├── .gitignore ├── .travis.yml ├── LICENSE ├── api-tests ├── Conduit.postman_collection.json └── run-api-tests.sh ├── apis.http ├── build.sbt ├── docker-compose-dev.yml ├── docker-compose.yml ├── logo.png ├── project ├── build.properties └── plugins.sbt ├── readme.md └── src ├── main ├── resources │ ├── application.conf │ ├── db │ │ └── migration │ │ │ ├── V01__users.sql │ │ │ ├── V02__followers.sql │ │ │ ├── V03__articles.sql │ │ │ ├── V04__tags.sql │ │ │ ├── V05__favorites.sql │ │ │ └── V06__comments.sql │ └── logback.xml └── scala │ ├── Main.scala │ ├── apis │ ├── ArticleApis.scala │ ├── CommentApis.scala │ ├── ProfileApis.scala │ ├── TagApis.scala │ ├── UserApis.scala │ └── package.scala │ ├── data.scala │ ├── repos │ ├── ArticleRepo.scala │ ├── CommentRepo.scala │ ├── FavoriteRepo.scala │ ├── FollowerRepo.scala │ ├── TagRepo.scala │ ├── UserRepo.scala │ └── package.scala │ ├── routes │ ├── ArticleRoutes.scala │ ├── CommentRoutes.scala │ ├── ProfileRoutes.scala │ ├── TagRoutes.scala │ ├── UserRoutes.scala │ └── package.scala │ ├── security.scala │ ├── utils.scala │ └── validation.scala └── test └── scala ├── WithEmbededDbTestSuite.scala ├── routes ├── ArticleRoutesTests.scala ├── CommentRoutesTests.scala ├── ProfileRoutesTests.scala ├── TagRoutesTests.scala ├── UserRoutesTests.scala └── package.scala ├── securityTests.scala ├── utilsTests.scala └── validationTests.scala /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /bower_components 6 | 7 | # IDEs and editors 8 | /.idea 9 | .project 10 | .classpath 11 | *.launch 12 | .settings/ 13 | .metals/ 14 | .bloop/ 15 | project/metals.sbt 16 | .projectile 17 | 18 | # System Files 19 | .DS_Store 20 | Thumbs.db 21 | 22 | # builds 23 | target/ 24 | 25 | # tmp file 26 | .#* 27 | 28 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: scala 2 | 3 | jdk: openjdk11 4 | 5 | scala: 6 | - 2.13.1 7 | 8 | services: 9 | - docker 10 | 11 | script: 12 | # run app tests 13 | - sbt ++$TRAVIS_SCALA_VERSION test 14 | # run postman tests 15 | - sbt docker:publishLocal 16 | - docker-compose up -d 17 | - APIURL=http://localhost:8080/api ./api-tests/run-api-tests.sh 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 wangzitian0 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /api-tests/run-api-tests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -x 3 | 4 | SCRIPTDIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )" 5 | 6 | APIURL=${APIURL:-https://conduit.productionready.io/api} 7 | USERNAME=${USERNAME:-u`date +%s`} 8 | EMAIL=${EMAIL:-$USERNAME@mail.com} 9 | PASSWORD=${PASSWORD:-password} 10 | 11 | npx newman run $SCRIPTDIR/Conduit.postman_collection.json \ 12 | --delay-request 500 \ 13 | --global-var "APIURL=$APIURL" \ 14 | --global-var "USERNAME=$USERNAME" \ 15 | --global-var "EMAIL=$EMAIL" \ 16 | --global-var "PASSWORD=$PASSWORD" 17 | -------------------------------------------------------------------------------- /apis.http: -------------------------------------------------------------------------------- 1 | # 2 | :base-url = http://localhost:8080/api 3 | 4 | # register 5 | POST :base-url/users 6 | Content-Type: application/json 7 | {"user":{"email":"user1@app.com", "username":"user1", "password":"password123"}} 8 | 9 | # login user 10 | POST :base-url/users/login 11 | Content-Type: application/json 12 | {"user":{"email":"user1@app.com", "password":"password123"}} 13 | 14 | # 15 | :jwt = eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE1ODQxMzk1MjAsImp0aSI6IjI4YjYxODQwYmYzOTNhZWM4ZTYwNDRhZGFmMjY0YWQ2IiwicGF5bG9hZCI6eyJhdXRoVXNlciI6M319.tiUNNXVQ4OyMaGni6tgLUVVHrRS0atpu-P-TakrjZeI 16 | 17 | # get current user 18 | GET :base-url/user 19 | Authorization: Token :jwt 20 | 21 | # update user 22 | PUT :base-url/user 23 | Authorization: Token :jwt 24 | Content-Type: application/json 25 | {"user":{"bio":"bio-bio-bio", "username":"user2","email":"user2@app.com"}} 26 | 27 | # get profile 28 | GET :base-url/profiles/user1 29 | Authorization: Token :jwt 30 | 31 | # follow 32 | POST :base-url/profiles/user2/follow 33 | Authorization: Token :jwt 34 | 35 | # unfollow 36 | DELETE :base-url/profiles/username7/follow 37 | Authorization: Token :jwt 38 | 39 | # get all articles 40 | GET :base-url/articles 41 | Authorization: Token :jwt 42 | 43 | # get feed 44 | GET :base-url/articles/feed 45 | Authorization: Token :jwt 46 | 47 | # get article by slug 48 | GET :base-url/articles/my-first-article-2-dsfdsf-oKAMRxA 49 | Authorization: Token :jwt 50 | 51 | # add article 52 | POST :base-url/articles 53 | Authorization: Token :jwt 54 | Content-Type: application/json 55 | {"article":{"title":"title123-213","description":"abs", "body":"sdf", "tagList":["hello", "world", "scala"]}} 56 | 57 | # update article 58 | PUT :base-url/articles/title1-z5vEb9 59 | Authorization: Token :jwt 60 | Content-Type: application/json 61 | {"article":{"body":"", "description":" "}} 62 | 63 | # delete article 64 | DELETE :base-url/articles/my-first-article-2-oB4wz2A 65 | Authorization: Token :jwt 66 | 67 | # favorite article 68 | POST :base-url/articles/my-first-article-2-dsfdsf-oKAMRxA/favorite 69 | Authorization: Token :jwt 70 | 71 | # unfavorite article 72 | DELETE :base-url/articles/my-first-article-2-dsfdsf-oKAMRxA/favorite 73 | Authorization: Token :jwt 74 | 75 | # add comment 76 | POST :base-url/articles/title123-213-2ENOvPq/comments 77 | Authorization: Token :jwt 78 | Content-Type: application/json 79 | {"comment":{"body":"ddd sdfasdf "}} 80 | 81 | # get comments 82 | GET :base-url/articles/title123-213-2ENOvPq/comments 83 | Authorization: Token :jwt 84 | 85 | # delete comments 86 | DELETE :base-url/articles/title1-z5vEb9/comments/9 87 | Authorization: Token :jwt 88 | 89 | # get tags 90 | GET :base-url/tags 91 | Authorization: Token :jwt -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | enablePlugins(JavaAppPackaging) 2 | 3 | val http4sVersion = "0.21.1" 4 | val circeVersion = "0.13.0" 5 | val doobieVersion = "0.8.8" 6 | val tSecVersion = "0.2.0" 7 | val hashidsVersion = "1.0.3" 8 | val pureConfigVersion = "0.12.3" 9 | val logbackVersion = "1.2.3" 10 | val uTestVertion = "0.7.4" 11 | val pgEmbededVersion = "0.13.3" 12 | val flywayVersion = "6.2.0" 13 | 14 | lazy val root = (project in file(".")) 15 | .settings( 16 | organization := "io.rw.app", 17 | name := "scala-http4s-realworld", 18 | version := "0.0.1", 19 | scalaVersion := "2.13.1", 20 | libraryDependencies ++= Seq( 21 | "org.http4s" %% "http4s-blaze-server" % http4sVersion, 22 | "org.http4s" %% "http4s-blaze-client" % http4sVersion, 23 | "org.http4s" %% "http4s-circe" % http4sVersion, 24 | "org.http4s" %% "http4s-dsl" % http4sVersion, 25 | 26 | "io.circe" %% "circe-generic" % circeVersion, 27 | 28 | "org.tpolecat" %% "doobie-core" % doobieVersion, 29 | "org.tpolecat" %% "doobie-postgres" % doobieVersion, 30 | "org.tpolecat" %% "doobie-hikari" % doobieVersion, 31 | 32 | "io.github.jmcardon" %% "tsec-common" % tSecVersion, 33 | "io.github.jmcardon" %% "tsec-password" % tSecVersion, 34 | "io.github.jmcardon" %% "tsec-jwt-mac" % tSecVersion, 35 | 36 | "org.hashids" % "hashids" % hashidsVersion, 37 | 38 | "com.github.pureconfig" %% "pureconfig" % pureConfigVersion, 39 | 40 | "ch.qos.logback" % "logback-classic" % logbackVersion, 41 | 42 | "com.lihaoyi" %% "utest" % uTestVertion % Test, 43 | "com.opentable.components" % "otj-pg-embedded" % pgEmbededVersion % Test, 44 | "org.flywaydb" % "flyway-core" % flywayVersion % Test 45 | ), 46 | testFrameworks += new TestFramework("utest.runner.Framework"), 47 | addCompilerPlugin("org.typelevel" %% "kind-projector" % "0.11.0" cross CrossVersion.full), 48 | addCompilerPlugin("com.olegpy" %% "better-monadic-for" % "0.3.1"), 49 | 50 | dockerBaseImage := "openjdk:11-jre-slim" 51 | ) 52 | 53 | scalacOptions ++= Seq( 54 | "-deprecation", 55 | "-encoding", "UTF-8", 56 | "-language:higherKinds", 57 | "-language:postfixOps", 58 | "-feature", 59 | "-Xfatal-warnings" 60 | ) 61 | -------------------------------------------------------------------------------- /docker-compose-dev.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | services: 4 | 5 | db-dev: 6 | image: postgres:latest 7 | restart: always 8 | ports: 9 | - 5432:5432 10 | 11 | migrate-dev: 12 | image: flyway/flyway 13 | command: -url=jdbc:postgresql://db-dev:5432/postgres -user=postgres -connectRetries=60 migrate 14 | volumes: 15 | - ./src/main/resources/db/migration:/flyway/sql 16 | depends_on: 17 | - db-dev 18 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | services: 4 | 5 | api: 6 | image: scala-http4s-realworld:0.0.1 7 | restart: always 8 | ports: 9 | - 8080:8080 10 | depends_on: 11 | - db 12 | - migrate 13 | environment: 14 | - "API_HOST=0.0.0.0" 15 | - "API_PORT=8080" 16 | - "ID_HASHER_SALT=8a51c03f1ff77c2b8e76da512070c23c5e69813d5c61732b3025199e5f0c14d5" 17 | - "JWT_TOKEN_KEY=8c3c13530da35492b627866080342dd9dd96bcf3f7858d5de9ee19c63f0a27e5" 18 | - "DB_USER=db_user" 19 | - "DB_PASSWORD=db_password" 20 | - "DB_HOST=db" 21 | - "DB_PORT=5432" 22 | 23 | db: 24 | image: postgres:latest 25 | restart: always 26 | ports: 27 | - 5432:5432 28 | environment: 29 | - "POSTGRES_USER=db_user" 30 | - "POSTGRES_PASSWORD=db_password" 31 | 32 | migrate: 33 | image: flyway/flyway 34 | command: -url=jdbc:postgresql://db:5432/postgres -user=db_user -password=db_password -connectRetries=60 migrate 35 | volumes: 36 | - ./src/main/resources/db/migration:/flyway/sql 37 | depends_on: 38 | - db 39 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alex-k1/scala-http4s-realworld-example-app/cabeb9ffdc7a9dc89f2306b16eeeb5097d8b2997/logo.png -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.3.4 2 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("io.spray" % "sbt-revolver" % "0.9.1") 2 | addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.6.1") 3 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # ![RealWorld Example App](logo.png) 2 | 3 | [![Build Status](https://travis-ci.org/alex-k1/scala-http4s-realworld-example-app.svg?branch=master)](https://travis-ci.org/alex-k1/scala-http4s-realworld-example-app) 4 | 5 | > ### Scala + http4s codebase containing real world examples (CRUD, auth, advanced patterns, etc) that adheres to the [RealWorld](https://github.com/gothinkster/realworld) spec and API. 6 | 7 | 8 | This codebase was created to demonstrate a fully fledged fullstack application built with **Scala + http4s** including CRUD operations, authentication, routing, pagination, and more. 9 | 10 | We've gone to great lengths to adhere to the **Scala + http4s** community styleguides & best practices. 11 | 12 | For more information on how to this works with other frontends/backends, head over to the [RealWorld](https://github.com/gothinkster/realworld) repo. 13 | 14 | 15 | # How it works 16 | 17 | ## The application stack 18 | 19 | - http4s 20 | - doobie 21 | - cats 22 | 23 | # Requirements 24 | 25 | - jdk 11 26 | - sbt 27 | - docker-compose 28 | 29 | # Getting started 30 | 31 | ## Run a local development 32 | 33 | ### Start a local database 34 | 35 | ``` 36 | docker-compose -f docker-compose-dev.yml up -d 37 | ``` 38 | 39 | ### Start the application server 40 | 41 | ``` 42 | sbt run 43 | ``` 44 | 45 | The server will start on `localhost:8080` 46 | 47 | ## Run with Docker 48 | 49 | ### Build an image 50 | 51 | ``` 52 | sbt docker:publishLocal 53 | ``` 54 | 55 | ### Start containers 56 | 57 | ``` 58 | docker-compose up -d 59 | ``` 60 | 61 | ## Run tests 62 | 63 | ``` 64 | sbt test 65 | ``` 66 | -------------------------------------------------------------------------------- /src/main/resources/application.conf: -------------------------------------------------------------------------------- 1 | api-host = "localhost" 2 | api-host = ${?API_HOST} 3 | api-port = 8080 4 | api-port = ${?API_PORT} 5 | 6 | id-hasher-salt = "salty-salt" 7 | id-hasher-salt = ${?ID_HASHER_SALT} 8 | 9 | jwt-token-key = "secret-token-key" 10 | jwt-token-key = ${?JWT_TOKEN_KEY} 11 | jwt-token-expiration = 10080 12 | 13 | db-user = "postgres" 14 | db-user = ${?DB_USER} 15 | db-password = "" 16 | db-password = ${?DB_PASSWORD} 17 | db-host = "localhost" 18 | db-host = ${?DB_HOST} 19 | db-port = 5432 20 | db-port = ${?DB_PORT} 21 | db-url = "jdbc:postgresql://"${db-host}":"${db-port}"/postgres" 22 | -------------------------------------------------------------------------------- /src/main/resources/db/migration/V01__users.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE users ( 2 | id serial PRIMARY KEY, 3 | email text UNIQUE NOT NULL, 4 | username text UNIQUE NOT NULL, 5 | password text NOT NULL, 6 | bio text, 7 | image text, 8 | created_at timestamp not null, 9 | updated_at timestamp not null 10 | ); 11 | -------------------------------------------------------------------------------- /src/main/resources/db/migration/V02__followers.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE followers ( 2 | followee_id integer NOT NULL REFERENCES users ON DELETE CASCADE, 3 | follower_id integer NOT NULL REFERENCES users ON DELETE CASCADE 4 | ); 5 | 6 | CREATE UNIQUE INDEX followers_udx01 ON followers (followee_id, follower_id); 7 | -------------------------------------------------------------------------------- /src/main/resources/db/migration/V03__articles.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE articles ( 2 | id serial PRIMARY KEY, 3 | slug text UNIQUE NOT NULL, 4 | title text NOT NULL, 5 | description text NOT NULL, 6 | body text NOT NULL, 7 | author_id integer NOT NULL REFERENCES users ON DELETE CASCADE, 8 | created_at timestamp NOT NULL, 9 | updated_at timestamp NOT NULL 10 | ); 11 | 12 | CREATE INDEX articles_idx01 ON articles (author_id); 13 | -------------------------------------------------------------------------------- /src/main/resources/db/migration/V04__tags.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE tags ( 2 | article_id integer NOT NULL REFERENCES articles ON DELETE CASCADE, 3 | tag text NOT NULL 4 | ); 5 | 6 | CREATE UNIQUE INDEX tags_udx01 ON tags (article_id, tag); 7 | 8 | CREATE INDEX tags_idx01 ON tags (article_id); 9 | CREATE INDEX tags_idx02 ON tags (tag); 10 | -------------------------------------------------------------------------------- /src/main/resources/db/migration/V05__favorites.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE favorites ( 2 | article_id integer NOT NULL REFERENCES articles ON DELETE CASCADE, 3 | user_id integer NOT NULL REFERENCES users ON DELETE CASCADE 4 | ); 5 | 6 | CREATE UNIQUE INDEX favorites_udx01 ON favorites (article_id, user_id); 7 | 8 | CREATE INDEX favorites_idx01 ON favorites (article_id); 9 | CREATE INDEX favorites_idx02 ON favorites (user_id); 10 | -------------------------------------------------------------------------------- /src/main/resources/db/migration/V06__comments.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE comments ( 2 | id serial PRIMARY KEY, 3 | body text NOT NULL, 4 | article_id integer NOT NULL REFERENCES articles ON DELETE CASCADE, 5 | author_id integer NOT NULL REFERENCES users ON DELETE CASCADE, 6 | created_at timestamp NOT NULL, 7 | updated_at timestamp NOT NULL 8 | ); 9 | 10 | CREATE INDEX comments_idx01 ON comments (article_id); 11 | CREATE INDEX comments_idx02 ON comments (article_id, author_id); 12 | -------------------------------------------------------------------------------- /src/main/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | true 9 | 10 | [%thread] %highlight(%-5level) %cyan(%logger{15}) - %msg %n 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/main/scala/Main.scala: -------------------------------------------------------------------------------- 1 | package io.rw.app 2 | 3 | import cats._ 4 | import cats.data._ 5 | import cats.effect._ 6 | import cats.implicits._ 7 | import doobie.util.transactor.Transactor 8 | import io.rw.app.apis._ 9 | import io.rw.app.data._ 10 | import io.rw.app.repos._ 11 | import io.rw.app.routes._ 12 | import io.rw.app.security._ 13 | import io.rw.app.utils._ 14 | import org.http4s._ 15 | import org.http4s.implicits._ 16 | import org.http4s.server._ 17 | import org.http4s.server.blaze._ 18 | import pureconfig._ 19 | import pureconfig.generic.auto._ 20 | import tsec.mac.jca.HMACSHA256 21 | 22 | 23 | object Main extends IOApp { 24 | 25 | def run(args: List[String]): IO[ExitCode] = { 26 | // app shouldn't event start if no config is there 27 | val config = ConfigSource.default.loadOrThrow[AppConfig] 28 | 29 | val passwordHasher = PasswordHasher.impl 30 | val idHasher = IdHasher.impl(config.idHasherSalt) 31 | 32 | // TODO use safe 33 | val key = HMACSHA256.unsafeBuildKey(config.jwtTokenKey.getBytes) 34 | val token = JwtToken.impl(key, config.jwtTokenExpiration) 35 | 36 | // TODO use hikaricp 37 | val xa = Transactor.fromDriverManager[IO]("org.postgresql.Driver", config.dbUrl, config.dbUser, config.dbPassword) 38 | val userRepo = UserRepo.impl(xa) 39 | val followerRepo = FollowerRepo.impl(xa) 40 | val tagRepo = TagRepo.impl(xa) 41 | val articleRepo = ArticleRepo.impl(xa) 42 | val favoriteRepo = FavoriteRepo.impl(xa) 43 | val commentRepo = CommentRepo.impl(xa) 44 | 45 | val userApis = UserApis.impl(passwordHasher, token, userRepo) 46 | val profileApis = ProfileApis.impl(userRepo, followerRepo) 47 | val articleApis = ArticleApis.impl(articleRepo, followerRepo, tagRepo, favoriteRepo, idHasher) 48 | val tagApis = TagApis.impl(tagRepo) 49 | val commentApis = CommentApis.impl(commentRepo, articleRepo, followerRepo) 50 | 51 | val routes = List(UserRoutes(userApis), ProfileRoutes(profileApis), ArticleRoutes(articleApis), CommentRoutes(commentApis), TagRoutes(tagApis)) 52 | val httpApp = mkHttpApp(routes, token) 53 | 54 | BlazeServerBuilder[IO].bindHttp(config.apiPort, config.apiHost).withHttpApp(httpApp).serve.compile.drain.as(ExitCode.Success) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/main/scala/apis/ArticleApis.scala: -------------------------------------------------------------------------------- 1 | package io.rw.app.apis 2 | 3 | import cats.data.OptionT 4 | import cats.implicits._ 5 | import cats.Monad 6 | import io.rw.app.data.{Entities => E, _} 7 | import io.rw.app.data.ApiErrors._ 8 | import io.rw.app.data.ApiInputs._ 9 | import io.rw.app.data.ApiOutputs._ 10 | import io.rw.app.repos._ 11 | import io.rw.app.utils._ 12 | import java.time.Instant 13 | import io.rw.app.data 14 | 15 | trait ArticleApis[F[_]] { 16 | def getAll(input: GetAllArticlesInput): F[ApiResult[GetAllArticlesOutput]] 17 | def getFeed(input: GetArticlesFeedInput): F[ApiResult[GetArticlesFeedOutput]] 18 | def get(input: GetArticleInput): F[ApiResult[GetArticleOutput]] 19 | def create(input: CreateArticleInput): F[ApiResult[CreateArticleOutput]] 20 | def update(input: UpdateArticleInput): F[ApiResult[UpdateArticleOutput]] 21 | def delete(input: DeleteArticleInput): F[ApiResult[DeleteArticleOutput]] 22 | def favorite(input: FavoriteArticleInput): F[ApiResult[FavoriteArticleOutput]] 23 | def unfavorite(input: UnfavoriteArticleInput): F[ApiResult[UnfavoriteArticleOutput]] 24 | } 25 | 26 | object ArticleApis { 27 | 28 | def impl[F[_] : Monad](articleRepo: ArticleRepo[F], followerRepo: FollowerRepo[F], tagRepo: TagRepo[F], favoriteRepo: FavoriteRepo[F], idHasher: IdHasher) = new ArticleApis[F] { 29 | 30 | def getAll(input: GetAllArticlesInput): F[ApiResult[GetAllArticlesOutput]] = { 31 | val articles = for { 32 | articlesWithAuthor <- articleRepo.findArticlesFilteredBy(input.filter, input.pagination) 33 | articleCounts <- articleRepo.countArticlesFilteredBy(input.filter) 34 | authorsIds = articlesWithAuthor.map(_._2.id) 35 | articlesIds = articlesWithAuthor.map(_._1.id) 36 | followers <- input.authUser.traverse(followerRepo.findFollowers(authorsIds, _)).map(_.getOrElse(List.empty)) 37 | extras <- articlesExtras(input.authUser, articlesIds) 38 | } yield (mkArticles(articlesWithAuthor, followers, extras), articleCounts) 39 | 40 | articles.map(p => Right(GetAllArticlesOutput(p._1, p._2))) 41 | } 42 | 43 | def getFeed(input: GetArticlesFeedInput): F[ApiResult[GetArticlesFeedOutput]] = { 44 | val articles = for { 45 | articlesWithAuthor <- articleRepo.findArticlesByFollower(input.authUser, input.pagination) 46 | articleCounts <- articleRepo.countArticlesForFollower(input.authUser) 47 | authorsIds = articlesWithAuthor.map(_._2.id) 48 | articlesIds = articlesWithAuthor.map(_._1.id) 49 | followers <- Monad[F].pure(authorsIds.map(E.Follower(_, input.authUser))) 50 | extras <- articlesExtras(Some(input.authUser), articlesIds) 51 | } yield (mkArticles(articlesWithAuthor, followers, extras), articleCounts) 52 | 53 | articles.map(p => Right(GetArticlesFeedOutput(p._1, p._2))) 54 | } 55 | 56 | def get(input: GetArticleInput): F[ApiResult[GetArticleOutput]] = { 57 | val article = for { 58 | (articleWithId, author) <- OptionT(articleRepo.findArticleBySlug(input.slug)) 59 | following <- OptionT.liftF(input.authUser.flatTraverse(followerRepo.findFollower(author.id, _)).map(_.nonEmpty)) 60 | (tags, favorited, favoritesCount) <- OptionT.liftF(articleExtra(input.authUser, articleWithId.id)) 61 | } yield mkArticle(articleWithId.entity, tags, favorited, favoritesCount, author.entity, following) 62 | 63 | article.value.map(_.map(GetArticleOutput).toRight(ArticleNotFound())) 64 | } 65 | 66 | def create(input: CreateArticleInput): F[ApiResult[CreateArticleOutput]] = { 67 | def mkArticleEntity(authorId: Int): E.Article = { 68 | val now = Instant.now 69 | E.Article(mkSlug(input.title, authorId, now), input.title, input.description, input.body, authorId, now, now) 70 | } 71 | 72 | val article = for { 73 | ((articleWithId, author), tags) <- articleRepo.createArticleWithTags(mkArticleEntity(input.authUser), input.tagList) 74 | } yield mkArticle(articleWithId.entity, tags, false, 0, author.entity, false) 75 | 76 | article.map(a => Right(CreateArticleOutput(a))) 77 | } 78 | 79 | def update(input: UpdateArticleInput): F[ApiResult[UpdateArticleOutput]] = { 80 | def mkArticleForUpdateEntity(authorId: Int): E.ArticleForUpdate = { 81 | val now = Instant.now 82 | E.ArticleForUpdate(input.title.map(mkSlug(_, authorId, now)), input.title, input.description, input.body, now) 83 | } 84 | 85 | val article = for { 86 | (articleWithId, author) <- OptionT(articleRepo.updateArticleBySlug(input.slug, input.authUser, mkArticleForUpdateEntity(input.authUser))) 87 | (tags, favorited, favoritesCount) <- OptionT.liftF(articleExtra(Some(input.authUser), articleWithId.id)) 88 | } yield mkArticle(articleWithId.entity, tags, favorited, favoritesCount, author.entity, false) 89 | 90 | article.value.map(_.map(UpdateArticleOutput).toRight(ArticleNotFound())) 91 | } 92 | 93 | def delete(input: DeleteArticleInput): F[ApiResult[DeleteArticleOutput]] = 94 | articleRepo.deleteArticleBySlug(input.slug, input.authUser).map(_.map(_ => DeleteArticleOutput()).toRight(ArticleNotFound())) 95 | 96 | def favorite(input: FavoriteArticleInput): F[ApiResult[FavoriteArticleOutput]] = { 97 | val article = for { 98 | (articleWithId, author) <- OptionT(articleRepo.findArticleBySlug(input.slug)) 99 | _ <- OptionT.liftF(favoriteRepo.createFavorite(E.Favorite(articleWithId.id, input.authUser))) 100 | following <- OptionT.liftF(followerRepo.findFollower(author.id, input.authUser).map(_.nonEmpty)) 101 | // TODO article just favorited no need to select again 102 | (tags, favorited, favoritesCount) <- OptionT.liftF(articleExtra(Some(input.authUser), articleWithId.id)) 103 | } yield mkArticle(articleWithId.entity, tags, favorited, favoritesCount, author.entity, following) 104 | 105 | article.value.map(_.map(FavoriteArticleOutput).toRight(ArticleNotFound())) 106 | } 107 | 108 | def unfavorite(input: UnfavoriteArticleInput): F[ApiResult[UnfavoriteArticleOutput]] = { 109 | val article = for { 110 | (articleWithId, author) <- OptionT(articleRepo.findArticleBySlug(input.slug)) 111 | _ <- OptionT.liftF(favoriteRepo.deleteFavorite(articleWithId.id, input.authUser)) 112 | following <- OptionT.liftF(followerRepo.findFollower(author.id, input.authUser).map(_.nonEmpty)) 113 | // TODO article just unfavorited no need to select again 114 | (tags, favorited, favoritesCount) <- OptionT.liftF(articleExtra(Some(input.authUser), articleWithId.id)) 115 | } yield mkArticle(articleWithId.entity, tags, favorited, favoritesCount, author.entity, following) 116 | 117 | article.value.map(_.map(UnfavoriteArticleOutput).toRight(ArticleNotFound())) 118 | } 119 | 120 | def articleExtra(authUser: Option[AuthUser], articleId: Int): F[(List[E.Tag], Boolean, Int)] = 121 | for { 122 | tags <- tagRepo.findTags(articleId) 123 | favorited <- authUser.flatTraverse(favoriteRepo.findFavorite(articleId, _)).map(_.nonEmpty) 124 | favoritesCount <- favoriteRepo.countFavorite(articleId) 125 | } yield (tags, favorited, favoritesCount) 126 | 127 | def articlesExtras(authUser: Option[AuthUser], articleIds: List[Int]): F[Map[Int, (List[E.Tag], Boolean, Int)]] = { 128 | def withExtra(id: Int, groupedTags: Map[Int, List[E.Tag]], groupedFavorites: Map[Int, List[E.Favorite]], groupedFavoriteCounts: Map[Int, Int]): (Int, (List[E.Tag], Boolean, Int)) = { 129 | val tags = groupedTags.getOrElse(id, List.empty) 130 | val favorite = groupedFavorites.get(id).nonEmpty 131 | val favoriteCounts = groupedFavoriteCounts.getOrElse(id, 0) 132 | id -> (tags, favorite, favoriteCounts) 133 | } 134 | 135 | val groupedExtras = for { 136 | tags <- tagRepo.findTags(articleIds) 137 | favorites <- authUser.traverse(favoriteRepo.findFavorites(articleIds, _)).map(_.getOrElse(List.empty)) 138 | favoriteCounts <- favoriteRepo.countFavorites(articleIds) 139 | } yield (tags.groupBy(_.articleId), favorites.groupBy(_.articleId), favoriteCounts.toMap) 140 | 141 | groupedExtras.map { case (groupedTags, groupedFavorites, groupedFavoriteCounts) => 142 | articleIds.map(withExtra(_, groupedTags, groupedFavorites, groupedFavoriteCounts)).toMap 143 | } 144 | } 145 | 146 | // same author cannot create article with the same name at exact the same time, so no collisions should be here 147 | def mkSlug(s: String, authorId: Int, time: Instant): String = 148 | slugify(s) + "-" + idHasher.hash(authorId + time.getNano) 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/main/scala/apis/CommentApis.scala: -------------------------------------------------------------------------------- 1 | package io.rw.app.apis 2 | 3 | import cats.data.OptionT 4 | import cats.implicits._ 5 | import cats.Monad 6 | import io.rw.app.data.{Entities => E, _} 7 | import io.rw.app.data.ApiErrors._ 8 | import io.rw.app.data.ApiInputs._ 9 | import io.rw.app.data.ApiOutputs._ 10 | import io.rw.app.repos._ 11 | import io.rw.app.utils._ 12 | import java.time.Instant 13 | 14 | trait CommentApis[F[_]] { 15 | def add(input: AddCommentInput): F[ApiResult[AddCommentOutput]] 16 | def get(input: GetCommentsInput): F[ApiResult[GetCommentsOutput]] 17 | def delete(input: DeleteCommentInput): F[ApiResult[DeleteCommentOutput]] 18 | } 19 | 20 | object CommentApis { 21 | 22 | def impl[F[_] : Monad](commentRepo: CommentRepo[F], articleRepo: ArticleRepo[F], followerRepo: FollowerRepo[F]) = new CommentApis[F] { 23 | 24 | def add(input: AddCommentInput): F[ApiResult[AddCommentOutput]] = { 25 | def mkCommentEntity(articleId: Int): E.Comment = { 26 | val now = Instant.now 27 | E.Comment(input.body, articleId, input.authUser, now, now) 28 | } 29 | 30 | val comment = for { 31 | (articleWithId, articleAuthor) <- OptionT(articleRepo.findArticleBySlug(input.slug)) 32 | (commentWithId, commentAuthor) <- OptionT.liftF(commentRepo.createComment(mkCommentEntity(articleWithId.id))) 33 | following <- OptionT.liftF(followerRepo.findFollower(articleAuthor.id, input.authUser).map(_.nonEmpty)) 34 | } yield mkComment(commentWithId, commentAuthor.entity, following) 35 | 36 | comment.value.map(_.map(AddCommentOutput)).map(_.toRight(ArticleNotFound())) 37 | } 38 | 39 | def get(input: GetCommentsInput): F[ApiResult[GetCommentsOutput]] = { 40 | val comments = for { 41 | (articleWithId, _) <- OptionT(articleRepo.findArticleBySlug(input.slug)) 42 | commentsWithAuthors <- OptionT.liftF(commentRepo.findCommentsByArticleId(articleWithId.id)) 43 | authorsIds = commentsWithAuthors.map(_._2.id) 44 | followers <- OptionT.liftF(input.authUser.traverse(followerRepo.findFollowers(authorsIds, _)).map(_.getOrElse(List.empty))) 45 | } yield mkComments(commentsWithAuthors, followers) 46 | 47 | comments.value.map(_.map(GetCommentsOutput)).map(_.toRight(ArticleNotFound())) 48 | } 49 | 50 | def delete(input: DeleteCommentInput): F[ApiResult[DeleteCommentOutput]] = { 51 | val deleted = for { 52 | (articleWithId, _) <- OptionT(articleRepo.findArticleBySlug(input.slug)) 53 | _ <- OptionT(commentRepo.deleteComment(input.commentId, articleWithId.id, input.authUser)) 54 | } yield () 55 | 56 | deleted.value.map(_.map(_ => DeleteCommentOutput())).map(_.toRight(CommentNotFound())) 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/main/scala/apis/ProfileApis.scala: -------------------------------------------------------------------------------- 1 | package io.rw.app.apis 2 | 3 | import cats.data.{EitherT, OptionT} 4 | import cats.implicits._ 5 | import cats.Monad 6 | import io.rw.app.data.{Entities => E, _} 7 | import io.rw.app.data.ApiErrors._ 8 | import io.rw.app.data.ApiInputs._ 9 | import io.rw.app.data.ApiOutputs._ 10 | import java.time.Instant 11 | import io.rw.app.repos._ 12 | import io.rw.app.security.{JwtToken, PasswordHasher} 13 | import io.rw.app.data 14 | 15 | trait ProfileApis[F[_]] { 16 | def get(input: GetProfileInput): F[ApiResult[GetProfileOutput]] 17 | def follow(input: FollowUserInput): F[ApiResult[FollowUserOutput]] 18 | def unfollow(input: UnfollowUserInput): F[ApiResult[UnfollowUserOutput]] 19 | } 20 | 21 | object ProfileApis { 22 | 23 | def impl[F[_] : Monad](userRepo: UserRepo[F], followerRepo: FollowerRepo[F]) = new ProfileApis[F]() { 24 | 25 | def get(input: GetProfileInput): F[ApiResult[GetProfileOutput]] = { 26 | val profile = for { 27 | userWithId <- OptionT(userRepo.findUserByUsername(input.username)) 28 | following <- OptionT.liftF(input.authUser.flatTraverse(followerRepo.findFollower(userWithId.id, _)).map(_.nonEmpty)) 29 | } yield mkProfile(userWithId.entity, following) 30 | 31 | profile.value.map(_.map(GetProfileOutput).toRight(ProfileNotFound())) 32 | } 33 | 34 | def follow(input: FollowUserInput): F[ApiResult[FollowUserOutput]] = { 35 | val profile = for { 36 | userWithId <- EitherT.fromOptionF(userRepo.findUserByUsername(input.username), ProfileNotFound()) 37 | _ <- EitherT.cond[F](input.authUser != userWithId.id, (), UserFollowingHimself(mkProfile(userWithId.entity, false))) 38 | _ <- EitherT.liftF[F, ApiError, E.Follower](followerRepo.createFollower(E.Follower(userWithId.id, input.authUser))) 39 | } yield mkProfile(userWithId.entity, true) 40 | 41 | profile.value.map(_.recover({ case UserFollowingHimself(p) => p }).map(FollowUserOutput(_))) 42 | } 43 | 44 | def unfollow(input: UnfollowUserInput): F[ApiResult[UnfollowUserOutput]] = { 45 | val profile = for { 46 | userWithId <- EitherT.fromOptionF(userRepo.findUserByUsername(input.username), ProfileNotFound()) 47 | _ <- EitherT.cond[F](input.authUser != userWithId.id, (), UserUnfollowingHimself(mkProfile(userWithId.entity, false))) 48 | _ <- EitherT.liftF[F, ApiError, Unit](followerRepo.deleteFollower(userWithId.id, input.authUser)) 49 | } yield mkProfile(userWithId.entity, false) 50 | 51 | profile.value.map(_.recover({ case UserUnfollowingHimself(p) => p }).map(UnfollowUserOutput(_))) 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/main/scala/apis/TagApis.scala: -------------------------------------------------------------------------------- 1 | package io.rw.app.apis 2 | 3 | import cats.data.OptionT 4 | import cats.implicits._ 5 | import cats.Monad 6 | import io.rw.app.data.{Entities => E, _} 7 | import io.rw.app.data.ApiErrors._ 8 | import io.rw.app.data.ApiInputs._ 9 | import io.rw.app.data.ApiOutputs._ 10 | import io.rw.app.repos._ 11 | 12 | trait TagApis[F[_]] { 13 | def get(input: GetTagsInput): F[ApiResult[GetTagsOutput]] 14 | } 15 | 16 | object TagApis { 17 | 18 | def impl[F[_] : Monad](tagRepo: TagRepo[F]) = new TagApis[F] { 19 | def get(input: GetTagsInput): F[ApiResult[GetTagsOutput]] = 20 | tagRepo.findPopularTags().map(tags => Right(GetTagsOutput(tags))) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/main/scala/apis/UserApis.scala: -------------------------------------------------------------------------------- 1 | package io.rw.app.apis 2 | 3 | import cats.data.{EitherT, OptionT} 4 | import cats.implicits._ 5 | import cats.Monad 6 | import io.rw.app.data.{Entities => E, _} 7 | import io.rw.app.data.ApiErrors._ 8 | import io.rw.app.data.ApiInputs._ 9 | import io.rw.app.data.ApiOutputs._ 10 | import io.rw.app.repos._ 11 | import io.rw.app.security.{JwtToken, PasswordHasher} 12 | import java.time.Instant 13 | 14 | trait UserApis[F[_]] { 15 | def authenticate(input: AuthenticateUserInput): F[ApiResult[AuthenticateUserOutput]] 16 | def register(input: RegisterUserInput): F[ApiResult[RegisterUserOutput]] 17 | def get(input: GetUserInput): F[ApiResult[GetUserOutput]] 18 | def update(input: UpdateUserInput): F[ApiResult[UpdateUserOutput]] 19 | } 20 | 21 | object UserApis { 22 | 23 | def impl[F[_] : Monad](passwordHasher: PasswordHasher[F], token: JwtToken[F], userRepo: UserRepo[F]) = new UserApis[F]() { 24 | 25 | def authenticate(input: AuthenticateUserInput): F[ApiResult[AuthenticateUserOutput]] = { 26 | val userWithToken = for { 27 | userWithId <- OptionT(userRepo.findUserByEmail(input.email)) 28 | _ <- OptionT(passwordHasher.validate(input.password, userWithId.entity.password).map(if (_) Some(true) else None)) 29 | token <- OptionT.liftF(token.generate(JwtTokenPayload(userWithId.id))) 30 | } yield mkUser(userWithId.entity, token) 31 | 32 | userWithToken.value.map(_.map(AuthenticateUserOutput).toRight(UserNotFoundOrPasswordNotMatched())) 33 | } 34 | 35 | def register(input: RegisterUserInput): F[ApiResult[RegisterUserOutput]] = { 36 | def mkUserEntity(hashedPassword: String): E.User = { 37 | val now = Instant.now 38 | E.User(input.email, input.username, hashedPassword, None, None, now, now) 39 | } 40 | 41 | val userWithToken = for { 42 | _ <- EitherT(emailAndUsernameNotExist(input.email, input.username)) 43 | hashedPsw <- EitherT.liftF(passwordHasher.hash(input.password)) 44 | userWithId <- EitherT.liftF(userRepo.createUser(mkUserEntity(hashedPsw))) 45 | token <- EitherT.liftF[F, ApiError, String](token.generate(JwtTokenPayload(userWithId.id))) 46 | } yield mkUser(userWithId.entity, token) 47 | 48 | userWithToken.value.map(_.map(RegisterUserOutput)) 49 | } 50 | 51 | def get(input: GetUserInput): F[ApiResult[GetUserOutput]] = { 52 | val userWithToken = for { 53 | userWithId <- OptionT(userRepo.findUserById(input.authUser)) 54 | token <- OptionT.liftF(token.generate(JwtTokenPayload(userWithId.id))) 55 | } yield mkUser(userWithId.entity, token) 56 | 57 | userWithToken.value.map(_.map(GetUserOutput).toRight(UserNotFound())) 58 | } 59 | 60 | def update(input: UpdateUserInput): F[ApiResult[UpdateUserOutput]] = { 61 | def mkUserForUpdateEntity(hashedPassword: Option[String]): E.UserForUpdate = { 62 | val now = Instant.now 63 | E.UserForUpdate(input.username, input.email, hashedPassword, input.bio, input.image, now) 64 | } 65 | 66 | val userWithToken = for { 67 | _ <- input.email.traverse(e => EitherT(emailNotTakenByOthers(e, input.authUser))) 68 | _ <- input.username.traverse(u => EitherT(usernameNotTakenByOthers(u, input.authUser))) 69 | hashedPsw <- EitherT.liftF(input.password.traverse(passwordHasher.hash)) 70 | userWithId <- EitherT.liftF(userRepo.updateUser(input.authUser, mkUserForUpdateEntity(hashedPsw))) 71 | token <- EitherT.liftF[F, ApiError, String](token.generate(JwtTokenPayload(userWithId.id))) 72 | } yield mkUser(userWithId.entity, token) 73 | 74 | userWithToken.value.map(_.map(UpdateUserOutput)) 75 | } 76 | 77 | def emailAndUsernameNotExist(email: String, username: String): F[Either[ApiError, Boolean]] = { 78 | val notExists = for { 79 | emailNotExists <- EitherT(emailNotExists(email)) 80 | usernameNotExists <- EitherT(usernameNotExists(username)) 81 | } yield (emailNotExists && usernameNotExists) 82 | 83 | notExists.value 84 | } 85 | 86 | def notExists(user: Option[E.WithId[E.User]], error: ApiError): Either[ApiError, Boolean] = 87 | if (user.isEmpty) Right(true) else Left(error) 88 | 89 | def emailNotExists(email: String): F[Either[ApiError, Boolean]] = 90 | userRepo.findUserByEmail(email).map(notExists(_, EmailAlreadyExists())) 91 | 92 | def usernameNotExists(username: String): F[Either[ApiError, Boolean]] = 93 | userRepo.findUserByUsername(username).map(notExists(_, UsernameAlreadyExists())) 94 | 95 | def notTakenByOthers(user: Option[E.WithId[E.User]], authUser: AuthUser, error: ApiError): Either[ApiError, Boolean] = 96 | if (user.isEmpty || user.map(_.id).getOrElse(authUser) == authUser) Right(true) else Left(error) 97 | 98 | def emailNotTakenByOthers(email: String, authUser: AuthUser): F[Either[ApiError, Boolean]] = 99 | userRepo.findUserByEmail(email).map(notTakenByOthers(_, authUser, EmailAlreadyExists())) 100 | 101 | def usernameNotTakenByOthers(username: String, authUser: AuthUser): F[Either[ApiError, Boolean]] = 102 | userRepo.findUserByUsername(username).map(notTakenByOthers(_, authUser, UsernameAlreadyExists())) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/main/scala/apis/package.scala: -------------------------------------------------------------------------------- 1 | package io.rw.app 2 | 3 | import io.rw.app.data.{Entities => E, _} 4 | 5 | package object apis { 6 | 7 | def mkUser(user: E.User, token: String): User = 8 | User(user.email, token, user.username, user.bio, user.image) 9 | 10 | def mkProfile(user: E.User, following: Boolean): Profile = 11 | Profile(user.username, user.bio, user.image, following) 12 | 13 | def mkArticle(article: E.Article, tags: List[E.Tag], favorited: Boolean, favoritesCount: Int, user: E.User, following: Boolean): Article = 14 | Article(article.slug, article.title, article.description, article.body, tags.map(mkTag), article.createdAt, article.updatedAt, favorited, favoritesCount, mkProfile(user, following)) 15 | 16 | def mkArticles(articleWithAuthors: List[(E.WithId[E.Article], E.WithId[E.User])], followers: List[E.Follower], extras: Map[Int, (List[E.Tag], Boolean, Int)]): List[Article] = { 17 | val groupedFollowers = followers.groupBy(_.followeeId) 18 | 19 | def withExtras(article: E.WithId[E.Article], author: E.WithId[E.User]): Article = { 20 | val following = groupedFollowers.get(article.entity.authorId).nonEmpty 21 | val (tags, favorited, favoritesCount) = extras.getOrElse(article.id, (List.empty, false, 0)) 22 | mkArticle(article.entity, tags, favorited, favoritesCount, author.entity, following) 23 | } 24 | 25 | articleWithAuthors.map({case (article, author) => withExtras(article, author)}) 26 | } 27 | 28 | def mkComment(comment: E.WithId[E.Comment], author: E.User, following: Boolean): Comment = 29 | Comment(comment.id, comment.entity.createdAt, comment.entity.updatedAt, comment.entity.body, mkProfile(author, following)) 30 | 31 | def mkComments(commentsWithAuthors: List[(E.WithId[E.Comment], E.WithId[E.User])], followers: List[E.Follower]): List[Comment] = { 32 | val groupedFollowers = followers.groupBy(_.followeeId) 33 | 34 | def withFollowing(comment: E.WithId[E.Comment], author: E.WithId[E.User]): Option[Comment] = 35 | for { 36 | following <- Some(groupedFollowers.get(author.id).nonEmpty) 37 | } yield mkComment(comment, author.entity, following) 38 | 39 | commentsWithAuthors.map({case (comment, author) => withFollowing(comment, author)}).collect { case Some(c) => c } 40 | } 41 | 42 | def mkTag(tag: E.Tag): String = 43 | tag.tag 44 | } 45 | -------------------------------------------------------------------------------- /src/main/scala/data.scala: -------------------------------------------------------------------------------- 1 | package io.rw.app 2 | 3 | import java.time.Instant 4 | 5 | object data { 6 | 7 | type ApiResult[R <: ApiOutput] = Either[ApiError, R] 8 | type AuthUser = Int 9 | 10 | object RequestBodies { 11 | case class WrappedUserBody[T](user: T) 12 | case class AuthenticateUserBody(email: String, password: String) 13 | case class RegisterUserBody(username: String, email: String, password: String) 14 | case class UpdateUserBody(username: Option[String], email: Option[String], password: Option[String], bio: Option[String], image: Option[String]) 15 | case class WrappedArticleBody[T](article: T) 16 | case class CreateArticleBody(title: String, description: String, body: String, tagList: Option[List[String]]) 17 | case class UpdateArticleBody(title: Option[String], description: Option[String], body: Option[String]) 18 | case class WrappedCommentBody[T](comment: T) 19 | case class AddCommentBody(body: String) 20 | } 21 | 22 | sealed trait ApiInput 23 | object ApiInputs { 24 | case class AuthenticateUserInput(email: String, password: String) extends ApiInput 25 | case class RegisterUserInput(username: String, email: String, password: String) extends ApiInput 26 | case class GetUserInput(authUser: AuthUser) extends ApiInput 27 | case class UpdateUserInput(authUser: AuthUser, username: Option[String], email: Option[String], password: Option[String], bio: Option[String], image: Option[String]) extends ApiInput 28 | case class GetProfileInput(authUser: Option[AuthUser], username: String) extends ApiInput 29 | case class FollowUserInput(authUser: AuthUser, username: String) extends ApiInput 30 | case class UnfollowUserInput(authUser: AuthUser, username: String) extends ApiInput 31 | case class GetAllArticlesInput(authUser: Option[AuthUser], filter: ArticleFilter, pagination: Pagination) extends ApiInput 32 | case class GetArticlesFeedInput(authUser: AuthUser, pagination: Pagination) extends ApiInput 33 | case class GetArticleInput(authUser: Option[AuthUser], slug: String) extends ApiInput 34 | case class CreateArticleInput(authUser: AuthUser, title: String, description: String, body: String, tagList: List[String]) extends ApiInput 35 | case class UpdateArticleInput(authUser: AuthUser, slug: String, title: Option[String], description: Option[String], body: Option[String]) extends ApiInput 36 | case class DeleteArticleInput(authUser: AuthUser, slug: String) extends ApiInput 37 | case class FavoriteArticleInput(authUser: AuthUser, slug: String) extends ApiInput 38 | case class UnfavoriteArticleInput(authUser: AuthUser, slug: String) extends ApiInput 39 | case class AddCommentInput(authUser: AuthUser, slug: String, body: String) extends ApiInput 40 | case class GetCommentsInput(authUser: Option[AuthUser], slug: String) extends ApiInput 41 | case class DeleteCommentInput(authUser: AuthUser, slug: String, commentId: Int) extends ApiInput 42 | case class GetTagsInput() extends ApiInput 43 | } 44 | 45 | sealed trait ApiOutput 46 | object ApiOutputs { 47 | case class AuthenticateUserOutput(user: User) extends ApiOutput 48 | case class RegisterUserOutput(user: User) extends ApiOutput 49 | case class GetUserOutput(user: User) extends ApiOutput 50 | case class UpdateUserOutput(user: User) extends ApiOutput 51 | case class GetProfileOutput(profile: Profile) extends ApiOutput 52 | case class FollowUserOutput(profile: Profile) extends ApiOutput 53 | case class UnfollowUserOutput(profile: Profile) extends ApiOutput 54 | case class GetAllArticlesOutput(articles: List[Article], articlesCount: Int) extends ApiOutput 55 | case class GetArticlesFeedOutput(articles: List[Article], articlesCount: Int) extends ApiOutput 56 | case class GetArticleOutput(article: Article) extends ApiOutput 57 | case class CreateArticleOutput(article: Article) extends ApiOutput 58 | case class UpdateArticleOutput(article: Article) extends ApiOutput 59 | case class DeleteArticleOutput() extends ApiOutput 60 | case class FavoriteArticleOutput(article: Article) extends ApiOutput 61 | case class UnfavoriteArticleOutput(article: Article) extends ApiOutput 62 | case class AddCommentOutput(comment: Comment) extends ApiOutput 63 | case class GetCommentsOutput(comments: List[Comment]) extends ApiOutput 64 | // TODO return {} instead of null 65 | case class DeleteCommentOutput() extends ApiOutput 66 | case class GetTagsOutput(tags: List[String]) extends ApiOutput 67 | } 68 | 69 | sealed trait ApiError 70 | object ApiErrors { 71 | case class UserNotFound() extends ApiError 72 | case class UserFollowingHimself(profile: Profile) extends ApiError 73 | case class UserUnfollowingHimself(profile: Profile) extends ApiError 74 | case class UserNotFoundOrPasswordNotMatched() extends ApiError 75 | case class EmailAlreadyExists() extends ApiError 76 | case class UsernameAlreadyExists() extends ApiError 77 | case class ProfileNotFound() extends ApiError 78 | case class ArticleNotFound() extends ApiError 79 | case class CommentNotFound() extends ApiError 80 | } 81 | 82 | object Entities { 83 | case class WithId[T](id: Int, entity: T) 84 | case class User(email: String, username: String, password: String, bio: Option[String], image: Option[String], createdAt: Instant, updatedAt: Instant) 85 | case class UserForUpdate(username: Option[String], email: Option[String], password: Option[String], bio: Option[String], image: Option[String], updatedAt: Instant) 86 | case class Follower(followeeId: Int, followerId: Int) 87 | case class Article(slug: String, title: String, description: String, body: String, authorId: Int, createdAt: Instant, updatedAt: Instant) 88 | case class ArticleForUpdate(slug: Option[String], title: Option[String], description: Option[String], body: Option[String], updatedAt: Instant) 89 | case class Tag(articleId: Int, tag: String) 90 | case class Favorite(articleId: Int, userId: Int) 91 | case class Comment(body: String, articleId: Int, authorId: Int, createdAt: Instant, updatedAt: Instant) 92 | } 93 | 94 | case class AppConfig(apiHost: String, apiPort: Int, idHasherSalt: String, jwtTokenKey: String, jwtTokenExpiration: Int, dbUser: String, dbPassword: String, dbUrl: String) 95 | 96 | case class JwtTokenPayload(authUser: AuthUser) 97 | case class ArticleFilter(tag: Option[String], author: Option[String], favorited: Option[String]) 98 | case class Pagination(limit: Int, offset: Int) 99 | case class User(email: String, token: String, username: String, bio: Option[String] = None, image: Option[String] = None) 100 | case class Profile(username: String, bio: Option[String], image: Option[String], following: Boolean) 101 | case class Article(slug: String, title: String, description: String, body: String, tagList: List[String], createdAt: Instant, updatedAt: Instant, favorited: Boolean, favoritesCount: Int, author: Profile) 102 | case class Comment(id: Int, createdAt: Instant, updatedAt: Instant, body: String, author: Profile) 103 | 104 | type ValidationErrors = Map[String, List[String]] 105 | case class ValidationErrorResponse(errors: ValidationErrors) 106 | case class NotFoundResponse(status: Int, error: String) 107 | } 108 | -------------------------------------------------------------------------------- /src/main/scala/repos/ArticleRepo.scala: -------------------------------------------------------------------------------- 1 | package io.rw.app.repos 2 | 3 | import cats.data.OptionT 4 | import cats.effect.IO 5 | import cats.implicits._ 6 | import doobie._ 7 | import doobie.Fragments._ 8 | import doobie.implicits._ 9 | import doobie.implicits.legacy.instant._ 10 | import io.rw.app.data.Entities._ 11 | import io.rw.app.data.{ArticleFilter, Pagination} 12 | 13 | trait ArticleRepo[F[_]] { 14 | def findArticleBySlug(slug: String): F[Option[(WithId[Article], WithId[User])]] 15 | def findArticlesFilteredBy(filter: ArticleFilter, pagination: Pagination): F[List[(WithId[Article], WithId[User])]] 16 | def findArticlesByFollower(followerId: Int, pagination: Pagination): F[List[(WithId[Article], WithId[User])]] 17 | def countArticlesFilteredBy(filter: ArticleFilter): F[Int] 18 | def countArticlesForFollower(followerId: Int): F[Int] 19 | def createArticleWithTags(article: Article, tags: List[String]): F[((WithId[Article], WithId[User]), List[Tag])] 20 | def updateArticleBySlug(slug: String, authorId: Int, article: ArticleForUpdate): F[Option[(WithId[Article], WithId[User])]] 21 | def deleteArticleBySlug(slug: String, authorId: Int): F[Option[Unit]] 22 | } 23 | 24 | object ArticleRepo { 25 | 26 | def impl(xa: Transactor[IO]) = new ArticleRepo[IO] { 27 | def findArticleBySlug(slug: String): IO[Option[(WithId[Article], WithId[User])]] = 28 | Q.selectArticleBySlug(slug).option.transact(xa) 29 | 30 | def findArticlesFilteredBy(filter: ArticleFilter, pagination: Pagination): IO[List[(WithId[Article], WithId[User])]] = 31 | Q.selectArticlesFilteredBy(filter, pagination).to[List].transact(xa) 32 | 33 | def findArticlesByFollower(followerId: Int, pagination: Pagination): IO[List[(WithId[Article], WithId[User])]] = 34 | Q.selectArticlesByFollower(followerId, pagination).to[List].transact(xa) 35 | 36 | def countArticlesFilteredBy(filter: ArticleFilter): IO[Int] = 37 | Q.countArticlesFilteredBy(filter).unique.transact(xa) 38 | 39 | def countArticlesForFollower(followerId: Int): IO[Int] = 40 | Q.countArticlesForFollower(followerId).unique.transact(xa) 41 | 42 | def createArticleWithTags(article: Article, tags: List[String]): IO[((WithId[Article], WithId[User]), List[Tag])] = { 43 | val trx = for { 44 | id <- Q.insertArticle(article).withUniqueGeneratedKeys[Int]("id") 45 | tagsEntities = tags.distinct.map(Tag(id, _)) 46 | _ <- Q.insertTags.updateMany(tagsEntities) 47 | article <- Q.selectArticleById(id).unique 48 | } yield (article, tagsEntities) 49 | 50 | trx.transact(xa) 51 | } 52 | 53 | def updateArticleBySlug(slug: String, authorId: Int, article: ArticleForUpdate): IO[Option[(WithId[Article], WithId[User])]] = { 54 | val trx = for { 55 | _ <- OptionT(Q.updateArticleBySlug(slug, authorId, article).run.map(affectedToOption)) 56 | article <- OptionT(Q.selectArticleBySlug(article.slug.getOrElse(slug)).option) 57 | } yield article 58 | 59 | trx.value.transact(xa) 60 | } 61 | 62 | def deleteArticleBySlug(slug: String, authorId: Int): IO[Option[Unit]] = 63 | Q.deleteArticleBySlug(slug, authorId).run.map(affectedToOption).transact(xa) 64 | } 65 | 66 | object Q { 67 | def selectArticleById(id: Int) = { 68 | val q = articleJoinUser ++ 69 | fr""" 70 | where a.id = $id 71 | """ 72 | q.query[(WithId[Article], WithId[User])] 73 | } 74 | 75 | def selectArticleBySlug(slug: String) = { 76 | val q = articleJoinUser ++ 77 | fr""" 78 | where a.slug = $slug 79 | """ 80 | q.query[(WithId[Article], WithId[User])] 81 | } 82 | 83 | def selectArticlesFilteredBy(filter: ArticleFilter, pagination: Pagination) = { 84 | val q = 85 | articleJoinUser ++ 86 | whereWithFilter(filter) ++ 87 | recentWithPagination(pagination) 88 | 89 | q.query[(WithId[Article], WithId[User])] 90 | } 91 | 92 | def selectArticlesByFollower(followerId: Int, pagination: Pagination) = { 93 | val q = articleJoinUser ++ 94 | articlesForFollower(followerId) ++ 95 | recentWithPagination(pagination) 96 | 97 | q.query[(WithId[Article], WithId[User])] 98 | } 99 | 100 | def countArticlesFilteredBy(filter: ArticleFilter) = { 101 | val q = 102 | fr""" 103 | select count(1) 104 | from articles a 105 | inner join users u on a.author_id = u.id 106 | """ ++ whereWithFilter(filter) 107 | q.query[Int] 108 | } 109 | 110 | def countArticlesForFollower(followerId: Int) = { 111 | val q = 112 | fr""" 113 | select count(1) 114 | from articles a 115 | """ ++ articlesForFollower(followerId) 116 | q.query[Int] 117 | } 118 | 119 | def insertArticle(article: Article) = 120 | sql""" 121 | insert into articles(slug, title, description, body, author_id, created_at, updated_at) 122 | values(${article.slug}, ${article.title}, ${article.description}, ${article.body}, ${article.authorId}, ${article.createdAt}, ${article.updatedAt}) 123 | """.update 124 | 125 | val insertTags = { 126 | val q = """ 127 | insert into tags(article_id, tag) 128 | values(?, ?) 129 | """ 130 | Update[Tag](q) 131 | } 132 | 133 | def updateArticleBySlug(slug: String, authorId: Int, article: ArticleForUpdate) = 134 | sql""" 135 | update articles a 136 | set slug = coalesce(${article.slug}, a.slug), 137 | title = coalesce(${article.title}, a.title), 138 | description = coalesce(${article.description}, a.description), 139 | body = coalesce(${article.body}, a.body), 140 | updated_at = ${article.updatedAt} 141 | where slug = $slug 142 | and a.author_id = $authorId 143 | """.update 144 | 145 | def deleteArticleBySlug(slug: String, authorId: Int) = 146 | sql""" 147 | delete from articles 148 | where slug = $slug 149 | and author_id = $authorId 150 | """.update 151 | 152 | val articleJoinUser = 153 | fr""" 154 | select a.id, a.slug, a.title, a.description, a.body, a.author_id, a.created_at, a.updated_at, 155 | u.id, u.email, u.username, u.password, u.bio, u.image, u.created_at, u.updated_at 156 | from articles a 157 | inner join users u on a.author_id = u.id 158 | """ 159 | 160 | def whereWithFilter(filter: ArticleFilter) = { 161 | val tag = filter.tag.map(t => fr"a.id in (select distinct article_id from tags where tag = $t)") 162 | val author = filter.author.map(a => fr"u.username = $a") 163 | val favorited = filter.favorited.map(f => fr"a.id in (select distinct f.article_id from favorites f inner join users uu on f.user_id = uu.id where uu.username = $f)") 164 | 165 | whereAndOpt(tag, author, favorited) 166 | } 167 | 168 | def recentWithPagination(pagination: Pagination) = 169 | fr""" 170 | order by a.created_at desc 171 | limit ${pagination.limit} offset ${pagination.offset} 172 | """ 173 | 174 | def articlesForFollower(followerId: Int) = 175 | fr""" 176 | where a.author_id in (select distinct followee_id from followers where follower_id = $followerId) 177 | """ 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /src/main/scala/repos/CommentRepo.scala: -------------------------------------------------------------------------------- 1 | package io.rw.app.repos 2 | 3 | import cats.effect.IO 4 | import doobie._ 5 | import doobie.implicits._ 6 | import doobie.implicits.legacy.instant._ 7 | import io.rw.app.data.Entities._ 8 | 9 | trait CommentRepo[F[_]] { 10 | def findCommentsByArticleId(articleId: Int): F[List[(WithId[Comment], WithId[User])]] 11 | def createComment(comment: Comment): F[(WithId[Comment], WithId[User])] 12 | def deleteComment(commentId: Int, articleId: Int, authorId: Int): F[Option[Unit]] 13 | } 14 | 15 | object CommentRepo { 16 | 17 | def impl(xa: Transactor[IO]) = new CommentRepo[IO] { 18 | def findCommentsByArticleId(articleId: Int): IO[List[(WithId[Comment], WithId[User])]] = 19 | Q.selectCommentsByArticleId(articleId).to[List].transact(xa) 20 | 21 | def createComment(comment: Comment): IO[(WithId[Comment], WithId[User])] = { 22 | val trx = for { 23 | id <- Q.insertComment(comment).withUniqueGeneratedKeys[Int]("id") 24 | comment <- Q.selectCommentById(id).unique 25 | } yield comment 26 | 27 | trx.transact(xa) 28 | } 29 | 30 | def deleteComment(commentId: Int, articleId: Int, authorId: Int): IO[Option[Unit]] = 31 | Q.deleteComment(commentId, articleId, authorId).run.map(affectedToOption).transact(xa) 32 | 33 | } 34 | 35 | object Q { 36 | def selectCommentsByArticleId(articleId: Int) = { 37 | val q = commentJoinUser ++ 38 | fr""" 39 | where c.article_id = $articleId 40 | order by c.created_at desc 41 | """ 42 | q.query[(WithId[Comment], WithId[User])] 43 | } 44 | 45 | def selectCommentById(id: Int) = { 46 | val q = commentJoinUser ++ 47 | fr""" 48 | where c.id = $id 49 | """ 50 | q.query[(WithId[Comment], WithId[User])] 51 | } 52 | 53 | def insertComment(comment: Comment) = 54 | sql""" 55 | insert into comments(body, article_id, author_id, created_at, updated_at) 56 | values(${comment.body}, ${comment.articleId}, ${comment.authorId}, ${comment.createdAt}, ${comment.updatedAt}) 57 | """.update 58 | 59 | def deleteComment(commentId: Int, articleId: Int, authorId: Int) = 60 | sql""" 61 | delete from comments 62 | where id = $commentId 63 | and article_id = $articleId 64 | and author_id = $authorId 65 | """.update 66 | 67 | val commentJoinUser = 68 | fr""" 69 | select c.id, c.body, c.article_id, c.author_id, c.created_at, c.updated_at, 70 | u.id, u.email, u.username, u.password, u.bio, u.image, u.created_at, u.updated_at 71 | from comments c 72 | inner join users u on c.author_id = u.id 73 | """ 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/main/scala/repos/FavoriteRepo.scala: -------------------------------------------------------------------------------- 1 | package io.rw.app.repos 2 | 3 | import cats.data.NonEmptyList 4 | import cats.effect.IO 5 | import cats.Functor 6 | import doobie._ 7 | import doobie.Fragments._ 8 | import doobie.implicits._ 9 | import doobie.implicits.legacy.instant._ 10 | import io.rw.app.data.Entities._ 11 | 12 | trait FavoriteRepo[F[_]] { 13 | def findFavorites(articleIds: List[Int], userId: Int): F[List[Favorite]] 14 | 15 | def findFavorite(articleId: Int, userId: Int)(implicit Fun: Functor[F]): F[Option[Favorite]] = 16 | Fun.map(findFavorites(List(articleId), userId))(_.headOption) 17 | 18 | def countFavorites(articleIds: List[Int]): F[List[(Int, Int)]] 19 | 20 | def countFavorite(articleId: Int)(implicit Fun: Functor[F]): F[Int] = 21 | Fun.map(countFavorites(List(articleId)))(_.headOption.getOrElse((0, 0))._2) 22 | 23 | def createFavorite(favorite: Favorite): F[Favorite] 24 | 25 | def deleteFavorite(articleId: Int, userId: Int): F[Unit] 26 | } 27 | 28 | object FavoriteRepo { 29 | 30 | def impl(xa: Transactor[IO]) = new FavoriteRepo[IO] { 31 | def findFavorites(articleIds: List[Int], userId: Int): IO[List[Favorite]] = 32 | NonEmptyList.fromList(articleIds.distinct).map(Q.selectFavorites(_, userId).to[List].transact(xa)).getOrElse(IO.pure(List.empty)) 33 | 34 | def countFavorites(articleIds: List[Int]): IO[List[(Int, Int)]] = 35 | NonEmptyList.fromList(articleIds.distinct).map(Q.countFavorites(_).to[List].transact(xa)).getOrElse(IO.pure(List.empty)) 36 | 37 | def createFavorite(favorite: Favorite): IO[Favorite] = 38 | Q.insertFavorite(favorite).run.map(_ => favorite).transact(xa) 39 | 40 | def deleteFavorite(articleId: Int, userId: Int): IO[Unit] = 41 | Q.deleteFavorite(articleId, userId).run.map(_ => ()).transact(xa) 42 | } 43 | 44 | object Q { 45 | def selectFavorites(articleIds: NonEmptyList[Int], userId: Int) = { 46 | val q = 47 | fr""" 48 | select article_id, user_id 49 | from favorites 50 | where """ ++ in(fr"article_id", articleIds) ++ 51 | fr""" 52 | and user_id = $userId 53 | """ 54 | q.query[Favorite] 55 | } 56 | 57 | def countFavorites(articleIds: NonEmptyList[Int]) = { 58 | val q = 59 | fr""" 60 | select article_id, count(1) 61 | from favorites 62 | where """ ++ in(fr"article_id", articleIds) ++ 63 | fr""" 64 | group by article_id 65 | """ 66 | q.query[(Int, Int)] 67 | } 68 | 69 | def insertFavorite(favorite: Favorite) = 70 | sql""" 71 | insert into favorites(article_id, user_id) 72 | values(${favorite.articleId}, ${favorite.userId}) 73 | on conflict do nothing 74 | """.update 75 | 76 | def deleteFavorite(articleId: Int, userId: Int) = 77 | sql""" 78 | delete from favorites 79 | where article_id = $articleId 80 | and user_id = $userId 81 | """.update 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/main/scala/repos/FollowerRepo.scala: -------------------------------------------------------------------------------- 1 | package io.rw.app.repos 2 | 3 | import cats.data.NonEmptyList 4 | import cats.effect.IO 5 | import cats.Functor 6 | import doobie._ 7 | import doobie.Fragments._ 8 | import doobie.implicits._ 9 | import doobie.implicits.legacy.instant._ 10 | import io.rw.app.data.Entities._ 11 | 12 | trait FollowerRepo[F[_]] { 13 | def findFollowers(followeeIds: List[Int], followerId: Int): F[List[Follower]] 14 | 15 | def findFollower(followeeId: Int, followerId: Int)(implicit Fun: Functor[F]): F[Option[Follower]] = 16 | Fun.map(findFollowers(List(followeeId), followerId))(_.headOption) 17 | 18 | def createFollower(follower: Follower): F[Follower] 19 | def deleteFollower(followeeId: Int, followerId: Int): F[Unit] 20 | } 21 | 22 | object FollowerRepo { 23 | 24 | def impl(xa: Transactor[IO]) = new FollowerRepo[IO] { 25 | def findFollowers(followeeIds: List[Int], followerId: Int): IO[List[Follower]] = 26 | NonEmptyList.fromList(followeeIds.distinct).map(Q.selectFollowers(_, followerId).to[List].transact(xa)).getOrElse(IO.pure(List.empty)) 27 | 28 | def createFollower(follower: Follower): IO[Follower] = 29 | Q.insertFollower(follower).run.map(_ => follower).transact(xa) 30 | 31 | def deleteFollower(followeeId: Int, followerId: Int): IO[Unit] = 32 | Q.deleteFollower(followeeId, followerId).run.map(_ => ()).transact(xa) 33 | } 34 | 35 | object Q { 36 | def selectFollowers(followeeIds: NonEmptyList[Int], followerId: Int) = { 37 | val q = 38 | fr""" 39 | select followee_id, follower_id 40 | from followers 41 | where """ ++ in(fr"followee_id", followeeIds) ++ 42 | fr""" 43 | and follower_id = $followerId 44 | """ 45 | q.query[Follower] 46 | } 47 | 48 | def insertFollower(follower: Follower) = 49 | sql""" 50 | insert into followers(followee_id, follower_id) 51 | values(${follower.followeeId}, ${follower.followerId}) 52 | on conflict do nothing 53 | """.update 54 | 55 | def deleteFollower(followeeId: Int, followerId: Int) = 56 | sql""" 57 | delete from followers 58 | where followee_id = $followeeId 59 | and follower_id = $followerId 60 | """.update 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/main/scala/repos/TagRepo.scala: -------------------------------------------------------------------------------- 1 | package io.rw.app.repos 2 | 3 | import cats.data.NonEmptyList 4 | import cats.effect.IO 5 | import doobie._ 6 | import doobie.Fragments._ 7 | import doobie.implicits._ 8 | import doobie.implicits.legacy.instant._ 9 | import io.rw.app.data.Entities._ 10 | 11 | trait TagRepo[F[_]] { 12 | def findPopularTags(): F[List[String]] 13 | def findTags(articleIds: List[Int]): F[List[Tag]] 14 | 15 | def findTags(articleId: Int): F[List[Tag]] = 16 | findTags(List(articleId)) 17 | } 18 | 19 | object TagRepo { 20 | 21 | def impl(xa: Transactor[IO]) = new TagRepo[IO] { 22 | def findPopularTags(): IO[List[String]] = 23 | Q.selectPopularTags.to[List].transact(xa) 24 | 25 | def findTags(articleIds: List[Int]): IO[List[Tag]] = 26 | NonEmptyList.fromList(articleIds.distinct).map(Q.selectTags(_).to[List].transact(xa)).getOrElse(IO.pure(List.empty)) 27 | } 28 | 29 | object Q { 30 | val selectPopularTags = 31 | sql""" 32 | select tag 33 | from tags 34 | group by tag 35 | order by count(1) desc 36 | """.query[String] 37 | 38 | def selectTags(articleIds: NonEmptyList[Int]) = { 39 | val q = 40 | fr""" 41 | select article_id, tag 42 | from tags 43 | where """ ++ in(fr"article_id", articleIds) 44 | 45 | q.query[Tag] 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/main/scala/repos/UserRepo.scala: -------------------------------------------------------------------------------- 1 | package io.rw.app.repos 2 | 3 | import cats.effect.IO 4 | import doobie._ 5 | import doobie.implicits._ 6 | import doobie.implicits.legacy.instant._ 7 | import io.rw.app.data.Entities._ 8 | 9 | trait UserRepo[F[_]] { 10 | def findUserById(id: Int): F[Option[WithId[User]]] 11 | def findUserByEmail(email: String): F[Option[WithId[User]]] 12 | def findUserByUsername(username: String): F[Option[WithId[User]]] 13 | def createUser(user: User): F[WithId[User]] 14 | def updateUser(id: Int, user: UserForUpdate): F[WithId[User]] 15 | } 16 | 17 | object UserRepo { 18 | 19 | def impl(xa: Transactor[IO]) = new UserRepo[IO] { 20 | def findUserById(id: Int): IO[Option[WithId[User]]] = 21 | Q.selectUserById(id).option.transact(xa) 22 | 23 | def findUserByEmail(email: String): IO[Option[WithId[User]]] = 24 | Q.selectUserByEmail(email).option.transact(xa) 25 | 26 | def findUserByUsername(username: String): IO[Option[WithId[User]]] = 27 | Q.selectUserByUsername(username).option.transact(xa) 28 | 29 | def createUser(user: User): IO[WithId[User]] = 30 | Q.insertUser(user).withUniqueGeneratedKeys[WithId[User]]("id", "email", "username", "password", "bio", "image", "created_at", "updated_at").transact(xa) 31 | 32 | def updateUser(id: Int, user: UserForUpdate): IO[WithId[User]] = 33 | Q.updateUser(id, user).withUniqueGeneratedKeys[WithId[User]]("id", "email", "username", "password", "bio", "image", "created_at", "updated_at").transact(xa) 34 | } 35 | 36 | object Q { 37 | def selectUserById(id: Int) = 38 | sql""" 39 | select id, email, username, password, bio, image, created_at, updated_at 40 | from users 41 | where id = $id 42 | """.query[WithId[User]] 43 | 44 | def selectUserByEmail(email: String) = 45 | sql""" 46 | select id, email, username, password, bio, image, created_at, updated_at 47 | from users 48 | where email = $email 49 | """.query[WithId[User]] 50 | 51 | def selectUserByUsername(username: String) = 52 | sql""" 53 | select id, email, username, password, bio, image, created_at, updated_at 54 | from users 55 | where username = $username 56 | """.query[WithId[User]] 57 | 58 | def insertUser(user: User) = 59 | sql""" 60 | insert into users(email, username, password, bio, image, created_at, updated_at) 61 | values(${user.email}, ${user.username}, ${user.password}, ${user.bio}, ${user.image}, ${user.createdAt}, ${user.updatedAt}) 62 | """.update 63 | 64 | def updateUser(id: Int, user: UserForUpdate) = 65 | sql""" 66 | update users u 67 | set email = coalesce(${user.email}, u.email), 68 | username = coalesce(${user.username}, u.username), 69 | password = coalesce(${user.password}, u.password), 70 | bio = coalesce(${user.bio}, u.bio), 71 | image = coalesce(${user.image}, u.image), 72 | updated_at = ${user.updatedAt} 73 | where id = $id 74 | """.update 75 | } 76 | } 77 | 78 | -------------------------------------------------------------------------------- /src/main/scala/repos/package.scala: -------------------------------------------------------------------------------- 1 | package io.rw.app 2 | 3 | import doobie._ 4 | 5 | package object repos { 6 | 7 | // TODO use jdbc logging? 8 | implicit val logHandler = LogHandler.jdkLogHandler 9 | 10 | def affectedToOption(n: Int): Option[Unit] = 11 | if (n > 0) Some(()) else None 12 | } 13 | -------------------------------------------------------------------------------- /src/main/scala/routes/ArticleRoutes.scala: -------------------------------------------------------------------------------- 1 | package io.rw.app.routes 2 | 3 | import cats.effect.Sync 4 | import cats.implicits._ 5 | import io.circe.generic.auto._ 6 | import io.rw.app.apis._ 7 | import io.rw.app.data._ 8 | import io.rw.app.data.ApiInputs._ 9 | import io.rw.app.data.RequestBodies._ 10 | import io.rw.app.valiation._ 11 | import org.http4s._ 12 | import org.http4s.circe.CirceEntityCodec._ 13 | import org.http4s.dsl.Http4sDsl 14 | 15 | object ArticleRoutes { 16 | 17 | def apply[F[_] : Sync](articles: ArticleApis[F]): AppRoutes[F] = { 18 | 19 | implicit val dsl = Http4sDsl.apply[F] 20 | import dsl._ 21 | 22 | object Limit extends OptionalQueryParamDecoderMatcher[Int]("limit") 23 | object Offset extends OptionalQueryParamDecoderMatcher[Int]("offset") 24 | 25 | object Tag extends OptionalQueryParamDecoderMatcher[String]("tag") 26 | object Author extends OptionalQueryParamDecoderMatcher[String]("author") 27 | object Favorited extends OptionalQueryParamDecoderMatcher[String]("favorited") 28 | 29 | AuthedRoutes.of[Option[AuthUser], F] { 30 | case GET -> Root / "articles" :? Tag(tag) +& Author(author) +& Favorited(favorited) +& Limit(limit) +& Offset(offset) as authUser => { 31 | val rq = GetAllArticlesInput(authUser, ArticleFilter(tag, author, favorited), Pagination(limit.getOrElse(10), offset.getOrElse(0))) 32 | articles.getAll(rq).flatMap(toResponse(_)) 33 | } 34 | 35 | case GET -> Root / "articles" / "feed" :? Limit(limit) +& Offset(offset) as authUser => 36 | withAuthUser(authUser) { u => 37 | articles.getFeed(GetArticlesFeedInput(u, Pagination(limit.getOrElse(10), offset.getOrElse(0)))).flatMap(toResponse(_)) 38 | } 39 | 40 | case GET -> Root / "articles" / slug as authUser => 41 | articles.get(GetArticleInput(authUser, slug)).flatMap(toResponse(_)) 42 | 43 | case rq @ POST -> Root / "articles" as authUser => 44 | for { 45 | body <- rq.req.as[WrappedArticleBody[CreateArticleBody]] 46 | rs <- withAuthUser(authUser) { u => 47 | withValidation(validCreateArticleBody(body.article)) { valid => 48 | articles.create(CreateArticleInput(u, valid.title, valid.description, valid.body, valid.tagList.getOrElse(List.empty))).flatMap(toResponse(_)) 49 | } 50 | } 51 | } yield rs 52 | 53 | case rq @ PUT -> Root / "articles" / slug as authUser => 54 | for { 55 | body <- rq.req.as[WrappedArticleBody[UpdateArticleBody]] 56 | rs <- withAuthUser(authUser) { u => 57 | withValidation(validUpdateArticleBody(body.article)) { valid => 58 | articles.update(UpdateArticleInput(u, slug, valid.title, valid.description, valid.body)).flatMap(toResponse(_)) 59 | } 60 | } 61 | } yield rs 62 | 63 | case DELETE -> Root / "articles" / slug as authUser => 64 | withAuthUser(authUser) { u => 65 | articles.delete(DeleteArticleInput(u, slug)).flatMap(toResponse(_)) 66 | } 67 | 68 | case POST -> Root / "articles" / slug / "favorite" as authUser => 69 | withAuthUser(authUser) { u => 70 | articles.favorite(FavoriteArticleInput(u, slug)).flatMap(toResponse(_)) 71 | } 72 | 73 | case DELETE -> Root / "articles" / slug / "favorite" as authUser => 74 | withAuthUser(authUser) { u => 75 | articles.unfavorite(UnfavoriteArticleInput(u, slug)).flatMap(toResponse(_)) 76 | } 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/main/scala/routes/CommentRoutes.scala: -------------------------------------------------------------------------------- 1 | package io.rw.app.routes 2 | 3 | import cats.effect.Sync 4 | import cats.implicits._ 5 | import io.circe.generic.auto._ 6 | import io.rw.app.apis._ 7 | import io.rw.app.data.AuthUser 8 | import io.rw.app.data.ApiInputs._ 9 | import io.rw.app.data.RequestBodies._ 10 | import io.rw.app.valiation._ 11 | import org.http4s._ 12 | import org.http4s.circe.CirceEntityCodec._ 13 | import org.http4s.dsl.Http4sDsl 14 | 15 | object CommentRoutes { 16 | 17 | def apply[F[_] : Sync](comments: CommentApis[F]): AppRoutes[F] = { 18 | 19 | implicit val dsl = Http4sDsl.apply[F] 20 | import dsl._ 21 | 22 | AuthedRoutes.of[Option[AuthUser], F] { 23 | case rq @ POST -> Root / "articles" / slug / "comments" as authUser => 24 | for { 25 | body <- rq.req.as[WrappedCommentBody[AddCommentBody]] 26 | rs <- withAuthUser(authUser) { u => 27 | withValidation(validAddCommentBody(body.comment)) { valid => 28 | comments.add(AddCommentInput(u, slug, valid.body)).flatMap(toResponse(_)) 29 | } 30 | } 31 | } yield rs 32 | 33 | case GET -> Root / "articles" / slug / "comments" as authUser => 34 | comments.get(GetCommentsInput(authUser, slug)).flatMap(toResponse(_)) 35 | 36 | case DELETE -> Root / "articles" / slug / "comments" / IntVar(commentId) as authUser => 37 | withAuthUser(authUser) { u => 38 | comments.delete(DeleteCommentInput(u, slug, commentId)).flatMap(toResponse(_)) 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/main/scala/routes/ProfileRoutes.scala: -------------------------------------------------------------------------------- 1 | package io.rw.app.routes 2 | 3 | import cats.effect.Sync 4 | import cats.implicits._ 5 | import io.circe.generic.auto._ 6 | import io.rw.app.apis._ 7 | import io.rw.app.data.AuthUser 8 | import io.rw.app.data.ApiInputs._ 9 | import io.rw.app.data.RequestBodies._ 10 | import io.rw.app.valiation._ 11 | import org.http4s._ 12 | import org.http4s.circe.CirceEntityCodec._ 13 | import org.http4s.dsl.Http4sDsl 14 | 15 | object ProfileRoutes { 16 | 17 | def apply[F[_] : Sync](profiles: ProfileApis[F]): AppRoutes[F] = { 18 | 19 | implicit val dsl = Http4sDsl.apply[F] 20 | import dsl._ 21 | 22 | AuthedRoutes.of[Option[AuthUser], F] { 23 | case GET -> Root / "profiles" / username as authUser => 24 | profiles.get(GetProfileInput(authUser, username)).flatMap(toResponse(_)) 25 | 26 | case POST -> Root / "profiles" / username / "follow" as authUser => 27 | withAuthUser(authUser) { u => 28 | profiles.follow(FollowUserInput(u, username)).flatMap(toResponse(_)) 29 | } 30 | 31 | case DELETE -> Root / "profiles" / username / "follow" as authUser => 32 | withAuthUser(authUser) { u => 33 | profiles.unfollow(UnfollowUserInput(u, username)).flatMap(toResponse(_)) 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/main/scala/routes/TagRoutes.scala: -------------------------------------------------------------------------------- 1 | package io.rw.app.routes 2 | 3 | import cats.effect.Sync 4 | import cats.implicits._ 5 | import io.circe.generic.auto._ 6 | import io.rw.app.apis._ 7 | import io.rw.app.data.AuthUser 8 | import io.rw.app.data.ApiInputs._ 9 | import io.rw.app.data.RequestBodies._ 10 | import io.rw.app.valiation._ 11 | import org.http4s._ 12 | import org.http4s.circe.CirceEntityCodec._ 13 | import org.http4s.dsl.Http4sDsl 14 | 15 | object TagRoutes { 16 | 17 | def apply[F[_] : Sync](tags: TagApis[F]): AppRoutes[F] = { 18 | 19 | implicit val dsl = Http4sDsl.apply[F] 20 | import dsl._ 21 | 22 | AuthedRoutes.of[Option[AuthUser], F] { 23 | case GET -> Root / "tags" as _ => 24 | tags.get(GetTagsInput()).flatMap(toResponse(_)) 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/main/scala/routes/UserRoutes.scala: -------------------------------------------------------------------------------- 1 | package io.rw.app.routes 2 | 3 | import cats.effect.Sync 4 | import cats.implicits._ 5 | import io.circe.generic.auto._ 6 | import io.rw.app.apis._ 7 | import io.rw.app.data.AuthUser 8 | import io.rw.app.data.ApiInputs._ 9 | import io.rw.app.data.RequestBodies._ 10 | import io.rw.app.valiation._ 11 | import org.http4s._ 12 | import org.http4s.circe.CirceEntityCodec._ 13 | import org.http4s.dsl.Http4sDsl 14 | 15 | object UserRoutes { 16 | 17 | def apply[F[_] : Sync](users: UserApis[F]): AppRoutes[F] = { 18 | 19 | implicit val dsl = Http4sDsl.apply[F] 20 | import dsl._ 21 | 22 | AuthedRoutes.of[Option[AuthUser], F] { 23 | case rq @ POST -> Root / "users" / "login" as _ => 24 | for { 25 | body <- rq.req.as[WrappedUserBody[AuthenticateUserBody]] 26 | rs <- withValidation(validAuthenticateUserBody(body.user)) { valid => 27 | users.authenticate(AuthenticateUserInput(valid.email, valid.password)).flatMap(toResponse(_)) 28 | } 29 | } yield rs 30 | 31 | case rq @ POST -> Root / "users" as _ => 32 | for { 33 | body <- rq.req.as[WrappedUserBody[RegisterUserBody]] 34 | rs <- withValidation(validRegisterUserBody(body.user)) { valid => 35 | users.register(RegisterUserInput(valid.username, valid.email, valid.password)).flatMap(toResponse(_)) 36 | } 37 | } yield rs 38 | 39 | case GET -> Root / "user" as authUser => 40 | withAuthUser(authUser) { u => 41 | users.get(GetUserInput(u)).flatMap(toResponse(_)) 42 | } 43 | 44 | case rq @ PUT -> Root / "user" as authUser => { 45 | for { 46 | body <- rq.req.as[WrappedUserBody[UpdateUserBody]] 47 | rs <- withAuthUser(authUser) { u => 48 | withValidation(validUpdateUserBody(body.user)) { valid => 49 | users.update(UpdateUserInput(u, valid.username, valid.email, valid.password, valid.bio, valid.image)).flatMap(toResponse(_)) 50 | } 51 | } 52 | } yield rs 53 | } 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/main/scala/routes/package.scala: -------------------------------------------------------------------------------- 1 | package io.rw.app 2 | 3 | import cats.Monad 4 | import cats.data._ 5 | import cats.effect.Sync 6 | import cats.implicits._ 7 | import io.circe.Encoder 8 | import io.circe.generic.auto._ 9 | import io.rw.app.data._ 10 | import io.rw.app.data.ApiErrors._ 11 | import io.rw.app.security.JwtToken 12 | import io.rw.app.valiation._ 13 | import io.rw.app.valiation.InvalidFields._ 14 | import io.rw.app.utils._ 15 | import org.http4s._ 16 | import org.http4s.circe._ 17 | import org.http4s.circe.CirceEntityCodec._ 18 | import org.http4s.dsl.Http4sDsl 19 | import org.http4s.headers.Authorization 20 | 21 | package object routes { 22 | 23 | type AppRoutes[F[_]] = AuthedRoutes[Option[AuthUser], F] 24 | 25 | def authUser[F[_] : Monad](token: JwtToken[F]): Kleisli[OptionT[F, *], Request[F], Option[AuthUser]] = 26 | Kleisli {rq => 27 | for { 28 | header <- OptionT.liftF(Monad[F].pure(rq.headers.get(Authorization))) 29 | jwt <- OptionT.liftF(Monad[F].pure(header.flatMap(h => extractTokenValue(h.value)))) 30 | payload <- OptionT.liftF(jwt.flatTraverse(token.validate(_))) 31 | } yield payload.map(_.authUser) 32 | } 33 | 34 | def withAuthUser[F[_] : Sync](authUser: Option[AuthUser])(fn: AuthUser => F[Response[F]]): F[Response[F]] = 35 | authUser.fold(Sync[F].pure(Response[F](Status.Unauthorized)))(fn) 36 | 37 | def withValidation[F[_] : Sync, A](validated: ValidationResult[A])(fn: A => F[Response[F]])(implicit dsl: Http4sDsl[F]): F[Response[F]] = { 38 | import dsl._ 39 | 40 | validated.toEither.fold(errors => UnprocessableEntity(validationErrorsToResponse(errors)), fn) 41 | } 42 | 43 | val defaultNotFoundResponse = NotFoundResponse(404, "Not Found") 44 | 45 | def toResponse[F[_] : Sync, R <: ApiOutput](res: ApiResult[R])(implicit dsl: Http4sDsl[F], encoder: Encoder[R]): F[Response[F]] = { 46 | import dsl._ 47 | 48 | res match { 49 | case Right(r) => Ok(r) 50 | case Left(_: UserNotFound) => NotFound(defaultNotFoundResponse) 51 | case Left(_: ProfileNotFound) => NotFound(defaultNotFoundResponse) 52 | case Left(_: ArticleNotFound) => NotFound(defaultNotFoundResponse) 53 | case Left(_: CommentNotFound) => NotFound(defaultNotFoundResponse) 54 | case Left(_: EmailAlreadyExists) => UnprocessableEntity(validationErrorsToResponse(NonEmptyChain.one(InvalidEmail(List("has already been taken"))))) 55 | case Left(_: UsernameAlreadyExists) => UnprocessableEntity(validationErrorsToResponse(NonEmptyChain.one(InvalidUsername(List("has already been taken"))))) 56 | case Left(_: UserNotFoundOrPasswordNotMatched) => UnprocessableEntity(validationErrorsToResponse(NonEmptyChain.one(InvalidEmailOrPassword(List("is invalid"))))) 57 | case Left(e) => InternalServerError() 58 | } 59 | } 60 | 61 | def validationErrorsToResponse(nec: NonEmptyChain[InvalidField]): ValidationErrorResponse = 62 | ValidationErrorResponse(nec.toList.map(e => e.field -> e.errors).toMap) 63 | } 64 | -------------------------------------------------------------------------------- /src/main/scala/security.scala: -------------------------------------------------------------------------------- 1 | package io.rw.app 2 | 3 | import cats.effect.IO 4 | import io.circe._ 5 | import io.circe.generic.auto._ 6 | import io.circe.syntax._ 7 | import io.rw.app.data.JwtTokenPayload 8 | import tsec.jws.mac.JWTMac 9 | import tsec.jwt.JWTClaims 10 | import tsec.mac.jca.{HMACSHA256, MacSigningKey} 11 | import tsec.passwordhashers.jca.SCrypt 12 | import tsec.passwordhashers.PasswordHash 13 | 14 | object security { 15 | 16 | trait PasswordHasher[F[_]] { 17 | def hash(psw: String): F[String] 18 | def validate(psw: String, hash: String): F[Boolean] 19 | } 20 | 21 | object PasswordHasher { 22 | def impl = new PasswordHasher[IO] { 23 | def hash(psw: String): IO[String] = SCrypt.hashpw[IO](psw) 24 | def validate(psw: String, hash: String): IO[Boolean] = SCrypt.checkpwBool[IO](psw, PasswordHash(hash)) 25 | } 26 | } 27 | 28 | trait JwtToken[F[_]] { 29 | def generate(payload: JwtTokenPayload): F[String] 30 | def validate(token: String): F[Option[JwtTokenPayload]] 31 | } 32 | 33 | object JwtToken { 34 | def impl(key: MacSigningKey[HMACSHA256], durationMinutes: Int) = new JwtToken[IO] { 35 | import scala.concurrent.duration._ 36 | 37 | val payloadKeyName = "payload" 38 | 39 | def generate(payload: JwtTokenPayload): IO[String] = { 40 | for { 41 | claims <- JWTClaims.withDuration[IO](expiration = Some(durationMinutes.minutes), customFields = List(payloadKeyName -> payload.asJson)) 42 | jwtStr <- JWTMac.buildToString[IO, HMACSHA256](claims, key) 43 | } yield jwtStr 44 | } 45 | 46 | def validate(token: String): IO[Option[JwtTokenPayload]] = { 47 | val payload = for { 48 | jwt <- JWTMac.verifyAndParse[IO, HMACSHA256](token, key) 49 | payloadE <- IO.pure(jwt.body.getCustom[JwtTokenPayload](payloadKeyName)) 50 | } yield payloadE.toOption 51 | 52 | // suppress error from verification 53 | payload.handleErrorWith(_ => IO.pure(None)) 54 | } 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/main/scala/utils.scala: -------------------------------------------------------------------------------- 1 | package io.rw.app 2 | 3 | import cats.data.Kleisli 4 | import cats.effect.IO 5 | import cats.implicits._ 6 | import java.text.Normalizer 7 | import java.text.Normalizer.Form 8 | import io.rw.app.data._ 9 | import io.rw.app.routes._ 10 | import io.rw.app.security._ 11 | import org.hashids.Hashids 12 | import org.http4s._ 13 | import org.http4s.implicits._ 14 | import org.http4s.server._ 15 | import org.http4s.server.middleware.CORS 16 | 17 | object utils { 18 | 19 | trait IdHasher { 20 | def hash(id: Long): String 21 | } 22 | 23 | object IdHasher { 24 | def impl(salt: String) = new IdHasher { 25 | val hashids = new Hashids(salt, 5) 26 | 27 | def hash(id: Long): String = 28 | hashids.encode(id) 29 | } 30 | } 31 | 32 | val notAsciiRe = "[^\\p{ASCII}]".r 33 | val notWordsRs = "[^\\w]".r 34 | val spacesRe = "\\s+".r 35 | def slugify(s: String): String = { 36 | // get rid of fancy characters accents 37 | val normalized = Normalizer.normalize(s, Form.NFD) 38 | val cleaned = notAsciiRe.replaceAllIn(normalized, "") 39 | 40 | val wordsOnly = notWordsRs.replaceAllIn(cleaned, " ").trim 41 | spacesRe.replaceAllIn(wordsOnly, "-").toLowerCase 42 | } 43 | 44 | val tokenPattern = "^Token (.+)".r 45 | def extractTokenValue(s: String): Option[String] = 46 | s match { 47 | case tokenPattern(token) => Some(token) 48 | case _ => None 49 | } 50 | 51 | def mkHttpApp(appRoutes: List[AppRoutes[IO]], token: JwtToken[IO]): HttpApp[IO] = { 52 | val authMiddleware = AuthMiddleware(authUser[IO](token)) 53 | val routes = authMiddleware(appRoutes.reduce(_ <+> _)) 54 | 55 | Router("/api" -> CORS(routes)).orNotFound 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/main/scala/validation.scala: -------------------------------------------------------------------------------- 1 | package io.rw.app 2 | 3 | import cats.data._ 4 | import cats.data.Validated._ 5 | import cats.implicits._ 6 | import io.rw.app.data.RequestBodies._ 7 | 8 | object valiation { 9 | 10 | sealed trait InvalidField { 11 | def errors: List[String] 12 | def field: String 13 | } 14 | 15 | object InvalidFields { 16 | case class InvalidEmail(override val errors: List[String], override val field: String = "email") extends InvalidField 17 | case class InvalidPassword(override val errors: List[String], override val field: String = "password") extends InvalidField 18 | case class InvalidUsername(override val errors: List[String], override val field: String = "username") extends InvalidField 19 | case class InvalidTitle(override val errors: List[String], override val field: String = "title") extends InvalidField 20 | case class InvalidDescription(override val errors: List[String], override val field: String = "description") extends InvalidField 21 | case class InvalidBody(override val errors: List[String], override val field: String = "body") extends InvalidField 22 | 23 | case class InvalidEmailOrPassword(override val errors: List[String], override val field: String = "email or password") extends InvalidField 24 | } 25 | 26 | type ValidationResult[A] = ValidatedNec[InvalidField, A] 27 | 28 | def validAuthenticateUserBody(body: AuthenticateUserBody): ValidationResult[AuthenticateUserBody] = 29 | (validEmail(body.email), body.password.validNec).mapN(AuthenticateUserBody) 30 | 31 | def validRegisterUserBody(body: RegisterUserBody): ValidationResult[RegisterUserBody] = 32 | (validUsername(body.username), validEmail(body.email), validPassword(body.password)).mapN(RegisterUserBody) 33 | 34 | def validUpdateUserBody(body: UpdateUserBody): ValidationResult[UpdateUserBody] = 35 | (body.username.traverse(validUsername), body.email.traverse(validEmail), body.password.traverse(validPassword), body.bio.validNec, body.image.validNec).mapN(UpdateUserBody) 36 | 37 | def validCreateArticleBody(body: CreateArticleBody): ValidationResult[CreateArticleBody] = 38 | (validTitle(body.title), validDescription(body.description), validBody(body.body), body.tagList.traverse(validTags)).mapN(CreateArticleBody) 39 | 40 | def validUpdateArticleBody(body: UpdateArticleBody): ValidationResult[UpdateArticleBody] = 41 | (body.title.traverse(validTitle), body.description.traverse(validDescription), body.body.traverse(validBody)).mapN(UpdateArticleBody) 42 | 43 | def validAddCommentBody(body: AddCommentBody): ValidationResult[AddCommentBody] = 44 | validBody(body.body).map(AddCommentBody) 45 | 46 | import validators._ 47 | import InvalidFields._ 48 | def validEmail(email: String): ValidationResult[String] = { 49 | val trimmedEmail = email.trim 50 | (notBlank(trimmedEmail), max(trimmedEmail, 350), looksLikeEmail(trimmedEmail)).mapN({ case t => t._1 }).leftMap(toInvalidField(_, InvalidEmail.apply(_))) 51 | } 52 | 53 | def validPassword(password: String): ValidationResult[String] = 54 | (notBlank(password), min(password, 8), max(password, 100)).mapN({ case t => t._1 }).leftMap(toInvalidField(_, InvalidPassword.apply(_))) 55 | 56 | def validUsername(username: String): ValidationResult[String] = { 57 | val trimmedUsername = username.trim 58 | (notBlank(trimmedUsername), min(trimmedUsername, 1), max(trimmedUsername, 25)).mapN({ case t => t._1 }).leftMap(toInvalidField(_, InvalidUsername.apply(_))) 59 | } 60 | 61 | def validTitle(title: String): ValidationResult[String] = { 62 | val trimmedTitle = title.trim 63 | notBlank(trimmedTitle).leftMap(toInvalidField(_, InvalidTitle.apply(_))) 64 | } 65 | 66 | def validDescription(description: String): ValidationResult[String] = { 67 | val trimmedDescription = description.trim 68 | notBlank(trimmedDescription).leftMap(toInvalidField(_, InvalidDescription.apply(_))) 69 | } 70 | 71 | def validBody(body: String): ValidationResult[String] = { 72 | val trimmedBody = body.trim 73 | notBlank(trimmedBody).leftMap(toInvalidField(_, InvalidBody.apply(_))) 74 | } 75 | 76 | def validTags(tags: List[String]): ValidationResult[List[String]] = 77 | tags.map(_.trim).filter(_.nonEmpty).distinct.validNec 78 | 79 | def toInvalidField[F <: InvalidField](nec: NonEmptyChain[String], mkInvalidField: List[String] => F): NonEmptyChain[F] = 80 | NonEmptyChain.one(mkInvalidField(nec.toList)) 81 | 82 | object validators { 83 | type ValidatorResult[A] = ValidatedNec[String, A] 84 | def notBlank(s: String): ValidatorResult[String] = 85 | if (s.nonEmpty) s.validNec else "can't be blank".invalidNec 86 | 87 | def min(s: String, minSize: Int): ValidatorResult[String] = 88 | if (s.size >= minSize) s.validNec else s"is too short (minimum is $minSize character)".invalidNec 89 | 90 | def max(s: String, maxSize: Int): ValidatorResult[String] = 91 | if (s.size <= maxSize) s.validNec else s"is too long (maximum is $maxSize character)".invalidNec 92 | 93 | val emailPattern = ".+@.+\\..+".r 94 | def looksLikeEmail(s: String): ValidatorResult[String] = 95 | if (emailPattern.matches(s)) s.validNec else "is invalid".invalidNec 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/test/scala/WithEmbededDbTestSuite.scala: -------------------------------------------------------------------------------- 1 | package test.io.rw.app 2 | 3 | import cats.effect.IO 4 | import com.opentable.db.postgres.embedded.EmbeddedPostgres 5 | import doobie.util.ExecutionContexts 6 | import doobie.util.transactor.Transactor 7 | import org.flywaydb.core.Flyway 8 | import utest._ 9 | 10 | trait WithEmbededDbTestSuite extends TestSuite { 11 | 12 | // use different port each time since tests run in parallel 13 | val pg = EmbeddedPostgres.builder().start() 14 | val port = pg.getPort() 15 | val fw = Flyway.configure().dataSource(s"jdbc:postgresql://localhost:$port/postgres", "postgres", "postgres").load() 16 | 17 | implicit val cs = IO.contextShift(ExecutionContexts.synchronous) 18 | val xa = Transactor.fromDriverManager[IO]("org.postgresql.Driver", s"jdbc:postgresql://localhost:$port/postgres", "postgres", "postgres") 19 | 20 | override def utestAfterAll(): Unit = { 21 | pg.close() 22 | } 23 | 24 | override def utestBeforeEach(path: Seq[String]): Unit = { 25 | fw.migrate() 26 | } 27 | 28 | override def utestAfterEach(path: Seq[String]): Unit = { 29 | fw.clean() 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/test/scala/routes/ArticleRoutesTests.scala: -------------------------------------------------------------------------------- 1 | package test.io.rw.app.routes 2 | 3 | import cats._ 4 | import cats.data._ 5 | import cats.effect.IO 6 | import cats.implicits._ 7 | import io.circe.generic.auto._ 8 | import io.rw.app.apis._ 9 | import io.rw.app.data._ 10 | import io.rw.app.data.ApiErrors._ 11 | import io.rw.app.data.ApiInputs._ 12 | import io.rw.app.data.ApiOutputs._ 13 | import io.rw.app.data.RequestBodies._ 14 | import io.rw.app.repos._ 15 | import io.rw.app.routes._ 16 | import io.rw.app.security._ 17 | import io.rw.app.utils._ 18 | import org.http4s._ 19 | import org.http4s.circe.CirceEntityCodec._ 20 | import scala.util.Random 21 | import test.io.rw.app.WithEmbededDbTestSuite 22 | import utest._ 23 | import doobie.util.update 24 | import io.rw.app.utils 25 | 26 | object ArticleRoutesTests extends WithEmbededDbTestSuite { 27 | 28 | val tests = Tests { 29 | test("get all") { 30 | test("authenticated user should get all articles") { 31 | val registerBody = RegisterUserBody("username", "email@email.com", "password123") 32 | val createArticleBodies = generateCreateArticleBodies(10) 33 | 34 | val t = for { 35 | jwt <- logon(registerBody) 36 | _ <- createArticleBodies.map(b => postWithToken("articles", WrappedArticleBody(b), jwt)).sequence 37 | rs <- getWithToken("articles", jwt) 38 | (articles, articlesCount) <- rs.as[GetAllArticlesOutput].map(r => (r.articles, r.articlesCount)) 39 | } yield { 40 | rs.status ==> Status.Ok 41 | articlesCount ==> createArticleBodies.size 42 | } 43 | 44 | t.unsafeRunSync() 45 | } 46 | 47 | test("authenticated user should get articles paginated") { 48 | val registerBody = RegisterUserBody("username", "email@email.com", "password123") 49 | val createArticleBodies = generateCreateArticleBodies(10) 50 | 51 | val t = for { 52 | jwt <- logon(registerBody) 53 | _ <- createArticleBodies.map(b => postWithToken("articles", WrappedArticleBody(b), jwt)).sequence 54 | rs1 <- getWithToken(s"articles?limit=7&offset=0", jwt) 55 | (articles1, articlesCount1) <- rs1.as[GetAllArticlesOutput].map(r => (r.articles, r.articlesCount)) 56 | rs2 <- getWithToken(s"articles?limit=7&offset=7", jwt) 57 | (articles2, articlesCount2) <- rs2.as[GetAllArticlesOutput].map(r => (r.articles, r.articlesCount)) 58 | rs3 <- getWithToken(s"articles?limit=7&offset=10", jwt) 59 | (articles3, articlesCount3) <- rs3.as[GetAllArticlesOutput].map(r => (r.articles, r.articlesCount)) 60 | } yield { 61 | rs1.status ==> Status.Ok 62 | rs2.status ==> Status.Ok 63 | rs3.status ==> Status.Ok 64 | articles1.size ==> 7 65 | articlesCount1 ==> createArticleBodies.size 66 | articles2.size ==> 3 67 | articlesCount2 ==> createArticleBodies.size 68 | articles3.size ==> 0 69 | articlesCount3 ==> createArticleBodies.size 70 | } 71 | 72 | t.unsafeRunSync() 73 | } 74 | 75 | test("authenticated user should get articles by tag") { 76 | val registerBody = RegisterUserBody("username", "email@email.com", "password123") 77 | val createArticleBodies = generateCreateArticleBodies(10) 78 | val tag = "tagAbc" 79 | val createArticleBodiesWithTag = generateCreateArticleBodies(5, List(tag)) 80 | 81 | val t = for { 82 | jwt <- logon(registerBody) 83 | _ <- (createArticleBodies ++ createArticleBodiesWithTag).map(b => postWithToken("articles", WrappedArticleBody(b), jwt)).sequence 84 | rs <- getWithToken(s"articles?tag=$tag", jwt) 85 | (articles, articlesCount) <- rs.as[GetAllArticlesOutput].map(r => (r.articles, r.articlesCount)) 86 | } yield { 87 | rs.status ==> Status.Ok 88 | articlesCount ==> createArticleBodiesWithTag.size 89 | } 90 | 91 | t.unsafeRunSync() 92 | } 93 | 94 | test("authenticated user should get articles by author") { 95 | val registerBody1 = RegisterUserBody("username1", "email1@email.com", "password123") 96 | val registerBody2 = RegisterUserBody("username2", "email2@email.com", "password123") 97 | val createArticleBodies1 = generateCreateArticleBodies(10) 98 | val createArticleBodies2 = generateCreateArticleBodies(7) 99 | 100 | val t = for { 101 | jwt1 <- logon(registerBody1) 102 | _ <- createArticleBodies1.map(b => postWithToken("articles", WrappedArticleBody(b), jwt1)).sequence 103 | jwt2 <- logon(registerBody2) 104 | _ <- createArticleBodies2.map(b => postWithToken("articles", WrappedArticleBody(b), jwt2)).sequence 105 | rs <- getWithToken(s"articles?author=${registerBody2.username}", jwt1) 106 | (articles, articlesCount) <- rs.as[GetAllArticlesOutput].map(r => (r.articles, r.articlesCount)) 107 | } yield { 108 | rs.status ==> Status.Ok 109 | articlesCount ==> createArticleBodies2.size 110 | } 111 | 112 | t.unsafeRunSync() 113 | } 114 | 115 | test("authenticated user should get articles by favorited") { 116 | val registerBody1 = RegisterUserBody("username1", "email1@email.com", "password123") 117 | val registerBody2 = RegisterUserBody("username2", "email2@email.com", "password123") 118 | val createArticleBodies1 = generateCreateArticleBodies(10) 119 | val createArticleBodies2 = generateCreateArticleBodies(7) 120 | 121 | val t = for { 122 | jwt1 <- logon(registerBody1) 123 | _ <- createArticleBodies1.map(b => postWithToken("articles", WrappedArticleBody(b), jwt1)).sequence 124 | jwt2 <- logon(registerBody2) 125 | _ <- createArticleBodies2.map(b => postWithToken("articles", WrappedArticleBody(b), jwt2)).sequence 126 | rs1 <- getWithToken(s"articles?author=${registerBody2.username}", jwt1) 127 | (articles, articlesCount) <- rs1.as[GetAllArticlesOutput].map(r => (r.articles, r.articlesCount)) 128 | favorites = articles.take(5) 129 | _ <- favorites.map(a => postWithToken(s"articles/${a.slug}/favorite", jwt1)).sequence 130 | rs2 <- getWithToken(s"articles?favorited=${registerBody1.username}", jwt1) 131 | (articles2, articlesCount2) <- rs2.as[GetAllArticlesOutput].map(r => (r.articles, r.articlesCount)) 132 | } yield { 133 | rs1.status ==> Status.Ok 134 | rs2.status ==> Status.Ok 135 | articles2.filter(_.favorited).size ==> favorites.size 136 | articles2.filter(_.favoritesCount == 1).size ==> favorites.size 137 | articlesCount2 ==> favorites.size 138 | } 139 | 140 | t.unsafeRunSync() 141 | } 142 | 143 | test("not authenticated user should get all articles") { 144 | val registerBody = RegisterUserBody("username", "email@email.com", "password123") 145 | val createArticleBodies = generateCreateArticleBodies(10) 146 | 147 | val t = for { 148 | jwt <- logon(registerBody) 149 | _ <- createArticleBodies.map(b => postWithToken("articles", WrappedArticleBody(b), jwt)).sequence 150 | rs <- get("articles") 151 | (articles, articlesCount) <- rs.as[GetAllArticlesOutput].map(r => (r.articles, r.articlesCount)) 152 | } yield { 153 | rs.status ==> Status.Ok 154 | articlesCount ==> createArticleBodies.size 155 | } 156 | 157 | t.unsafeRunSync() 158 | } 159 | } 160 | 161 | test("get feed") { 162 | test("authenticated user should get feed") { 163 | val registerBody1 = RegisterUserBody("username1", "email1@email.com", "password123") 164 | val registerBody2 = RegisterUserBody("username2", "email2@email.com", "password123") 165 | val createArticleBodies1 = generateCreateArticleBodies(10) 166 | val createArticleBodies2 = generateCreateArticleBodies(7) 167 | 168 | val t = for { 169 | jwt1 <- logon(registerBody1) 170 | _ <- createArticleBodies1.map(b => postWithToken("articles", WrappedArticleBody(b), jwt1)).sequence 171 | jwt2 <- logon(registerBody2) 172 | _ <- createArticleBodies2.map(b => postWithToken("articles", WrappedArticleBody(b), jwt2)).sequence 173 | rs1 <- postWithToken(s"profiles/${registerBody2.username}/follow", jwt1) 174 | rs2 <- getWithToken(s"articles/feed", jwt1) 175 | (articles, articlesCount) <- rs2.as[GetArticlesFeedOutput].map(r => (r.articles, r.articlesCount)) 176 | } yield { 177 | rs1.status ==> Status.Ok 178 | rs2.status ==> Status.Ok 179 | articlesCount ==> createArticleBodies2.size 180 | } 181 | 182 | t.unsafeRunSync() 183 | } 184 | 185 | test("authenticated user should get feed paginated") { 186 | val registerBody1 = RegisterUserBody("username1", "email1@email.com", "password123") 187 | val registerBody2 = RegisterUserBody("username2", "email2@email.com", "password123") 188 | val createArticleBodies1 = generateCreateArticleBodies(10) 189 | val createArticleBodies2 = generateCreateArticleBodies(10) 190 | 191 | val t = for { 192 | jwt1 <- logon(registerBody1) 193 | _ <- createArticleBodies1.map(b => postWithToken("articles", WrappedArticleBody(b), jwt1)).sequence 194 | jwt2 <- logon(registerBody2) 195 | _ <- createArticleBodies2.map(b => postWithToken("articles", WrappedArticleBody(b), jwt2)).sequence 196 | rs1 <- postWithToken(s"profiles/${registerBody2.username}/follow", jwt1) 197 | rs2 <- getWithToken(s"articles/feed?limit=7&offset=0", jwt1) 198 | (articles1, articlesCount1) <- rs2.as[GetArticlesFeedOutput].map(r => (r.articles, r.articlesCount)) 199 | rs3 <- getWithToken(s"articles/feed?limit=7&offset=7", jwt1) 200 | (articles2, articlesCount2) <- rs3.as[GetArticlesFeedOutput].map(r => (r.articles, r.articlesCount)) 201 | rs4 <- getWithToken(s"articles/feed?limit=7&offset=10", jwt1) 202 | (articles3, articlesCount3) <- rs4.as[GetArticlesFeedOutput].map(r => (r.articles, r.articlesCount)) 203 | } yield { 204 | rs1.status ==> Status.Ok 205 | rs2.status ==> Status.Ok 206 | rs3.status ==> Status.Ok 207 | rs4.status ==> Status.Ok 208 | articles1.size ==> 7 209 | articlesCount1 ==> createArticleBodies2.size 210 | articles2.size ==> 3 211 | articlesCount2 ==> createArticleBodies2.size 212 | articles3.size ==> 0 213 | articlesCount3 ==> createArticleBodies2.size 214 | } 215 | 216 | t.unsafeRunSync() 217 | } 218 | 219 | test("not authenticated user should get error") { 220 | val t = for { 221 | rs <- get("articles/feed") 222 | } yield { 223 | rs.status ==> Status.Unauthorized 224 | } 225 | 226 | t.unsafeRunSync() 227 | } 228 | } 229 | 230 | test("get") { 231 | test("authenticated user should get article by slug") { 232 | val registerBody = RegisterUserBody("username", "email@email.com", "password123") 233 | val createArticleBody = CreateArticleBody("title", "description", "body", Some(List("tag1", "tag2", "tag3"))) 234 | 235 | val t = for { 236 | jwt <- logon(registerBody) 237 | rs1 <- postWithToken("articles", WrappedArticleBody(createArticleBody), jwt) 238 | slug <- rs1.as[CreateArticleOutput].map(r => r.article.slug) 239 | rs2 <- getWithToken(s"articles/$slug", jwt) 240 | } yield { 241 | rs2.status ==> Status.Ok 242 | } 243 | 244 | t.unsafeRunSync() 245 | } 246 | 247 | test("authenticated user should get not found when article does not exist") { 248 | val registerBody = RegisterUserBody("username", "email@email.com", "password123") 249 | 250 | val t = for { 251 | jwt <- logon(registerBody) 252 | rs <- getWithToken("articles/slug-not-exists", jwt) 253 | } yield { 254 | rs.status ==> Status.NotFound 255 | } 256 | 257 | t.unsafeRunSync() 258 | } 259 | 260 | test("not authenticated user should get article by slug") { 261 | val registerBody = RegisterUserBody("username", "email@email.com", "password123") 262 | val createArticleBody = CreateArticleBody("title", "description", "body", Some(List("tag1", "tag2", "tag3"))) 263 | 264 | val t = for { 265 | jwt <- logon(registerBody) 266 | rs1 <- postWithToken("articles", WrappedArticleBody(createArticleBody), jwt) 267 | slug <- rs1.as[CreateArticleOutput].map(r => r.article.slug) 268 | rs2 <- get(s"articles/$slug") 269 | } yield { 270 | rs2.status ==> Status.Ok 271 | } 272 | 273 | t.unsafeRunSync() 274 | } 275 | } 276 | 277 | test("create") { 278 | test("authenticated user should create article") { 279 | val registerBody = RegisterUserBody("username", "email@email.com", "password123") 280 | val createArticleBody = CreateArticleBody("title", "description", "body", Some(List("tag1", "tag2", "tag3"))) 281 | 282 | val t = for { 283 | jwt <- logon(registerBody) 284 | rs <- postWithToken("articles", WrappedArticleBody(createArticleBody), jwt) 285 | article <- rs.as[CreateArticleOutput].map(_.article) 286 | } yield { 287 | rs.status ==> Status.Ok 288 | article.title ==> createArticleBody.title 289 | article.description ==> createArticleBody.description 290 | article.body ==> createArticleBody.body 291 | article.favoritesCount ==> 0 292 | article.favorited ==> false 293 | Some(article.tagList.toSet) ==> createArticleBody.tagList.map(_.toSet) 294 | article.author.username ==> registerBody.username 295 | } 296 | 297 | t.unsafeRunSync() 298 | } 299 | 300 | test("authenticated user should create article with the same title") { 301 | val registerBody = RegisterUserBody("username", "email@email.com", "password123") 302 | val createArticleBody1 = CreateArticleBody("title", "description1", "body1", Some(List("tag1", "tag2", "tag3"))) 303 | val createArticleBody2 = CreateArticleBody("title", "description2", "body2", Some(List("tag1", "tag2", "tag3"))) 304 | 305 | val t = for { 306 | jwt <- logon(registerBody) 307 | rs1 <- postWithToken("articles", WrappedArticleBody(createArticleBody1), jwt) 308 | article1 <- rs1.as[CreateArticleOutput].map(_.article) 309 | rs2 <- postWithToken("articles", WrappedArticleBody(createArticleBody2), jwt) 310 | article2 <- rs2.as[CreateArticleOutput].map(_.article) 311 | } yield { 312 | rs1.status ==> Status.Ok 313 | article1.title ==> createArticleBody1.title 314 | article1.description ==> createArticleBody1.description 315 | article1.body ==> createArticleBody1.body 316 | article1.favoritesCount ==> 0 317 | article1.favorited ==> false 318 | Some(article1.tagList.toSet) ==> createArticleBody1.tagList.map(_.toSet) 319 | article1.author.username ==> registerBody.username 320 | rs2.status ==> Status.Ok 321 | article2.title ==> createArticleBody2.title 322 | article2.description ==> createArticleBody2.description 323 | article2.body ==> createArticleBody2.body 324 | article2.favoritesCount ==> 0 325 | article2.favorited ==> false 326 | Some(article2.tagList.toSet) ==> createArticleBody2.tagList.map(_.toSet) 327 | article2.author.username ==> registerBody.username 328 | } 329 | 330 | t.unsafeRunSync() 331 | } 332 | 333 | test("authenticated user should get errors when creating article with invalid title, body or description") { 334 | val registerBody = RegisterUserBody("username", "email@email.com", "password123") 335 | val createArticleBody = CreateArticleBody("", "", "", Some(List("tag1", "tag2", "tag3"))) 336 | 337 | val t = for { 338 | jwt <- logon(registerBody) 339 | rs <- postWithToken("articles", WrappedArticleBody(createArticleBody), jwt) 340 | errors <- rs.as[ValidationErrorResponse].map(_.errors) 341 | } yield { 342 | rs.status ==> Status.UnprocessableEntity 343 | errors.size ==> 3 344 | errors.get("title") ==> Some(List("can't be blank")) 345 | errors.get("body") ==> Some(List("can't be blank")) 346 | errors.get("description") ==> Some(List("can't be blank")) 347 | } 348 | 349 | t.unsafeRunSync() 350 | } 351 | 352 | test("not authenticated user should get error") { 353 | val createArticleBody = CreateArticleBody("title", "description", "body", Some(List("tag1", "tag2", "tag3"))) 354 | 355 | val t = for { 356 | rs <- post("articles", WrappedArticleBody(createArticleBody)) 357 | } yield { 358 | rs.status ==> Status.Unauthorized 359 | } 360 | 361 | t.unsafeRunSync() 362 | } 363 | } 364 | 365 | test("update") { 366 | test("authenticated user should update article") { 367 | val registerBody = RegisterUserBody("username", "email@email.com", "password123") 368 | val createArticleBody = CreateArticleBody("title", "description", "body", Some(List("tag1", "tag2", "tag3"))) 369 | val updateArticleBody = UpdateArticleBody(Some("newTitle"), Some("newDescription"), Some("newBody")) 370 | 371 | val t = for { 372 | jwt <- logon(registerBody) 373 | rs1 <- postWithToken("articles", WrappedArticleBody(createArticleBody), jwt) 374 | slug <- rs1.as[CreateArticleOutput].map(_.article.slug) 375 | rs2 <- putWithToken(s"articles/$slug", WrappedArticleBody(updateArticleBody), jwt) 376 | article <- rs2.as[UpdateArticleOutput].map(_.article) 377 | } yield { 378 | rs1.status ==> Status.Ok 379 | rs2.status ==> Status.Ok 380 | Some(article.title) ==> updateArticleBody.title 381 | Some(article.description) ==> updateArticleBody.description 382 | Some(article.body) ==> updateArticleBody.body 383 | article.favoritesCount ==> 0 384 | article.favorited ==> false 385 | Some(article.tagList.toSet) ==> createArticleBody.tagList.map(_.toSet) 386 | article.author.username ==> registerBody.username 387 | } 388 | 389 | t.unsafeRunSync() 390 | } 391 | 392 | test("authenticated user should get errors when updating article with invalid title, body or description") { 393 | val registerBody = RegisterUserBody("username", "email@email.com", "password123") 394 | val createArticleBody = CreateArticleBody("title", "description", "body", Some(List("tag1", "tag2", "tag3"))) 395 | val updateArticleBody = UpdateArticleBody(Some(""), Some(""), Some("")) 396 | 397 | val t = for { 398 | jwt <- logon(registerBody) 399 | rs1 <- postWithToken("articles", WrappedArticleBody(createArticleBody), jwt) 400 | slug <- rs1.as[CreateArticleOutput].map(_.article.slug) 401 | rs2 <- putWithToken(s"articles/$slug", WrappedArticleBody(updateArticleBody), jwt) 402 | errors <- rs2.as[ValidationErrorResponse].map(_.errors) 403 | } yield { 404 | rs1.status ==> Status.Ok 405 | rs2.status ==> Status.UnprocessableEntity 406 | errors.size ==> 3 407 | errors.get("title") ==> Some(List("can't be blank")) 408 | errors.get("body") ==> Some(List("can't be blank")) 409 | errors.get("description") ==> Some(List("can't be blank")) 410 | } 411 | 412 | t.unsafeRunSync() 413 | } 414 | 415 | test("authenticated user should get not found when updating non existing article") { 416 | val registerBody = RegisterUserBody("username", "email@email.com", "password123") 417 | val updateArticleBody = UpdateArticleBody(Some("newTitle"), Some("newDescription"), Some("newBody")) 418 | 419 | val t = for { 420 | jwt <- logon(registerBody) 421 | rs <- putWithToken("articles/non-existing-slug", WrappedArticleBody(updateArticleBody), jwt) 422 | } yield { 423 | rs.status ==> Status.NotFound 424 | } 425 | 426 | t.unsafeRunSync() 427 | } 428 | 429 | test("authenticated user should get not found when updating another's article") { 430 | val registerBody1 = RegisterUserBody("username1", "email1@email.com", "password123") 431 | val registerBody2 = RegisterUserBody("username2", "email2@email.com", "password123") 432 | val createArticleBody = CreateArticleBody("title", "description", "body", Some(List("tag1", "tag2", "tag3"))) 433 | val updateArticleBody = UpdateArticleBody(Some("newTitle"), Some("newDescription"), Some("newBody")) 434 | 435 | val t = for { 436 | jwt1 <- logon(registerBody1) 437 | rs1 <- postWithToken("articles", WrappedArticleBody(createArticleBody), jwt1) 438 | slug <- rs1.as[CreateArticleOutput].map(_.article.slug) 439 | jwt2 <- logon(registerBody2) 440 | rs2 <- putWithToken(s"articles/$slug", WrappedArticleBody(updateArticleBody), jwt2) 441 | } yield { 442 | rs1.status ==> Status.Ok 443 | rs2.status ==> Status.NotFound 444 | } 445 | 446 | t.unsafeRunSync() 447 | } 448 | 449 | test("not authenticated user should get error") { 450 | val updateArticleBody = UpdateArticleBody(Some("newTitle"), Some(""), Some("")) 451 | 452 | val t = for { 453 | rs <- put("articles/slug", WrappedArticleBody(updateArticleBody)) 454 | } yield { 455 | rs.status ==> Status.Unauthorized 456 | } 457 | 458 | t.unsafeRunSync() 459 | } 460 | } 461 | 462 | test("delete") { 463 | test("authenticated user should delete article") { 464 | val registerBody = RegisterUserBody("username", "email@email.com", "password123") 465 | val createArticleBody = CreateArticleBody("title", "description", "body", Some(List("tag1", "tag2", "tag3"))) 466 | 467 | val t = for { 468 | jwt <- logon(registerBody) 469 | rs1 <- postWithToken("articles", WrappedArticleBody(createArticleBody), jwt) 470 | slug <- rs1.as[CreateArticleOutput].map(_.article.slug) 471 | rs2 <- deleteWithToken(s"articles/$slug", jwt) 472 | rs3 <- getWithToken(s"articles/$slug", jwt) 473 | } yield { 474 | rs1.status ==> Status.Ok 475 | rs2.status ==> Status.Ok 476 | rs3.status ==> Status.NotFound 477 | } 478 | 479 | t.unsafeRunSync() 480 | } 481 | 482 | test("authenticated user should get not found when article does not exist") { 483 | val registerBody = RegisterUserBody("username", "email@email.com", "password123") 484 | 485 | val t = for { 486 | jwt <- logon(registerBody) 487 | rs <- deleteWithToken("articles/non-exising-slug", jwt) 488 | } yield { 489 | rs.status ==> Status.NotFound 490 | } 491 | 492 | t.unsafeRunSync() 493 | } 494 | 495 | test("authenticated user should get not found when deleting another's article") { 496 | val registerBody1 = RegisterUserBody("username1", "email1@email.com", "password123") 497 | val registerBody2 = RegisterUserBody("username2", "email2@email.com", "password123") 498 | val createArticleBody = CreateArticleBody("title", "description", "body", Some(List("tag1", "tag2", "tag3"))) 499 | 500 | val t = for { 501 | jwt1 <- logon(registerBody1) 502 | rs1 <- postWithToken("articles", WrappedArticleBody(createArticleBody), jwt1) 503 | slug <- rs1.as[CreateArticleOutput].map(_.article.slug) 504 | jwt2 <- logon(registerBody2) 505 | rs2 <- deleteWithToken(s"articles/$slug", jwt2) 506 | rs3 <- getWithToken(s"articles/$slug", jwt1) 507 | } yield { 508 | rs1.status ==> Status.Ok 509 | rs2.status ==> Status.NotFound 510 | rs3.status ==> Status.Ok 511 | } 512 | 513 | t.unsafeRunSync() 514 | } 515 | 516 | test("not authenticated user should get error") { 517 | val t = for { 518 | rs <- delete("articles/slug") 519 | } yield { 520 | rs.status ==> Status.Unauthorized 521 | } 522 | 523 | t.unsafeRunSync() 524 | } 525 | } 526 | 527 | test("favorite") { 528 | test("authenticated user should favorite article") { 529 | val registerBody1 = RegisterUserBody("username1", "email1@email.com", "password123") 530 | val registerBody2 = RegisterUserBody("username2", "email2@email.com", "password123") 531 | val registerBody3 = RegisterUserBody("username3", "email3@email.com", "password123") 532 | val createArticleBody = CreateArticleBody("title", "description", "body", Some(List("tag1", "tag2", "tag3"))) 533 | 534 | val t = for { 535 | jwt1 <- logon(registerBody1) 536 | rs1 <- postWithToken("articles", WrappedArticleBody(createArticleBody), jwt1) 537 | slug <- rs1.as[CreateArticleOutput].map(_.article.slug) 538 | jwt2 <- logon(registerBody2) 539 | rs2 <- postWithToken(s"articles/$slug/favorite", jwt2) 540 | article1 <- rs2.as[FavoriteArticleOutput].map(_.article) 541 | jwt3 <- logon(registerBody3) 542 | rs3 <- postWithToken(s"articles/$slug/favorite", jwt3) 543 | article2 <- rs3.as[FavoriteArticleOutput].map(_.article) 544 | } yield { 545 | rs1.status ==> Status.Ok 546 | rs2.status ==> Status.Ok 547 | rs3.status ==> Status.Ok 548 | article1.favoritesCount ==> 1 549 | article1.favorited ==> true 550 | article2.favoritesCount ==> 2 551 | article2.favorited ==> true 552 | } 553 | 554 | t.unsafeRunSync() 555 | } 556 | 557 | test("authenticated user should favorite article twice") { 558 | val registerBody1 = RegisterUserBody("username1", "email1@email.com", "password123") 559 | val registerBody2 = RegisterUserBody("username2", "email2@email.com", "password123") 560 | val createArticleBody = CreateArticleBody("title", "description", "body", Some(List("tag1", "tag2", "tag3"))) 561 | 562 | val t = for { 563 | jwt1 <- logon(registerBody1) 564 | rs1 <- postWithToken("articles", WrappedArticleBody(createArticleBody), jwt1) 565 | slug <- rs1.as[CreateArticleOutput].map(_.article.slug) 566 | jwt2 <- logon(registerBody2) 567 | rs2 <- postWithToken(s"articles/$slug/favorite", jwt2) 568 | article1 <- rs2.as[FavoriteArticleOutput].map(_.article) 569 | rs3 <- postWithToken(s"articles/$slug/favorite", jwt2) 570 | article2 <- rs3.as[FavoriteArticleOutput].map(_.article) 571 | } yield { 572 | rs1.status ==> Status.Ok 573 | rs2.status ==> Status.Ok 574 | rs3.status ==> Status.Ok 575 | article1.favoritesCount ==> 1 576 | article1.favorited ==> true 577 | article2.favoritesCount ==> 1 578 | article2.favorited ==> true 579 | } 580 | 581 | t.unsafeRunSync() 582 | } 583 | 584 | test("authenticated user should get not found when article does not exist") { 585 | val registerBody = RegisterUserBody("username", "email@email.com", "password123") 586 | 587 | val t = for { 588 | jwt <- logon(registerBody) 589 | rs <- postWithToken(s"articles/non-existing-slug/favorite", jwt) 590 | } yield { 591 | rs.status ==> Status.NotFound 592 | } 593 | 594 | t.unsafeRunSync() 595 | } 596 | 597 | test("not authenticated user should get error") { 598 | val t = for { 599 | rs <- post(s"articles/non-existing-slug/favorite") 600 | } yield { 601 | rs.status ==> Status.Unauthorized 602 | } 603 | 604 | t.unsafeRunSync() 605 | } 606 | } 607 | 608 | test("unfavorite") { 609 | test("authenticated user should unfavorite article") { 610 | val registerBody1 = RegisterUserBody("username1", "email1@email.com", "password123") 611 | val registerBody2 = RegisterUserBody("username2", "email2@email.com", "password123") 612 | val createArticleBody = CreateArticleBody("title", "description", "body", Some(List("tag1", "tag2", "tag3"))) 613 | 614 | val t = for { 615 | jwt1 <- logon(registerBody1) 616 | rs1 <- postWithToken("articles", WrappedArticleBody(createArticleBody), jwt1) 617 | slug <- rs1.as[CreateArticleOutput].map(_.article.slug) 618 | jwt2 <- logon(registerBody2) 619 | rs2 <- postWithToken(s"articles/$slug/favorite", jwt2) 620 | article1 <- rs2.as[UnfavoriteArticleOutput].map(_.article) 621 | rs3 <- deleteWithToken(s"articles/$slug/favorite", jwt2) 622 | article2 <- rs3.as[UnfavoriteArticleOutput].map(_.article) 623 | } yield { 624 | rs1.status ==> Status.Ok 625 | rs2.status ==> Status.Ok 626 | rs3.status ==> Status.Ok 627 | article1.favoritesCount ==> 1 628 | article1.favorited ==> true 629 | article2.favoritesCount ==> 0 630 | article2.favorited ==> false 631 | } 632 | 633 | t.unsafeRunSync() 634 | } 635 | 636 | test("authenticated user should unfavorite article twice") { 637 | val registerBody1 = RegisterUserBody("username1", "email1@email.com", "password123") 638 | val registerBody2 = RegisterUserBody("username2", "email2@email.com", "password123") 639 | val createArticleBody = CreateArticleBody("title", "description", "body", Some(List("tag1", "tag2", "tag3"))) 640 | 641 | val t = for { 642 | jwt1 <- logon(registerBody1) 643 | rs1 <- postWithToken("articles", WrappedArticleBody(createArticleBody), jwt1) 644 | slug <- rs1.as[CreateArticleOutput].map(_.article.slug) 645 | jwt2 <- logon(registerBody2) 646 | rs2 <- postWithToken(s"articles/$slug/favorite", jwt2) 647 | article1 <- rs2.as[UnfavoriteArticleOutput].map(_.article) 648 | rs3 <- deleteWithToken(s"articles/$slug/favorite", jwt2) 649 | article2 <- rs3.as[UnfavoriteArticleOutput].map(_.article) 650 | rs4 <- deleteWithToken(s"articles/$slug/favorite", jwt2) 651 | article3 <- rs4.as[UnfavoriteArticleOutput].map(_.article) 652 | } yield { 653 | rs1.status ==> Status.Ok 654 | rs2.status ==> Status.Ok 655 | rs3.status ==> Status.Ok 656 | rs4.status ==> Status.Ok 657 | article1.favoritesCount ==> 1 658 | article1.favorited ==> true 659 | article2.favoritesCount ==> 0 660 | article2.favorited ==> false 661 | article3.favoritesCount ==> 0 662 | article3.favorited ==> false 663 | } 664 | 665 | t.unsafeRunSync() 666 | } 667 | 668 | test("authenticated user should get not found when article does not exist") { 669 | val registerBody = RegisterUserBody("username", "email@email.com", "password123") 670 | 671 | val t = for { 672 | jwt <- logon(registerBody) 673 | rs <- deleteWithToken(s"articles/non-existing-slug/favorite", jwt) 674 | } yield { 675 | rs.status ==> Status.NotFound 676 | } 677 | 678 | t.unsafeRunSync() 679 | } 680 | 681 | test("not authenticated user should get error") { 682 | val t = for { 683 | rs <- delete(s"articles/non-existing-slug/favorite") 684 | } yield { 685 | rs.status ==> Status.Unauthorized 686 | } 687 | 688 | t.unsafeRunSync() 689 | } 690 | } 691 | 692 | def generateCreateArticleBodies(n: Int, tags: List[String] = List.empty): List[CreateArticleBody] = 693 | List.fill(n) { 694 | val title = Random.shuffle("articletitle").mkString 695 | val description = Random.shuffle("articledescription").mkString 696 | val body = Random.shuffle("articlebody").mkString 697 | val tagList = if (tags.isEmpty) List("tag1", "tag2", "tag3", "tag4").map(Random.shuffle(_).mkString) else tags 698 | CreateArticleBody(title, description, body, Some(tagList)) 699 | } 700 | 701 | implicit val app: HttpApp[IO] = { 702 | val passwordHasher = PasswordHasher.impl 703 | val idHasher = IdHasher.impl("salt") 704 | val userRepo = UserRepo.impl(xa) 705 | val articleRepo = ArticleRepo.impl(xa) 706 | val followerRepo = FollowerRepo.impl(xa) 707 | val tagRepo = TagRepo.impl(xa) 708 | val favoriteRepo = FavoriteRepo.impl(xa) 709 | val userApis = UserApis.impl(passwordHasher, token, userRepo) 710 | val profileApis = ProfileApis.impl(userRepo, followerRepo) 711 | val articleApis = ArticleApis.impl(articleRepo, followerRepo, tagRepo, favoriteRepo, idHasher) 712 | mkApp(List(UserRoutes(userApis), ProfileRoutes(profileApis), ArticleRoutes(articleApis))) 713 | } 714 | } 715 | } 716 | -------------------------------------------------------------------------------- /src/test/scala/routes/CommentRoutesTests.scala: -------------------------------------------------------------------------------- 1 | package test.io.rw.app.routes 2 | 3 | import cats._ 4 | import cats.data._ 5 | import cats.effect.IO 6 | import cats.implicits._ 7 | import io.circe.generic.auto._ 8 | import io.rw.app.apis._ 9 | import io.rw.app.data._ 10 | import io.rw.app.data.ApiErrors._ 11 | import io.rw.app.data.ApiInputs._ 12 | import io.rw.app.data.ApiOutputs._ 13 | import io.rw.app.data.RequestBodies._ 14 | import io.rw.app.repos._ 15 | import io.rw.app.routes._ 16 | import io.rw.app.security._ 17 | import io.rw.app.utils._ 18 | import org.http4s._ 19 | import org.http4s.circe.CirceEntityCodec._ 20 | import scala.util.Random 21 | import test.io.rw.app.WithEmbededDbTestSuite 22 | import utest._ 23 | 24 | object CommentRoutesTests extends WithEmbededDbTestSuite { 25 | 26 | val tests = Tests { 27 | 28 | test("add") { 29 | test("authenticated user should add comment to other's article") { 30 | val registerBody1 = RegisterUserBody("username1", "email1@email.com", "password123") 31 | val registerBody2 = RegisterUserBody("username2", "email2@email.com", "password123") 32 | val createArticleBody = CreateArticleBody("title", "description", "body", Some(List("tag1", "tag2", "tag3"))) 33 | val addCommentBody = AddCommentBody("some comment") 34 | 35 | val t = for { 36 | jwt1 <- logon(registerBody1) 37 | rs1 <- postWithToken("articles", WrappedArticleBody(createArticleBody), jwt1) 38 | slug <- rs1.as[CreateArticleOutput].map(_.article.slug) 39 | jwt2 <- logon(registerBody2) 40 | rs2 <- postWithToken(s"articles/$slug/comments", WrappedCommentBody(addCommentBody), jwt2) 41 | comment <- rs2.as[AddCommentOutput].map(_.comment) 42 | } yield { 43 | rs1.status ==> Status.Ok 44 | rs2.status ==> Status.Ok 45 | comment.body ==> addCommentBody.body 46 | comment.author.username ==> registerBody2.username 47 | } 48 | 49 | t.unsafeRunSync() 50 | } 51 | 52 | test("authenticated user should add comment to its own article") { 53 | val registerBody = RegisterUserBody("username", "email@email.com", "password123") 54 | val createArticleBody = CreateArticleBody("title", "description", "body", Some(List("tag1", "tag2", "tag3"))) 55 | val addCommentBody = AddCommentBody("some comment") 56 | 57 | val t = for { 58 | jwt <- logon(registerBody) 59 | rs1 <- postWithToken("articles", WrappedArticleBody(createArticleBody), jwt) 60 | slug <- rs1.as[CreateArticleOutput].map(_.article.slug) 61 | rs2 <- postWithToken(s"articles/$slug/comments", WrappedCommentBody(addCommentBody), jwt) 62 | comment <- rs2.as[AddCommentOutput].map(_.comment) 63 | } yield { 64 | rs1.status ==> Status.Ok 65 | rs2.status ==> Status.Ok 66 | comment.body ==> addCommentBody.body 67 | comment.author.username ==> registerBody.username 68 | } 69 | 70 | t.unsafeRunSync() 71 | } 72 | 73 | test("authenticated user should add multiple comments article") { 74 | val registerBody = RegisterUserBody("username", "email@email.com", "password123") 75 | val createArticleBody = CreateArticleBody("title", "description", "body", Some(List("tag1", "tag2", "tag3"))) 76 | val addCommentBody1 = AddCommentBody("some comment 1") 77 | val addCommentBody2 = AddCommentBody("some comment 2") 78 | 79 | val t = for { 80 | jwt <- logon(registerBody) 81 | rs1 <- postWithToken("articles", WrappedArticleBody(createArticleBody), jwt) 82 | slug <- rs1.as[CreateArticleOutput].map(_.article.slug) 83 | rs2 <- postWithToken(s"articles/$slug/comments", WrappedCommentBody(addCommentBody1), jwt) 84 | comment1 <- rs2.as[AddCommentOutput].map(_.comment) 85 | rs3 <- postWithToken(s"articles/$slug/comments", WrappedCommentBody(addCommentBody2), jwt) 86 | comment2 <- rs3.as[AddCommentOutput].map(_.comment) 87 | } yield { 88 | rs1.status ==> Status.Ok 89 | rs2.status ==> Status.Ok 90 | rs3.status ==> Status.Ok 91 | comment1.body ==> addCommentBody1.body 92 | comment1.author.username ==> registerBody.username 93 | comment2.body ==> addCommentBody2.body 94 | comment2.author.username ==> registerBody.username 95 | } 96 | 97 | t.unsafeRunSync() 98 | } 99 | 100 | test("authenticated user should add comments to different articles") { 101 | val registerBody = RegisterUserBody("username", "email@email.com", "password123") 102 | val createArticleBody1 = CreateArticleBody("title1", "description1", "body1", Some(List("tag1", "tag2", "tag3"))) 103 | val createArticleBody2 = CreateArticleBody("title2", "description2", "body2", Some(List("tag1", "tag2", "tag3"))) 104 | val addCommentBody = AddCommentBody("some comment") 105 | 106 | val t = for { 107 | jwt <- logon(registerBody) 108 | rs1 <- postWithToken("articles", WrappedArticleBody(createArticleBody1), jwt) 109 | slug1 <- rs1.as[CreateArticleOutput].map(_.article.slug) 110 | rs2 <- postWithToken("articles", WrappedArticleBody(createArticleBody1), jwt) 111 | slug2 <- rs2.as[CreateArticleOutput].map(_.article.slug) 112 | rs3 <- postWithToken(s"articles/$slug1/comments", WrappedCommentBody(addCommentBody), jwt) 113 | comment1 <- rs3.as[AddCommentOutput].map(_.comment) 114 | rs4 <- postWithToken(s"articles/$slug2/comments", WrappedCommentBody(addCommentBody), jwt) 115 | comment2 <- rs4.as[AddCommentOutput].map(_.comment) 116 | } yield { 117 | rs1.status ==> Status.Ok 118 | rs2.status ==> Status.Ok 119 | rs3.status ==> Status.Ok 120 | rs4.status ==> Status.Ok 121 | comment1.body ==> addCommentBody.body 122 | comment1.author.username ==> registerBody.username 123 | comment2.body ==> addCommentBody.body 124 | comment2.author.username ==> registerBody.username 125 | } 126 | 127 | t.unsafeRunSync() 128 | } 129 | 130 | test("authenticated user should get errors when adding comment with invalid body") { 131 | val registerBody = RegisterUserBody("username", "email@email.com", "password123") 132 | val createArticleBody = CreateArticleBody("title", "description", "body", Some(List("tag1", "tag2", "tag3"))) 133 | val addCommentBody = AddCommentBody("") 134 | 135 | val t = for { 136 | jwt <- logon(registerBody) 137 | rs1 <- postWithToken("articles", WrappedArticleBody(createArticleBody), jwt) 138 | slug <- rs1.as[CreateArticleOutput].map(_.article.slug) 139 | rs2 <- postWithToken(s"articles/$slug/comments", WrappedCommentBody(addCommentBody), jwt) 140 | errors <- rs2.as[ValidationErrorResponse].map(_.errors) 141 | } yield { 142 | rs1.status ==> Status.Ok 143 | rs2.status ==> Status.UnprocessableEntity 144 | errors.size ==> 1 145 | errors.get("body") ==> Some(List("can't be blank")) 146 | } 147 | 148 | t.unsafeRunSync() 149 | } 150 | 151 | test("authenticated user should get not found when article does not exist") { 152 | val registerBody = RegisterUserBody("username", "email@email.com", "password123") 153 | val addCommentBody = AddCommentBody("some comment") 154 | 155 | val t = for { 156 | jwt <- logon(registerBody) 157 | rs <- postWithToken("articles/non-existing-slug/comments", WrappedCommentBody(addCommentBody), jwt) 158 | } yield { 159 | rs.status ==> Status.NotFound 160 | } 161 | 162 | t.unsafeRunSync() 163 | } 164 | 165 | test("not authenticated user should get error") { 166 | val addCommentBody = AddCommentBody("some comment") 167 | 168 | val t = for { 169 | rs <- post("articles/slug1/comments", WrappedCommentBody(addCommentBody)) 170 | } yield { 171 | rs.status ==> Status.Unauthorized 172 | } 173 | 174 | t.unsafeRunSync() 175 | } 176 | } 177 | 178 | test("get") { 179 | test("authenticated user should get all comments") { 180 | val registerBody = RegisterUserBody("username", "email@email.com", "password123") 181 | val createArticleBody = CreateArticleBody("title", "description", "body", Some(List("tag1", "tag2", "tag3"))) 182 | val addCommentBodies = generateAddCommentBodies(10) 183 | 184 | val t = for { 185 | jwt <- logon(registerBody) 186 | rs1 <- postWithToken("articles", WrappedArticleBody(createArticleBody), jwt) 187 | slug <- rs1.as[CreateArticleOutput].map(_.article.slug) 188 | _ <- addCommentBodies.map(b => postWithToken(s"articles/$slug/comments", WrappedCommentBody(b), jwt)).sequence 189 | rs2 <- getWithToken(s"articles/$slug/comments", jwt) 190 | comments <- rs2.as[GetCommentsOutput].map(_.comments) 191 | } yield { 192 | rs1.status ==> Status.Ok 193 | rs2.status ==> Status.Ok 194 | comments.size ==> addCommentBodies.size 195 | } 196 | 197 | t.unsafeRunSync() 198 | } 199 | 200 | test("authenticated user should get zero comments when article is not commented yet") { 201 | val registerBody = RegisterUserBody("username", "email@email.com", "password123") 202 | val createArticleBody = CreateArticleBody("title", "description", "body", Some(List("tag1", "tag2", "tag3"))) 203 | 204 | val t = for { 205 | jwt <- logon(registerBody) 206 | rs1 <- postWithToken("articles", WrappedArticleBody(createArticleBody), jwt) 207 | slug <- rs1.as[CreateArticleOutput].map(_.article.slug) 208 | rs2 <- getWithToken(s"articles/$slug/comments", jwt) 209 | comments <- rs2.as[GetCommentsOutput].map(_.comments) 210 | } yield { 211 | rs1.status ==> Status.Ok 212 | rs2.status ==> Status.Ok 213 | comments.size ==> 0 214 | } 215 | 216 | t.unsafeRunSync() 217 | } 218 | 219 | test("authenticated user should get not found when article does not exist") { 220 | val registerBody = RegisterUserBody("username", "email@email.com", "password123") 221 | 222 | val t = for { 223 | jwt <- logon(registerBody) 224 | rs <- getWithToken(s"articles/not-existing-slug/comments", jwt) 225 | } yield { 226 | rs.status ==> Status.NotFound 227 | } 228 | 229 | t.unsafeRunSync() 230 | } 231 | 232 | test("not authenticated user should get all comments") { 233 | val registerBody = RegisterUserBody("username", "email@email.com", "password123") 234 | val createArticleBody = CreateArticleBody("title", "description", "body", Some(List("tag1", "tag2", "tag3"))) 235 | val addCommentBodies = generateAddCommentBodies(10) 236 | 237 | val t = for { 238 | jwt <- logon(registerBody) 239 | rs1 <- postWithToken("articles", WrappedArticleBody(createArticleBody), jwt) 240 | slug <- rs1.as[CreateArticleOutput].map(_.article.slug) 241 | _ <- addCommentBodies.map(b => postWithToken(s"articles/$slug/comments", WrappedCommentBody(b), jwt)).sequence 242 | rs2 <- get(s"articles/$slug/comments") 243 | comments <- rs2.as[GetCommentsOutput].map(_.comments) 244 | } yield { 245 | rs1.status ==> Status.Ok 246 | rs2.status ==> Status.Ok 247 | comments.size ==> addCommentBodies.size 248 | } 249 | 250 | t.unsafeRunSync() 251 | } 252 | } 253 | 254 | test("delete") { 255 | test("authenticated user should delete comment") { 256 | val registerBody = RegisterUserBody("username", "email@email.com", "password123") 257 | val createArticleBody = CreateArticleBody("title", "description", "body", Some(List("tag1", "tag2", "tag3"))) 258 | val addCommentBody = AddCommentBody("some comment") 259 | 260 | val t = for { 261 | jwt <- logon(registerBody) 262 | rs1 <- postWithToken("articles", WrappedArticleBody(createArticleBody), jwt) 263 | slug <- rs1.as[CreateArticleOutput].map(_.article.slug) 264 | rs2 <- postWithToken(s"articles/$slug/comments", WrappedCommentBody(addCommentBody), jwt) 265 | comment <- rs2.as[AddCommentOutput].map(_.comment) 266 | rs3 <- deleteWithToken(s"articles/$slug/comments/${comment.id}", jwt) 267 | } yield { 268 | rs1.status ==> Status.Ok 269 | rs2.status ==> Status.Ok 270 | rs3.status ==> Status.Ok 271 | } 272 | 273 | t.unsafeRunSync() 274 | } 275 | 276 | test("authenticated user should get not found when comment does not exist") { 277 | val registerBody = RegisterUserBody("username", "email@email.com", "password123") 278 | val createArticleBody = CreateArticleBody("title", "description", "body", Some(List("tag1", "tag2", "tag3"))) 279 | 280 | val t = for { 281 | jwt <- logon(registerBody) 282 | rs1 <- postWithToken("articles", WrappedArticleBody(createArticleBody), jwt) 283 | slug <- rs1.as[CreateArticleOutput].map(_.article.slug) 284 | rs2 <- deleteWithToken(s"articles/$slug/comments/123", jwt) 285 | } yield { 286 | rs1.status ==> Status.Ok 287 | rs2.status ==> Status.NotFound 288 | } 289 | 290 | t.unsafeRunSync() 291 | } 292 | 293 | test("authenticated user should get not found when deleting another's comment") { 294 | val registerBody1 = RegisterUserBody("username1", "email1@email.com", "password123") 295 | val registerBody2 = RegisterUserBody("username2", "email2@email.com", "password123") 296 | val createArticleBody = CreateArticleBody("title", "description", "body", Some(List("tag1", "tag2", "tag3"))) 297 | val addCommentBody = AddCommentBody("some comment") 298 | 299 | val t = for { 300 | jwt1 <- logon(registerBody1) 301 | rs1 <- postWithToken("articles", WrappedArticleBody(createArticleBody), jwt1) 302 | slug <- rs1.as[CreateArticleOutput].map(_.article.slug) 303 | rs2 <- postWithToken(s"articles/$slug/comments", WrappedCommentBody(addCommentBody), jwt1) 304 | comment <- rs2.as[AddCommentOutput].map(_.comment) 305 | jwt2 <- logon(registerBody2) 306 | rs3 <- deleteWithToken(s"articles/$slug/comments/${comment.id}", jwt2) 307 | } yield { 308 | rs1.status ==> Status.Ok 309 | rs2.status ==> Status.Ok 310 | rs3.status ==> Status.NotFound 311 | } 312 | 313 | t.unsafeRunSync() 314 | } 315 | 316 | test("authenticated user should get not found when article does not exist") { 317 | val registerBody = RegisterUserBody("username", "email@email.com", "password123") 318 | 319 | val t = for { 320 | jwt <- logon(registerBody) 321 | rs <- deleteWithToken("articles/non-existing-slug/comments/123", jwt) 322 | } yield { 323 | rs.status ==> Status.NotFound 324 | } 325 | 326 | t.unsafeRunSync() 327 | } 328 | 329 | test("not authenticated user should get error") { 330 | val t = for { 331 | rs <- delete(s"articles/not-existing-slug/comments/1") 332 | } yield { 333 | rs.status ==> Status.Unauthorized 334 | } 335 | 336 | t.unsafeRunSync() 337 | } 338 | } 339 | 340 | def generateAddCommentBodies(n: Int, tags: List[String] = List.empty): List[AddCommentBody] = 341 | List.fill(n) { 342 | val body = Random.shuffle("comment").mkString 343 | AddCommentBody(body) 344 | } 345 | 346 | implicit val app: HttpApp[IO] = { 347 | val passwordHasher = PasswordHasher.impl 348 | val idHasher = IdHasher.impl("salt") 349 | val userRepo = UserRepo.impl(xa) 350 | val articleRepo = ArticleRepo.impl(xa) 351 | val followerRepo = FollowerRepo.impl(xa) 352 | val tagRepo = TagRepo.impl(xa) 353 | val favoriteRepo = FavoriteRepo.impl(xa) 354 | val commentRepo = CommentRepo.impl(xa) 355 | val userApis = UserApis.impl(passwordHasher, token, userRepo) 356 | val articleApis = ArticleApis.impl(articleRepo, followerRepo, tagRepo, favoriteRepo, idHasher) 357 | val commentApis = CommentApis.impl(commentRepo, articleRepo, followerRepo) 358 | mkApp(List(UserRoutes(userApis), ArticleRoutes(articleApis), CommentRoutes(commentApis))) 359 | } 360 | } 361 | } 362 | -------------------------------------------------------------------------------- /src/test/scala/routes/ProfileRoutesTests.scala: -------------------------------------------------------------------------------- 1 | package test.io.rw.app.routes 2 | 3 | import cats._ 4 | import cats.data._ 5 | import cats.effect.IO 6 | import io.circe.generic.auto._ 7 | import io.rw.app.apis._ 8 | import io.rw.app.data._ 9 | import io.rw.app.data.ApiErrors._ 10 | import io.rw.app.data.ApiInputs._ 11 | import io.rw.app.data.ApiOutputs._ 12 | import io.rw.app.data.RequestBodies._ 13 | import io.rw.app.repos._ 14 | import io.rw.app.routes._ 15 | import io.rw.app.security._ 16 | import org.http4s._ 17 | import org.http4s.circe.CirceEntityCodec._ 18 | import test.io.rw.app.WithEmbededDbTestSuite 19 | import utest._ 20 | 21 | object ProfileRoutesTests extends WithEmbededDbTestSuite { 22 | 23 | val tests = Tests { 24 | test("profile") { 25 | test("authenticated user should get profile") { 26 | val registerBody = RegisterUserBody("username", "email@email.com", "password123") 27 | 28 | val t = for { 29 | jwt <- logon(registerBody) 30 | rs <- getWithToken(s"profiles/${registerBody.username}", jwt) 31 | profile <- rs.as[GetProfileOutput].map(_.profile) 32 | } yield { 33 | rs.status ==> Status.Ok 34 | profile.username ==> registerBody.username 35 | } 36 | 37 | t.unsafeRunSync() 38 | } 39 | 40 | test("authenticated user should get not found error when profile does not exist") { 41 | val registerBody = RegisterUserBody("username", "email@email.com", "password123") 42 | 43 | val t = for { 44 | jwt <- logon(registerBody) 45 | rs <- getWithToken("profiles/username1", jwt) 46 | } yield { 47 | rs.status ==> Status.NotFound 48 | } 49 | 50 | t.unsafeRunSync() 51 | } 52 | 53 | test("not authenticated user should get profile") { 54 | val registerBody = RegisterUserBody("username", "email@email.com", "password123") 55 | 56 | val t = for { 57 | _ <- logon(registerBody) 58 | rs <- get(s"profiles/${registerBody.username}") 59 | profile <- rs.as[GetProfileOutput].map(_.profile) 60 | } yield { 61 | rs.status ==> Status.Ok 62 | profile.username ==> registerBody.username 63 | } 64 | 65 | t.unsafeRunSync() 66 | } 67 | } 68 | 69 | test("follow") { 70 | test("authenticated user should follow existing user") { 71 | val registerBody1 = RegisterUserBody("username1", "email1@email.com", "password123") 72 | val registerBody2 = RegisterUserBody("username2", "email2@email.com", "password123") 73 | 74 | val t = for { 75 | jwt <- logon(registerBody1) 76 | _ <- logon(registerBody2) 77 | rs <- postWithToken(s"profiles/${registerBody2.username}/follow", jwt) 78 | profile <- rs.as[FollowUserOutput].map(_.profile) 79 | } yield { 80 | rs.status ==> Status.Ok 81 | profile.username ==> registerBody2.username 82 | profile.following ==> true 83 | } 84 | 85 | t.unsafeRunSync() 86 | } 87 | 88 | test("authenticated user should get not found when profile does not exist") { 89 | val registerBody = RegisterUserBody("username", "email@email.com", "password123") 90 | 91 | val t = for { 92 | jwt <- logon(registerBody) 93 | rs <- postWithToken("profiles/username1/follow", jwt) 94 | } yield { 95 | rs.status ==> Status.NotFound 96 | } 97 | 98 | t.unsafeRunSync() 99 | } 100 | 101 | test("not authenticated user should get error") { 102 | val t = for { 103 | rs <- delete("profiles/username/follow") 104 | } yield { 105 | rs.status ==> Status.Unauthorized 106 | } 107 | 108 | t.unsafeRunSync() 109 | } 110 | } 111 | 112 | test("unfollow") { 113 | test("authenticated user should unfollow existing user") { 114 | val registerBody1 = RegisterUserBody("username1", "email1@email.com", "password123") 115 | val registerBody2 = RegisterUserBody("username2", "email2@email.com", "password123") 116 | 117 | val t = for { 118 | jwt <- logon(registerBody1) 119 | _ <- logon(registerBody2) 120 | rs1 <- postWithToken(s"profiles/${registerBody2.username}/follow", jwt) 121 | rs2 <- deleteWithToken(s"profiles/${registerBody2.username}/follow", jwt) 122 | profile <- rs2.as[FollowUserOutput].map(_.profile) 123 | } yield { 124 | rs1.status ==> Status.Ok 125 | rs2.status ==> Status.Ok 126 | profile.username ==> registerBody2.username 127 | profile.following ==> false 128 | } 129 | 130 | t.unsafeRunSync() 131 | } 132 | 133 | test("authenticated user should get not found when profile does not exist") { 134 | val registerBody = RegisterUserBody("username", "email@email.com", "password123") 135 | 136 | val t = for { 137 | jwt <- logon(registerBody) 138 | rs <- deleteWithToken("profiles/username1/follow", jwt) 139 | } yield { 140 | rs.status ==> Status.NotFound 141 | } 142 | 143 | t.unsafeRunSync() 144 | } 145 | 146 | test("not authenticated user should get error") { 147 | val t = for { 148 | rs <- delete("profiles/username/follow") 149 | } yield { 150 | rs.status ==> Status.Unauthorized 151 | } 152 | 153 | t.unsafeRunSync() 154 | } 155 | } 156 | 157 | implicit val app: HttpApp[IO] = { 158 | val passwordHasher = PasswordHasher.impl 159 | val userRepo = UserRepo.impl(xa) 160 | val followerRepo = FollowerRepo.impl(xa) 161 | val userApis = UserApis.impl(passwordHasher, token, userRepo) 162 | val profileApis = ProfileApis.impl(userRepo, followerRepo) 163 | mkApp(List(UserRoutes(userApis), ProfileRoutes(profileApis))) 164 | } 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src/test/scala/routes/TagRoutesTests.scala: -------------------------------------------------------------------------------- 1 | package test.io.rw.app.routes 2 | 3 | import cats._ 4 | import cats.data._ 5 | import cats.effect.IO 6 | import cats.implicits._ 7 | import io.circe.generic.auto._ 8 | import io.rw.app.apis._ 9 | import io.rw.app.data._ 10 | import io.rw.app.data.ApiErrors._ 11 | import io.rw.app.data.ApiInputs._ 12 | import io.rw.app.data.ApiOutputs._ 13 | import io.rw.app.data.RequestBodies._ 14 | import io.rw.app.repos._ 15 | import io.rw.app.routes._ 16 | import io.rw.app.security._ 17 | import io.rw.app.utils._ 18 | import org.http4s._ 19 | import org.http4s.circe.CirceEntityCodec._ 20 | import scala.util.Random 21 | import test.io.rw.app.WithEmbededDbTestSuite 22 | import utest._ 23 | 24 | object TagRoutesTests extends WithEmbededDbTestSuite { 25 | 26 | val tests = Tests { 27 | 28 | test("get") { 29 | test("authenticated user should get all tags") { 30 | val registerBody = RegisterUserBody("username", "email@email.com", "password123") 31 | val mostPopularTag = "tagX" 32 | val tags1 = List(mostPopularTag, "tag1", "tag2", "tag3", "tag4") 33 | val tags2 = List("tag2", mostPopularTag, "tag4", "tag5", "tag6") 34 | val tags3 = List("tag7", "tag5", mostPopularTag) 35 | val createArticleBody1 = CreateArticleBody("title1", "description", "body", Some(tags1)) 36 | val createArticleBody2 = CreateArticleBody("title2", "description", "body", Some(tags2)) 37 | val createArticleBody3 = CreateArticleBody("title3", "description", "body", Some(tags3)) 38 | 39 | val t = for { 40 | jwt <- logon(registerBody) 41 | rs1 <- postWithToken("articles", WrappedArticleBody(createArticleBody1), jwt) 42 | rs2 <- postWithToken("articles", WrappedArticleBody(createArticleBody2), jwt) 43 | rs3 <- postWithToken("articles", WrappedArticleBody(createArticleBody3), jwt) 44 | rs4 <- getWithToken("tags", jwt) 45 | tags <- rs4.as[GetTagsOutput].map(_.tags) 46 | } yield { 47 | rs1.status ==> Status.Ok 48 | rs2.status ==> Status.Ok 49 | rs3.status ==> Status.Ok 50 | rs4.status ==> Status.Ok 51 | tags.size ==> (tags1 ++ tags2 ++ tags3).toSet.size 52 | tags.head ==> mostPopularTag 53 | } 54 | 55 | t.unsafeRunSync() 56 | } 57 | 58 | test("non authenticated user should get all tags") { 59 | val registerBody = RegisterUserBody("username", "email@email.com", "password123") 60 | val mostPopularTag = "tagX" 61 | val tags1 = List(mostPopularTag, "tag1", "tag2", "tag3", "tag4") 62 | val tags2 = List("tag2", mostPopularTag, "tag4", "tag5", "tag6") 63 | val tags3 = List("tag7", "tag5", mostPopularTag) 64 | val createArticleBody1 = CreateArticleBody("title1", "description", "body", Some(tags1)) 65 | val createArticleBody2 = CreateArticleBody("title2", "description", "body", Some(tags2)) 66 | val createArticleBody3 = CreateArticleBody("title3", "description", "body", Some(tags3)) 67 | 68 | val t = for { 69 | jwt <- logon(registerBody) 70 | rs1 <- postWithToken("articles", WrappedArticleBody(createArticleBody1), jwt) 71 | rs2 <- postWithToken("articles", WrappedArticleBody(createArticleBody2), jwt) 72 | rs3 <- postWithToken("articles", WrappedArticleBody(createArticleBody3), jwt) 73 | rs4 <- get("tags") 74 | tags <- rs4.as[GetTagsOutput].map(_.tags) 75 | } yield { 76 | rs1.status ==> Status.Ok 77 | rs2.status ==> Status.Ok 78 | rs3.status ==> Status.Ok 79 | rs4.status ==> Status.Ok 80 | tags.size ==> (tags1 ++ tags2 ++ tags3).toSet.size 81 | tags.head ==> mostPopularTag 82 | } 83 | 84 | t.unsafeRunSync() 85 | } 86 | } 87 | 88 | implicit val app: HttpApp[IO] = { 89 | val passwordHasher = PasswordHasher.impl 90 | val idHasher = IdHasher.impl("salt") 91 | val userRepo = UserRepo.impl(xa) 92 | val articleRepo = ArticleRepo.impl(xa) 93 | val followerRepo = FollowerRepo.impl(xa) 94 | val tagRepo = TagRepo.impl(xa) 95 | val favoriteRepo = FavoriteRepo.impl(xa) 96 | val userApis = UserApis.impl(passwordHasher, token, userRepo) 97 | val articleApis = ArticleApis.impl(articleRepo, followerRepo, tagRepo, favoriteRepo, idHasher) 98 | val tagApis = TagApis.impl(tagRepo) 99 | mkApp(List(UserRoutes(userApis), ArticleRoutes(articleApis), TagRoutes(tagApis))) 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/test/scala/routes/UserRoutesTests.scala: -------------------------------------------------------------------------------- 1 | package test.io.rw.app.routes 2 | 3 | import cats._ 4 | import cats.data._ 5 | import cats.effect.IO 6 | import io.circe.generic.auto._ 7 | import io.rw.app.apis._ 8 | import io.rw.app.data._ 9 | import io.rw.app.data.ApiErrors._ 10 | import io.rw.app.data.ApiInputs._ 11 | import io.rw.app.data.ApiOutputs._ 12 | import io.rw.app.data.RequestBodies._ 13 | import io.rw.app.repos._ 14 | import io.rw.app.routes._ 15 | import io.rw.app.security._ 16 | import org.http4s._ 17 | import org.http4s.circe.CirceEntityCodec._ 18 | import tsec.mac.jca.HMACSHA256 19 | import test.io.rw.app.WithEmbededDbTestSuite 20 | import utest._ 21 | 22 | object UserRoutesTests extends WithEmbededDbTestSuite { 23 | 24 | val tests = Tests { 25 | test("register") { 26 | test("new user should register and get valid token back") { 27 | val registerBody = RegisterUserBody("username", "email@email.com", "password123") 28 | 29 | val t = for { 30 | rs <- post("users", WrappedUserBody(registerBody)) 31 | user <- rs.as[RegisterUserOutput].map(_.user) 32 | validToken <- token.validate(user.token) 33 | } yield { 34 | rs.status ==> Status.Ok 35 | user.username ==> registerBody.username 36 | user.email ==> registerBody.email 37 | validToken.isDefined ==> true 38 | } 39 | 40 | t.unsafeRunSync() 41 | } 42 | 43 | test("new user with invalid email should get error") { 44 | val registerBody = RegisterUserBody("username", "emailemail.com", "password123") 45 | 46 | val t = for { 47 | rs <- post("users", WrappedUserBody(registerBody)) 48 | errors <- rs.as[ValidationErrorResponse].map(_.errors) 49 | } yield { 50 | rs.status ==> Status.UnprocessableEntity 51 | errors.size ==> 1 52 | errors.get("email") ==> Some(List("is invalid")) 53 | } 54 | 55 | t.unsafeRunSync() 56 | } 57 | 58 | test("new user with short password shold get error") { 59 | val registerBody = RegisterUserBody("username", "email@email.com", "passwor") 60 | 61 | val t = for { 62 | rs <- post("users", WrappedUserBody(registerBody)) 63 | errors <- rs.as[ValidationErrorResponse].map(_.errors) 64 | } yield { 65 | rs.status ==> Status.UnprocessableEntity 66 | errors.size ==> 1 67 | errors.get("password") ==> Some(List("is too short (minimum is 8 character)")) 68 | } 69 | 70 | t.unsafeRunSync() 71 | } 72 | 73 | test("new user with empty username, invalid email and short password shold get errors") { 74 | val registerBody = RegisterUserBody("", "emailemail.com", "passwor") 75 | 76 | val t = for { 77 | rs <- post("users", WrappedUserBody(registerBody)) 78 | errors <- rs.as[ValidationErrorResponse].map(_.errors) 79 | } yield { 80 | rs.status ==> Status.UnprocessableEntity 81 | errors.size ==> 3 82 | errors.get("username") ==> Some(List("can't be blank", "is too short (minimum is 1 character)")) 83 | errors.get("password") ==> Some(List("is too short (minimum is 8 character)")) 84 | errors.get("email") ==> Some(List("is invalid")) 85 | } 86 | 87 | t.unsafeRunSync() 88 | } 89 | 90 | test("new user with existing username should get error") { 91 | val registerBody1 = RegisterUserBody("username", "email@email.com", "password123") 92 | val registerBody2 = RegisterUserBody("username", "email_1@email.com", "password123") 93 | 94 | val t = for { 95 | rs1 <- post("users", WrappedUserBody(registerBody1)) 96 | rs2 <- post("users", WrappedUserBody(registerBody2)) 97 | errors <- rs2.as[ValidationErrorResponse].map(_.errors) 98 | } yield { 99 | rs1.status ==> Status.Ok 100 | rs2.status ==> Status.UnprocessableEntity 101 | errors.size ==> 1 102 | errors.get("username") ==> Some(List("has already been taken")) 103 | } 104 | 105 | t.unsafeRunSync() 106 | } 107 | 108 | test("new user with existing email should get error") { 109 | val registerBody1 = RegisterUserBody("username", "email@email.com", "password123") 110 | val registerBody2 = RegisterUserBody("username_1", "email@email.com", "password123") 111 | 112 | val t = for { 113 | rs1 <- post("users", WrappedUserBody(registerBody1)) 114 | rs2 <- post("users", WrappedUserBody(registerBody2)) 115 | errors <- rs2.as[ValidationErrorResponse].map(_.errors) 116 | } yield { 117 | rs1.status ==> Status.Ok 118 | rs2.status ==> Status.UnprocessableEntity 119 | errors.size ==> 1 120 | errors.get("email") ==> Some(List("has already been taken")) 121 | } 122 | 123 | t.unsafeRunSync() 124 | } 125 | } 126 | 127 | test("authenticate") { 128 | test("existing user should authenticate and get valid token back") { 129 | val registerBody = RegisterUserBody("username", "email@email.com", "password123") 130 | val authenticateBody = AuthenticateUserBody("email@email.com", "password123") 131 | 132 | val t = for { 133 | rs1 <- post("users", WrappedUserBody(registerBody)) 134 | rs2 <- post("users/login", WrappedUserBody(authenticateBody)) 135 | user <- rs2.as[AuthenticateUserOutput].map(_.user) 136 | payload <- token.validate(user.token) 137 | } yield { 138 | rs2.status ==> Status.Ok 139 | user.email ==> authenticateBody.email 140 | payload.isDefined ==> true 141 | } 142 | 143 | t.unsafeRunSync() 144 | } 145 | 146 | test("existing user with wrong password should get error") { 147 | val registerBody = RegisterUserBody("username", "email@email.com", "password123") 148 | val authenticateBody = AuthenticateUserBody("email@email.com", "password12345") 149 | 150 | val t = for { 151 | rs1 <- post("users", WrappedUserBody(registerBody)) 152 | rs2 <- post("users/login", WrappedUserBody(authenticateBody)) 153 | errors <- rs2.as[ValidationErrorResponse].map(_.errors) 154 | } yield { 155 | rs2.status ==> Status.UnprocessableEntity 156 | errors.size ==> 1 157 | errors.get("email or password") ==> Some(List("is invalid")) 158 | } 159 | 160 | t.unsafeRunSync() 161 | } 162 | 163 | test("non existing user should get error") { 164 | val registerBody = AuthenticateUserBody("email@email.com", "password123") 165 | 166 | val t = for { 167 | rs <- post("users/login", WrappedUserBody(registerBody)) 168 | errors <- rs.as[ValidationErrorResponse].map(_.errors) 169 | } yield { 170 | rs.status ==> Status.UnprocessableEntity 171 | errors.size ==> 1 172 | errors.get("email or password") ==> Some(List("is invalid")) 173 | } 174 | 175 | t.unsafeRunSync() 176 | } 177 | } 178 | 179 | test("get") { 180 | test("authenticated user should get itself with valid token back") { 181 | val registerBody = RegisterUserBody("username", "email@email.com", "password123") 182 | 183 | val t = for { 184 | rs1 <- post("users", WrappedUserBody(registerBody)) 185 | jwt <- rs1.as[RegisterUserOutput].map(_.user.token) 186 | rs2 <- getWithToken("user", jwt) 187 | user <- rs2.as[GetUserOutput].map(_.user) 188 | payload <- token.validate(user.token) 189 | } yield { 190 | rs2.status ==> Status.Ok 191 | user.username ==> registerBody.username 192 | user.email ==> registerBody.email 193 | payload.isDefined ==> true 194 | } 195 | 196 | t.unsafeRunSync() 197 | } 198 | 199 | test("authenticated user should get not found when user does not exist") { 200 | val t = for { 201 | jwt <- token.generate(JwtTokenPayload(1)) 202 | rs <- getWithToken("user", jwt) 203 | } yield { 204 | rs.status ==> Status.NotFound 205 | } 206 | 207 | t.unsafeRunSync() 208 | } 209 | 210 | test("not authenticated user should get error") { 211 | val t = for { 212 | rs <- get("user") 213 | } yield { 214 | rs.status ==> Status.Unauthorized 215 | } 216 | 217 | t.unsafeRunSync() 218 | } 219 | 220 | test("user with invalid token should get error") { 221 | val anotherKey = HMACSHA256.unsafeBuildKey("secret_key_for_another_token_123".getBytes) 222 | val anotherToken = JwtToken.impl(anotherKey, 60) 223 | 224 | val t = for { 225 | anotherJwt <- anotherToken.generate(JwtTokenPayload(1)) 226 | rs <- getWithToken("user", anotherJwt) 227 | } yield { 228 | rs.status ==> Status.Unauthorized 229 | } 230 | 231 | t.unsafeRunSync() 232 | } 233 | } 234 | 235 | test("update") { 236 | test("authenticated user should update itself and get valid token back") { 237 | val registerBody = RegisterUserBody("username", "email@email.com", "password123") 238 | val updateBody = UpdateUserBody(Some("username1"), None, None, None, Some("image")) 239 | 240 | val t = for { 241 | rs1 <- post("users", WrappedUserBody(registerBody)) 242 | jwt <- rs1.as[RegisterUserOutput].map(_.user.token) 243 | rs2 <- putWithToken("user", WrappedUserBody(updateBody), jwt) 244 | user <- rs2.as[UpdateUserOutput].map(_.user) 245 | payload <- token.validate(user.token) 246 | } yield { 247 | rs2.status ==> Status.Ok 248 | Some(user.username) ==> updateBody.username 249 | user.image ==> updateBody.image 250 | user.email ==> registerBody.email 251 | payload.isDefined ==> true 252 | } 253 | 254 | t.unsafeRunSync() 255 | } 256 | 257 | test("not authenticated user should get error") { 258 | val registerBody = UpdateUserBody(Some("username1"), None, None, None, None) 259 | 260 | val t = for { 261 | rs <- put("user", WrappedUserBody(registerBody)) 262 | } yield { 263 | rs.status ==> Status.Unauthorized 264 | } 265 | 266 | t.unsafeRunSync() 267 | } 268 | 269 | test("user with invalid token should get error") { 270 | val anotherKey = HMACSHA256.unsafeBuildKey("secret_key_for_another_token_123".getBytes) 271 | val anotherToken = JwtToken.impl(anotherKey, 60) 272 | val registerBody = UpdateUserBody(Some("username1"), None, None, None, None) 273 | 274 | val t = for { 275 | anotherJwt <- anotherToken.generate(JwtTokenPayload(1)) 276 | rs <- putWithToken("user", WrappedUserBody(registerBody), anotherJwt) 277 | } yield { 278 | rs.status ==> Status.Unauthorized 279 | } 280 | 281 | t.unsafeRunSync() 282 | } 283 | } 284 | 285 | implicit val app: HttpApp[IO] = { 286 | val passwordHasher = PasswordHasher.impl 287 | val userRepo = UserRepo.impl(xa) 288 | val apis = UserApis.impl(passwordHasher, token, userRepo) 289 | mkApp(List(UserRoutes(apis))) 290 | } 291 | } 292 | } 293 | -------------------------------------------------------------------------------- /src/test/scala/routes/package.scala: -------------------------------------------------------------------------------- 1 | package test.io.rw.app 2 | 3 | import cats.effect.IO 4 | import io.circe.Encoder 5 | import io.circe.generic.auto._ 6 | import io.rw.app.data._ 7 | import io.rw.app.data.ApiErrors._ 8 | import io.rw.app.data.ApiInputs._ 9 | import io.rw.app.data.ApiOutputs._ 10 | import io.rw.app.data.RequestBodies._ 11 | import io.rw.app.routes._ 12 | import io.rw.app.utils._ 13 | import io.rw.app.security._ 14 | import org.http4s._ 15 | import org.http4s.circe.CirceEntityCodec._ 16 | import org.http4s.headers.Authorization 17 | import org.http4s.implicits._ 18 | import tsec.mac.jca.HMACSHA256 19 | 20 | package object routes { 21 | 22 | import io.rw.app.data.RequestBodies.RegisterUserBody 23 | 24 | val key = HMACSHA256.unsafeBuildKey("secret_key".getBytes) 25 | val token = JwtToken.impl(key, 60) 26 | 27 | def mkApp(routes: List[AppRoutes[IO]]): HttpApp[IO] = mkHttpApp(routes, token) 28 | 29 | def get(path: String)(implicit app: HttpApp[IO]): IO[Response[IO]] = { 30 | val rq = Request[IO](method = Method.GET, uri = mkApiUri(path)) 31 | runRq(rq) 32 | } 33 | 34 | def getWithToken(path: String, jwt: String)(implicit app: HttpApp[IO]): IO[Response[IO]] = { 35 | val rq = Request[IO](method = Method.GET, uri = mkApiUri(path)) 36 | runRq(withToken(rq, jwt)) 37 | } 38 | 39 | def post[B](path: String)(implicit app: HttpApp[IO]): IO[Response[IO]] = { 40 | val rq = Request[IO](method = Method.POST, uri = mkApiUri(path)) 41 | runRq(rq) 42 | } 43 | 44 | def post[B](path: String, entity: B)(implicit app: HttpApp[IO], encoder: Encoder[B]): IO[Response[IO]] = { 45 | val rq = Request[IO](method = Method.POST, uri = mkApiUri(path)).withEntity(entity) 46 | runRq(rq) 47 | } 48 | 49 | def postWithToken[B](path: String, jwt: String)(implicit app: HttpApp[IO]): IO[Response[IO]] = { 50 | val rq = Request[IO](method = Method.POST, uri = mkApiUri(path)) 51 | runRq(withToken(rq, jwt)) 52 | } 53 | 54 | def postWithToken[B](path: String, entity: B, jwt: String)(implicit app: HttpApp[IO], encoder: Encoder[B]): IO[Response[IO]] = { 55 | val rq = Request[IO](method = Method.POST, uri = mkApiUri(path)).withEntity(entity) 56 | runRq(withToken(rq, jwt)) 57 | } 58 | 59 | def put[B](path: String, entity: B)(implicit app: HttpApp[IO], encoder: Encoder[B]): IO[Response[IO]] = { 60 | val rq = Request[IO](method = Method.PUT, uri = mkApiUri(path)).withEntity(entity) 61 | runRq(rq) 62 | } 63 | 64 | def putWithToken[B](path: String, entity: B, jwt: String)(implicit app: HttpApp[IO], encoder: Encoder[B]): IO[Response[IO]] = { 65 | val rq = Request[IO](method = Method.PUT, uri = mkApiUri(path)).withEntity(entity) 66 | runRq(withToken(rq, jwt)) 67 | } 68 | 69 | def delete(path: String)(implicit app: HttpApp[IO]): IO[Response[IO]] = { 70 | val rq = Request[IO](method = Method.DELETE, uri = mkApiUri(path)) 71 | runRq(rq) 72 | } 73 | 74 | def deleteWithToken(path: String, jwt: String)(implicit app: HttpApp[IO]): IO[Response[IO]] = { 75 | val rq = Request[IO](method = Method.DELETE, uri = mkApiUri(path)) 76 | runRq(withToken(rq, jwt)) 77 | } 78 | 79 | def mkApiUri(path: String): Uri = 80 | Uri.unsafeFromString(s"api/$path") 81 | 82 | def runRq(rq: Request[IO])(implicit app: HttpApp[IO]): IO[Response[IO]] = 83 | app.run(rq) 84 | 85 | def withToken(rq: Request[IO], jwt: String): Request[IO] = 86 | rq.withHeaders(Authorization(Credentials.Token("Token".ci, jwt))) 87 | 88 | def logon(body: RegisterUserBody)(implicit app: HttpApp[IO]): IO[String] = 89 | for { 90 | rs <- post("users", WrappedUserBody(body)) 91 | jwt <- rs.as[RegisterUserOutput].map(_.user.token) 92 | } yield jwt 93 | } 94 | -------------------------------------------------------------------------------- /src/test/scala/securityTests.scala: -------------------------------------------------------------------------------- 1 | package test.io.rw.app 2 | 3 | import cats.effect.IO 4 | import io.rw.app.data.JwtTokenPayload 5 | import io.rw.app.security._ 6 | import tsec.mac.jca.HMACSHA256 7 | import utest._ 8 | 9 | object securityTests extends TestSuite { 10 | 11 | val tests = Tests { 12 | test("test hashed password") { 13 | val passwordHasher = PasswordHasher.impl 14 | val psw = "my_password_123" 15 | 16 | test("is valid") { 17 | val t = for { 18 | hash <- passwordHasher.hash(psw) 19 | valid <- passwordHasher.validate(psw, hash) 20 | } yield { 21 | valid ==> true 22 | } 23 | 24 | t.unsafeRunSync() 25 | } 26 | 27 | test("is not valid") { 28 | test("bad password") { 29 | val t = for { 30 | hash <- passwordHasher.hash(psw) 31 | valid <- passwordHasher.validate(tamper(psw), hash) 32 | } yield { 33 | valid ==> false 34 | } 35 | 36 | t.unsafeRunSync() 37 | } 38 | 39 | test("bad hash") { 40 | val t = for { 41 | hash <- passwordHasher.hash(psw) 42 | valid <- passwordHasher.validate(psw, tamper(hash)) 43 | } yield { 44 | valid ==> false 45 | } 46 | 47 | t.unsafeRunSync() 48 | } 49 | } 50 | } 51 | 52 | test("test jwt token") { 53 | val payload = JwtTokenPayload(99) 54 | val key = HMACSHA256.buildKey[IO]("secret_key".getBytes).unsafeRunSync() 55 | val token = JwtToken.impl(key, 10) 56 | 57 | test("is valid") { 58 | val t = for { 59 | jwtStr <- token.generate(payload) 60 | payloadFromToken <- token.validate(jwtStr) 61 | } yield { 62 | payloadFromToken ==> Some(payload) 63 | } 64 | 65 | t.unsafeRunSync() 66 | } 67 | 68 | test("is not valid") { 69 | val t = for { 70 | jwtStr <- token.generate(payload) 71 | payloadFromToken <- token.validate(tamper(jwtStr)) 72 | } yield { 73 | payloadFromToken ==> None 74 | } 75 | 76 | t.unsafeRunSync() 77 | } 78 | } 79 | 80 | def tamper(s: String): String = { 81 | val halfLength = s.size / 2 82 | if (halfLength > 0) s.substring(0, halfLength) + ((s.charAt(halfLength).toInt + 1) % 256).toChar + s.substring(halfLength + 1) 83 | else s 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/test/scala/utilsTests.scala: -------------------------------------------------------------------------------- 1 | package test.io.rw.app 2 | 3 | import io.rw.app.utils._ 4 | import utest._ 5 | 6 | object utilsTests extends TestSuite { 7 | 8 | val tests = Tests { 9 | test("test slugify") { 10 | test("should trim spaces") { 11 | slugify(" abc ") ==> "abc" 12 | slugify(" abc") ==> "abc" 13 | slugify("abc ") ==> "abc" 14 | slugify(" abc ") ==> "abc" 15 | } 16 | 17 | test("should replaces spaces between words with dash") { 18 | slugify("abc def") ==> "abc-def" 19 | slugify("abc def") ==> "abc-def" 20 | slugify("abc def ghi") ==> "abc-def-ghi" 21 | slugify("abc def ghi") ==> "abc-def-ghi" 22 | } 23 | 24 | test("should remove non word characters") { 25 | slugify("abc 123 ...") ==> "abc-123" 26 | slugify("abc 123 .,! e123g") ==> "abc-123-e123g" 27 | slugify("abc\n @*& 123 .,! e123g") ==> "abc-123-e123g" 28 | slugify("!@#$ abc\n \t %^&*() _ 123 .,! e123g -= _+") ==> "abc-_-123-e123g-_" 29 | } 30 | } 31 | 32 | test("test extract token value") { 33 | test("should extract token value") { 34 | extractTokenValue("Token 123abx90") ==> Some("123abx90") 35 | extractTokenValue("Token ") ==> Some(" ") 36 | extractTokenValue("Token 123 456 8") ==> Some(" 123 456 8") 37 | } 38 | 39 | test("should return none where token not present") { 40 | extractTokenValue("Token ") ==> None 41 | extractTokenValue("Token") ==> None 42 | extractTokenValue("Tken") ==> None 43 | extractTokenValue("") ==> None 44 | } 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/test/scala/validationTests.scala: -------------------------------------------------------------------------------- 1 | package test.io.rw.app 2 | 3 | import cats.data._ 4 | import cats.implicits._ 5 | import io.rw.app.valiation._ 6 | import io.rw.app.valiation.validators._ 7 | import utest._ 8 | 9 | object validationTests extends TestSuite { 10 | 11 | val tests = Tests { 12 | test("test validators") { 13 | test("not blank") { 14 | test("should return value if not blank") { 15 | notBlank("abc") ==> "abc".validNec 16 | notBlank(" ") ==> " ".validNec 17 | } 18 | 19 | test("should return error if blank") { 20 | notBlank("") ==> "can't be blank".invalidNec 21 | } 22 | } 23 | 24 | test("min") { 25 | test("should return value if its size not less than minimum") { 26 | min("abc", 2) ==> "abc".validNec 27 | min("a", 1) ==> "a".validNec 28 | min("", 0) ==> "".validNec 29 | } 30 | 31 | test("should return error if value too short") { 32 | min("abc", 4) ==> "is too short (minimum is 4 character)".invalidNec 33 | min("", 1) ==> "is too short (minimum is 1 character)".invalidNec 34 | } 35 | } 36 | 37 | test("max") { 38 | test("should return value if its size not greater than maximum") { 39 | max("abc", 5) ==> "abc".validNec 40 | max("a", 1) ==> "a".validNec 41 | max("", 0) ==> "".validNec 42 | } 43 | 44 | test("should return error if value too long") { 45 | max("abc", 2) ==> "is too long (maximum is 2 character)".invalidNec 46 | max("1", 0) ==> "is too long (maximum is 0 character)".invalidNec 47 | } 48 | } 49 | 50 | test("looks like email") { 51 | test("should return email if it looks like email") { 52 | looksLikeEmail("abc@sdf.com") ==> "abc@sdf.com".validNec 53 | looksLikeEmail("abc1.232@123sdf.com") ==> "abc1.232@123sdf.com".validNec 54 | looksLikeEmail("abc1@sdf1.com.123") ==> "abc1@sdf1.com.123".validNec 55 | } 56 | 57 | test("should return error if it doesn't look like email") { 58 | looksLikeEmail("@sdf.com") ==> "is invalid".invalidNec 59 | looksLikeEmail("abc123.2123sdf.com") ==> "is invalid".invalidNec 60 | looksLikeEmail("abc1@sdf1com") ==> "is invalid".invalidNec 61 | } 62 | } 63 | } 64 | } 65 | } 66 | --------------------------------------------------------------------------------