├── SEMVER ├── project ├── build.properties └── plugins.sbt ├── .gitignore ├── auto ├── sbt ├── test ├── test-compile ├── start-local └── dev-environment ├── src ├── main │ └── scala │ │ └── com │ │ └── reagroup │ │ ├── appliedscala │ │ ├── urls │ │ │ ├── savereview │ │ │ │ ├── ValidatedReview.scala │ │ │ │ ├── NewReviewRequest.scala │ │ │ │ ├── ReviewId.scala │ │ │ │ ├── SaveReviewService.scala │ │ │ │ ├── SaveReviewController.scala │ │ │ │ ├── NewReviewValidator.scala │ │ │ │ ├── ReviewValidationError.scala │ │ │ │ └── README.md │ │ │ ├── fetchenrichedmovie │ │ │ │ ├── package.scala │ │ │ │ ├── Metascore.scala │ │ │ │ ├── EnrichedMovie.scala │ │ │ │ ├── FetchEnrichedMovieController.scala │ │ │ │ ├── FetchEnrichedMovieService.scala │ │ │ │ └── README.md │ │ │ ├── savemovie │ │ │ │ ├── ValidatedMovie.scala │ │ │ │ ├── SaveMovieService.scala │ │ │ │ ├── NewMovieRequest.scala │ │ │ │ ├── SaveMovieController.scala │ │ │ │ ├── MovieValidationError.scala │ │ │ │ ├── NewMovieValidator.scala │ │ │ │ └── README.md │ │ │ ├── fetchallmovies │ │ │ │ ├── FetchAllMoviesService.scala │ │ │ │ ├── FetchAllMoviesController.scala │ │ │ │ └── README.md │ │ │ ├── fetchmovie │ │ │ │ ├── FetchMovieService.scala │ │ │ │ ├── FetchMovieController.scala │ │ │ │ └── README.md │ │ │ ├── ErrorHandler.scala │ │ │ └── repositories │ │ │ │ ├── Http4sMetascoreRepository.scala │ │ │ │ └── PostgresqlRepository.scala │ │ ├── models │ │ │ ├── AppError.scala │ │ │ ├── Review.scala │ │ │ ├── Movie.scala │ │ │ └── MovieId.scala │ │ ├── config │ │ │ ├── SensitiveValue.scala │ │ │ ├── ConfigError.scala │ │ │ ├── Environment.scala │ │ │ ├── DatabaseConfig.scala │ │ │ └── Config.scala │ │ ├── AppServer.scala │ │ ├── Main.scala │ │ ├── AppRoutes.scala │ │ └── AppRuntime.scala │ │ └── exercises │ │ ├── validated │ │ └── ValidationExercises.scala │ │ ├── circe │ │ └── CirceExercises.scala │ │ └── io │ │ └── IOExercises.scala └── test │ ├── scala │ └── com │ │ └── reagroup │ │ ├── exercises │ │ ├── io │ │ │ ├── TestLogger.scala │ │ │ └── IOExercisesSpec.scala │ │ ├── validated │ │ │ └── ValidationExercisesSpec.scala │ │ └── circe │ │ │ └── CirceExercisesSpec.scala │ │ └── appliedscala │ │ ├── urls │ │ ├── savereview │ │ │ ├── ReviewValidationErrorSpec.scala │ │ │ ├── NewReviewValidatorSpec.scala │ │ │ ├── SaveReviewServiceSpec.scala │ │ │ └── SaveReviewControllerSpec.scala │ │ ├── savemovie │ │ │ ├── MovieValidationErrorSpec.scala │ │ │ ├── NewMovieValidatorSpec.scala │ │ │ ├── SaveMovieServiceSpec.scala │ │ │ └── SaveMovieControllerSpec.scala │ │ ├── fetchenrichedmovie │ │ │ ├── MetascoreSpec.scala │ │ │ ├── FetchEnrichedMovieServiceSpec.scala │ │ │ └── FetchEnrichedMovieControllerSpec.scala │ │ ├── fetchmovie │ │ │ ├── FetchMovieServiceSpec.scala │ │ │ └── FetchMovieControllerSpec.scala │ │ ├── fetchallmovies │ │ │ └── FetchAllMoviesServiceSpec.scala │ │ └── repositories │ │ │ └── Http4sMetascoreRepositorySpec.scala │ │ ├── Http4sSpecHelpers.scala │ │ └── AppRoutesSpec.scala │ └── resources │ └── logback-test.xml ├── .travis.yml ├── wartremover.sbt ├── db └── schema.sql ├── docker-compose.yml ├── LICENSE ├── README.md └── docs ├── refresher.md └── faq.md /SEMVER: -------------------------------------------------------------------------------- 1 | 0.0 2 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.6.2 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /project/target 2 | /project/project 3 | /target 4 | .idea 5 | .env 6 | .bsp/ 7 | -------------------------------------------------------------------------------- /auto/sbt: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | cd $(dirname $0)/.. 4 | 5 | auto/dev-environment sbt "$@" 6 | -------------------------------------------------------------------------------- /auto/test: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | cd $(dirname $0)/.. 4 | 5 | auto/dev-environment sbt clean test 6 | -------------------------------------------------------------------------------- /auto/test-compile: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | cd $(dirname $0)/.. 4 | 5 | auto/dev-environment sbt clean Test/compile 6 | -------------------------------------------------------------------------------- /src/main/scala/com/reagroup/appliedscala/urls/savereview/ValidatedReview.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.appliedscala.urls.savereview 2 | 3 | case class ValidatedReview(author: String, comment: String) 4 | -------------------------------------------------------------------------------- /src/main/scala/com/reagroup/appliedscala/urls/fetchenrichedmovie/package.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.appliedscala.urls 2 | 3 | package object fetchenrichedmovie { 4 | 5 | type MovieName = String 6 | 7 | } 8 | -------------------------------------------------------------------------------- /src/main/scala/com/reagroup/appliedscala/models/AppError.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.appliedscala.models 2 | 3 | sealed trait AppError extends Throwable 4 | 5 | case class EnrichmentFailure(movieName: String) extends AppError -------------------------------------------------------------------------------- /src/main/scala/com/reagroup/appliedscala/config/SensitiveValue.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.appliedscala.config 2 | 3 | final case class SensitiveValue[T](value: T) extends AnyVal { 4 | override def toString: String = "****" 5 | } 6 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.3.17") 2 | 3 | addSbtPlugin("org.wartremover" % "sbt-wartremover" % "2.4.19") 4 | 5 | addSbtPlugin("org.jmotor.sbt" % "sbt-dependency-updates" % "1.2.2") 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: scala 2 | scala: 3 | - 2.13.3 4 | 5 | jdk: 6 | - openjdk11 7 | 8 | cache: 9 | directories: 10 | - $HOME/.ivy2/cache 11 | - $HOME/.sbt 12 | 13 | script: sbt ++$TRAVIS_SCALA_VERSION test:compile 14 | -------------------------------------------------------------------------------- /wartremover.sbt: -------------------------------------------------------------------------------- 1 | Compile / compile / wartremoverErrors ++= Warts.allBut( 2 | Wart.Nothing, 3 | Wart.Any, 4 | Wart.FinalCaseClass, 5 | Wart.Overloading, 6 | Wart.LeakingSealed, 7 | Wart.NonUnitStatements, 8 | Wart.Option2Iterable, 9 | Wart.EitherProjectionPartial, 10 | ) 11 | -------------------------------------------------------------------------------- /src/main/scala/com/reagroup/appliedscala/urls/savemovie/ValidatedMovie.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.appliedscala.urls.savemovie 2 | 3 | /** 4 | * The difference between this type and `NewMovieRequest` 5 | * is that this model is validated, based on the business rules 6 | * we have in our system. 7 | */ 8 | case class ValidatedMovie(name: String, synopsis: String) 9 | -------------------------------------------------------------------------------- /src/main/scala/com/reagroup/appliedscala/urls/fetchallmovies/FetchAllMoviesService.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.appliedscala.urls.fetchallmovies 2 | 3 | import cats.effect.IO 4 | import com.reagroup.appliedscala.models._ 5 | 6 | class FetchAllMoviesService(fetchAllMovies: IO[Vector[Movie]]) { 7 | 8 | def fetchAll: IO[Vector[Movie]] = fetchAllMovies 9 | 10 | } 11 | -------------------------------------------------------------------------------- /src/test/scala/com/reagroup/exercises/io/TestLogger.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.exercises.io 2 | 3 | import scala.collection.mutable.ListBuffer 4 | 5 | class TestLogger extends (String => Unit) { 6 | 7 | val loggedMessages: ListBuffer[String] = ListBuffer[String]() 8 | 9 | override def apply(v1: String): Unit = { 10 | loggedMessages += v1 11 | () 12 | } 13 | 14 | } -------------------------------------------------------------------------------- /src/main/scala/com/reagroup/appliedscala/models/Review.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.appliedscala.models 2 | 3 | import io.circe.{Decoder, Encoder} 4 | import io.circe.generic.semiauto._ 5 | 6 | case class Review(author: String, comment: String) 7 | 8 | object Review { 9 | 10 | /** 11 | * Add an Encoder instance here 12 | * 13 | * Hint: Use `deriveEncoder` 14 | */ 15 | 16 | } 17 | -------------------------------------------------------------------------------- /src/main/scala/com/reagroup/appliedscala/models/Movie.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.appliedscala.models 2 | 3 | import io.circe.Encoder 4 | import io.circe.generic.semiauto._ 5 | 6 | case class Movie(name: String, synopsis: String, reviews: Vector[Review]) 7 | 8 | object Movie { 9 | 10 | /** 11 | * Add an Encoder instance here 12 | * 13 | * Hint: Use `deriveEncoder` 14 | */ 15 | 16 | } -------------------------------------------------------------------------------- /src/test/resources/logback-test.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /auto/start-local: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | cd $(dirname $0)/.. 4 | 5 | # Clean up at the end 6 | trap "docker-compose down --volumes --remove-orphans" 0 7 | 8 | # Create volume 9 | docker volume create --name ivy-cache > /dev/null 10 | docker volume create --name docker-scala-sbt-cache > /dev/null 11 | docker volume create --name coursier-cache > /dev/null 12 | 13 | docker-compose up --remove-orphans dev 14 | -------------------------------------------------------------------------------- /auto/dev-environment: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | cd $(dirname $0)/.. 4 | 5 | # Clean up at the end 6 | trap "docker-compose down --volumes --remove-orphans" 0 7 | 8 | # Create volume 9 | docker volume create --name ivy-cache > /dev/null 10 | docker volume create --name docker-scala-sbt-cache > /dev/null 11 | docker volume create --name coursier-cache > /dev/null 12 | 13 | # Run dev script or use what is passed in as arguments 14 | docker-compose run --rm --service-ports dev "$@" 15 | -------------------------------------------------------------------------------- /src/main/scala/com/reagroup/appliedscala/AppServer.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.appliedscala 2 | 3 | import cats.effect.IO 4 | import org.http4s.HttpApp 5 | import org.http4s.blaze.server.BlazeServerBuilder 6 | 7 | class AppServer(port: Int, service: HttpApp[IO]) { 8 | 9 | def start(): IO[Unit] = { 10 | BlazeServerBuilder[IO] 11 | .bindHttp(port, "0.0.0.0") 12 | .withHttpApp(service) 13 | .serve 14 | .compile 15 | .drain 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /src/main/scala/com/reagroup/appliedscala/config/ConfigError.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.appliedscala.config 2 | 3 | sealed abstract class ConfigError { 4 | def message: String 5 | } 6 | 7 | object ConfigError { 8 | def show(errors: Iterable[ConfigError]): String = { 9 | errors.map(_.message).mkString("Configuration errors:\n ", "\n ", "") 10 | } 11 | } 12 | 13 | case class MissingEnvironmentVariable(name: String) extends ConfigError { 14 | def message: String = s"Missing environment variable: $name" 15 | } 16 | -------------------------------------------------------------------------------- /db/schema.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE movie ( 2 | id SERIAL PRIMARY KEY, 3 | name TEXT NOT NULL, 4 | synopsis TEXT NOT NULL 5 | ); 6 | 7 | CREATE TABLE review ( 8 | id SERIAL PRIMARY KEY, 9 | movie_id INT NOT NULL, 10 | author TEXT NOT NULL, 11 | comment TEXT NOT NULL, 12 | CONSTRAINT fk_movie_id FOREIGN KEY (movie_id) REFERENCES movie (id) 13 | ); 14 | 15 | INSERT INTO MOVIE (name, synopsis) VALUES ('Titanic', 'This is not going to end well'); 16 | 17 | INSERT INTO review (movie_id, author, comment) VALUES (1, 'The Snarky Reviewer', 'Instant classic'); 18 | -------------------------------------------------------------------------------- /src/main/scala/com/reagroup/appliedscala/urls/fetchmovie/FetchMovieService.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.appliedscala.urls.fetchmovie 2 | 3 | import cats.effect.IO 4 | import com.reagroup.appliedscala.models._ 5 | 6 | class FetchMovieService(fetchMovie: MovieId => IO[Option[Movie]]) { 7 | 8 | /** 9 | * This one is real easy! 10 | * 11 | * We have a `MovieId` and we want to yield a `IO[Option[Movie]]` and we have precisely the function that can do this for us in scope. 12 | */ 13 | def fetch(movieId: MovieId): IO[Option[Movie]] = 14 | ??? 15 | 16 | } 17 | -------------------------------------------------------------------------------- /src/main/scala/com/reagroup/appliedscala/config/Environment.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.appliedscala.config 2 | 3 | import cats.data.ValidatedNel 4 | import cats.implicits._ 5 | 6 | final case class Environment(env: Map[String, String]) { 7 | final def optional(key: String, defaultValue: => String): ValidatedNel[ConfigError, String] = { 8 | env.getOrElse(key, defaultValue).validNel 9 | } 10 | 11 | final def required(key: String): ValidatedNel[ConfigError, String] = { 12 | env.get(key) match { 13 | case Some(value) => value.validNel 14 | case None => MissingEnvironmentVariable(key).invalidNel 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/test/scala/com/reagroup/appliedscala/urls/savereview/ReviewValidationErrorSpec.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.appliedscala.urls.savereview 2 | 3 | import org.specs2.mutable.Specification 4 | 5 | class ReviewValidationErrorSpec extends Specification { 6 | 7 | "show" should { 8 | "stringify MovieNameTooShort" in { 9 | val result = ReviewValidationError.show(ReviewAuthorTooShort) 10 | 11 | result must_=== "REVIEW_AUTHOR_TOO_SHORT" 12 | } 13 | 14 | "return NewMovie" in { 15 | val result = ReviewValidationError.show(ReviewCommentTooShort) 16 | 17 | result must_=== "REVIEW_COMMENT_TOO_SHORT" 18 | } 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /src/main/scala/com/reagroup/appliedscala/urls/savemovie/SaveMovieService.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.appliedscala.urls.savemovie 2 | 3 | import cats.data._ 4 | import cats.implicits._ 5 | import cats.effect.IO 6 | import com.reagroup.appliedscala.models._ 7 | 8 | class SaveMovieService(saveMovie: ValidatedMovie => IO[MovieId]) { 9 | 10 | /** 11 | * Before saving a `NewMovieRequest`, we want to validate the request in order to get a `ValidatedMovie`. 12 | * Complete `NewMovieValidator`, then use it here before calling `saveMovie`. 13 | */ 14 | def save(newMovieReq: NewMovieRequest): IO[ValidatedNel[MovieValidationError, MovieId]] = 15 | ??? 16 | 17 | } 18 | -------------------------------------------------------------------------------- /src/test/scala/com/reagroup/appliedscala/urls/savemovie/MovieValidationErrorSpec.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.appliedscala.urls.savemovie 2 | 3 | import cats.data._ 4 | import cats.implicits._ 5 | import org.specs2.mutable.Specification 6 | 7 | class MovieValidationErrorSpec extends Specification { 8 | 9 | "show" should { 10 | "stringify MovieNameTooShort" in { 11 | val result = MovieValidationError.show(MovieNameTooShort) 12 | 13 | result must_=== "MOVIE_NAME_TOO_SHORT" 14 | } 15 | 16 | "return NewMovie" in { 17 | val result = MovieValidationError.show(MovieSynopsisTooShort) 18 | 19 | result must_=== "MOVIE_SYNOPSIS_TOO_SHORT" 20 | } 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /src/main/scala/com/reagroup/appliedscala/urls/savemovie/NewMovieRequest.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.appliedscala.urls.savemovie 2 | 3 | import io.circe.Decoder 4 | import io.circe.generic.semiauto._ 5 | 6 | case class NewMovieRequest(name: String, synopsis: String) 7 | 8 | object NewMovieRequest { 9 | 10 | /** 11 | * Here is the Decoder instance. 12 | * 13 | * { 14 | * "name": "Titanic", 15 | * "synopsis": "A movie about ships" 16 | * } 17 | * 18 | * We can just use `deriveDecoder` because the keys in the incoming JSON are named exactly the same 19 | * as the fields in resulting data type. 20 | */ 21 | 22 | implicit val decoder: Decoder[NewMovieRequest] = deriveDecoder 23 | 24 | } 25 | -------------------------------------------------------------------------------- /src/main/scala/com/reagroup/appliedscala/urls/savereview/NewReviewRequest.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.appliedscala.urls.savereview 2 | 3 | import io.circe.Decoder 4 | import io.circe.generic.semiauto._ 5 | 6 | case class NewReviewRequest(author: String, comment: String) 7 | 8 | object NewReviewRequest { 9 | 10 | /** 11 | * Here is the Decoder instance. 12 | * 13 | * { 14 | * "author": "Bob", 15 | * "comment": "I liked this a lot" 16 | * } 17 | * 18 | * We can just use `deriveDecoder` because the keys in the incoming JSON are named exactly the same 19 | * as the fields in resulting data type. 20 | */ 21 | 22 | implicit val decoder: Decoder[NewReviewRequest] = deriveDecoder 23 | } 24 | -------------------------------------------------------------------------------- /src/main/scala/com/reagroup/appliedscala/models/MovieId.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.appliedscala.models 2 | 3 | import io.circe.Encoder 4 | import io.circe.Json 5 | import io.circe.syntax._ 6 | 7 | case class MovieId(value: Long) 8 | 9 | object MovieId { 10 | 11 | /** 12 | * Here is the Encoder instance. 13 | * 14 | * We want the resulting Json to look like this: 15 | * 16 | * { 17 | * "id": 1 18 | * } 19 | * 20 | * We don't want to use `deriveEncoder` here, otherwise the resulting JSON key will be tied to the name 21 | * of the field in `MovieId` 22 | */ 23 | 24 | implicit val encoder: Encoder[MovieId] = 25 | Encoder { movieId => 26 | Json.obj(("id", movieId.value.asJson)) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/main/scala/com/reagroup/appliedscala/urls/savereview/ReviewId.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.appliedscala.urls.savereview 2 | 3 | import io.circe.Encoder 4 | import io.circe.Json 5 | import io.circe.syntax._ 6 | 7 | case class ReviewId(value: Long) 8 | 9 | object ReviewId { 10 | 11 | /** 12 | * Here is the Encoder instance. 13 | * 14 | * We want the resulting Json to look like this: 15 | * 16 | * { 17 | * "id": 1 18 | * } 19 | * 20 | * We don't want to use `deriveEncoder` here, otherwise the resulting JSON key will be tied to the name 21 | * of the field in `ReviewId` 22 | */ 23 | 24 | implicit val encoder: Encoder[ReviewId] = 25 | Encoder { id => 26 | Json.obj("id" -> id.value.asJson) 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /src/main/scala/com/reagroup/appliedscala/config/DatabaseConfig.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.appliedscala.config 2 | 3 | import cats.data.ValidatedNel 4 | import cats.implicits._ 5 | 6 | case class DatabaseConfig( 7 | host: String, 8 | username: String, 9 | password: SensitiveValue[String], 10 | databaseName: String 11 | ) 12 | 13 | object DatabaseConfig { 14 | def apply(env: Environment): ValidatedNel[ConfigError, DatabaseConfig] = { 15 | val host = env.required("DATABASE_HOST") 16 | val username = env.required("DATABASE_USERNAME") 17 | val password = env.required("DATABASE_PASSWORD").map(SensitiveValue.apply) 18 | val databaseName = env.required("DATABASE_NAME") 19 | (host, username, password, databaseName).mapN(DatabaseConfig.apply) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/main/scala/com/reagroup/appliedscala/urls/fetchenrichedmovie/Metascore.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.appliedscala.urls.fetchenrichedmovie 2 | 3 | import io.circe.Decoder.Result 4 | import io.circe.{Decoder, DecodingFailure, Encoder, HCursor} 5 | import io.circe.generic.semiauto._ 6 | 7 | case class Metascore(value: Int) 8 | 9 | object Metascore { 10 | 11 | /** 12 | * Add a Decoder instance here to decode a JSON containing "Metascore" into a `Metascore`, e.g. 13 | * 14 | * Convert: 15 | * 16 | * { 17 | * .. 18 | * .. 19 | * "Metascore": "75", 20 | * .. 21 | * .. 22 | * } 23 | * 24 | * into: 25 | * 26 | * `Metascore(75)` 27 | */ 28 | implicit val decoderMetascore: Decoder[Metascore] = Decoder { metascore => ??? } 29 | } -------------------------------------------------------------------------------- /src/test/scala/com/reagroup/appliedscala/urls/fetchenrichedmovie/MetascoreSpec.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.appliedscala.urls.fetchenrichedmovie 2 | 3 | import org.specs2.mutable.Specification 4 | import io.circe.syntax._ 5 | import io.circe.Json 6 | 7 | final class MetascoreSpec extends Specification { 8 | 9 | "A Metascore decoder" should { 10 | 11 | "convert valid Json to Metascore" in { 12 | val json = Json.obj("name" -> "Some movie".asJson, "Metascore" -> 75.asJson) 13 | val errOrMetascore = json.as[Metascore] 14 | 15 | errOrMetascore must_=== Right(Metascore(75)) 16 | } 17 | 18 | "convert invalid Json to error" in { 19 | val json = Json.obj("foo" -> "bar".asJson) 20 | val errOrMetascore = json.as[Metascore] 21 | errOrMetascore must beLeft 22 | } 23 | } 24 | 25 | } 26 | 27 | -------------------------------------------------------------------------------- /src/main/scala/com/reagroup/appliedscala/Main.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.appliedscala 2 | 3 | import cats.effect.{IO, IOApp} 4 | import com.reagroup.appliedscala.config.Config 5 | import org.http4s.blaze.client.BlazeClientBuilder 6 | 7 | object Main extends IOApp.Simple { 8 | 9 | override def run: IO[Unit] = { 10 | for { 11 | _ <- IO(println("Starting server")) 12 | _ <- startServer() 13 | } yield () 14 | } 15 | 16 | private def runServerWith(config: Config): IO[Unit] = { 17 | BlazeClientBuilder[IO].resource.use { httpClient => 18 | val app = new AppRuntime(config, httpClient).routes 19 | new AppServer(9200, app).start() 20 | } 21 | } 22 | 23 | private def startServer(): IO[Unit] = { 24 | for { 25 | config <- Config.fromEnvironment() 26 | _ <- runServerWith(config) 27 | } yield () 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /src/test/scala/com/reagroup/appliedscala/urls/fetchmovie/FetchMovieServiceSpec.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.appliedscala.urls.fetchmovie 2 | 3 | import cats.data.NonEmptyList 4 | import cats.effect.IO 5 | import cats.effect.unsafe.implicits.global 6 | import cats.implicits._ 7 | import com.reagroup.appliedscala.models._ 8 | import org.specs2.mutable.Specification 9 | 10 | class FetchMovieServiceSpec extends Specification { 11 | 12 | "fetchMovie" should { 13 | 14 | "return movie" in { 15 | 16 | val expectedMovie = Movie("badman", "nananana", Vector.empty[Review]) 17 | 18 | val repo = (movieId: MovieId) => IO.pure(Some(expectedMovie)) 19 | 20 | val service = new FetchMovieService(repo) 21 | 22 | val actual = service.fetch(MovieId(123)) 23 | 24 | actual.unsafeRunSync() must beSome(expectedMovie) 25 | 26 | } 27 | 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /src/test/scala/com/reagroup/appliedscala/urls/savereview/NewReviewValidatorSpec.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.appliedscala.urls.savereview 2 | 3 | import cats.data._ 4 | import cats.implicits._ 5 | import org.specs2.mutable.Specification 6 | 7 | class NewReviewValidatorSpec extends Specification { 8 | 9 | "validate" should { 10 | "return all errors if new review has no name and no synopsis" in { 11 | val review = NewReviewRequest("", "") 12 | 13 | val result = NewReviewValidator.validate(review) 14 | 15 | result must_=== NonEmptyList.of(ReviewAuthorTooShort, ReviewCommentTooShort).invalid 16 | } 17 | 18 | "return NewMovie" in { 19 | val review = NewReviewRequest("bob", "cool movie") 20 | 21 | val result = NewReviewValidator.validate(review) 22 | 23 | result must_=== ValidatedReview("bob", "cool movie").valid 24 | } 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /src/main/scala/com/reagroup/appliedscala/urls/ErrorHandler.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.appliedscala.urls 2 | 3 | import cats.effect.IO 4 | import com.reagroup.appliedscala.models._ 5 | import io.circe.Json 6 | import io.circe.syntax._ 7 | import org.http4s.Response 8 | import org.http4s.circe.CirceEntityCodec._ 9 | import org.http4s.dsl.Http4sDsl 10 | 11 | object ErrorHandler extends Http4sDsl[IO] { 12 | 13 | def apply(e: Throwable): IO[Response[IO]] = 14 | e match { 15 | case err: AppError => encodeAppError(err) 16 | case err => InternalServerError(Json.obj("error" -> s"Unexpected error has occurred: ${err.getMessage}".asJson)) 17 | } 18 | 19 | private def encodeAppError(appError: AppError): IO[Response[IO]] = 20 | appError match { 21 | case EnrichmentFailure(movieName) => InternalServerError(Json.obj("error" -> s"Failed to enrich movie: $movieName".asJson)) 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /src/test/scala/com/reagroup/appliedscala/urls/savemovie/NewMovieValidatorSpec.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.appliedscala.urls.savemovie 2 | 3 | import cats.data._ 4 | import cats.implicits._ 5 | import org.specs2.mutable.Specification 6 | 7 | class NewMovieValidatorSpec extends Specification { 8 | 9 | "validate" should { 10 | "return all errors if new movie has no name and no synopsis" in { 11 | val newMovie = NewMovieRequest("", "") 12 | 13 | val result = NewMovieValidator.validate(newMovie) 14 | 15 | result must_=== NonEmptyList.of(MovieNameTooShort, MovieSynopsisTooShort).invalid 16 | } 17 | 18 | "return NewMovie" in { 19 | val newMovie = NewMovieRequest("badman returns", "nananana badman") 20 | 21 | val result = NewMovieValidator.validate(newMovie) 22 | 23 | result must_=== ValidatedMovie(newMovie.name, newMovie.synopsis).valid 24 | } 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /src/main/scala/com/reagroup/appliedscala/urls/repositories/Http4sMetascoreRepository.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.appliedscala.urls.repositories 2 | 3 | import cats.effect.IO 4 | import com.reagroup.appliedscala.urls.fetchenrichedmovie.Metascore 5 | import io.circe.parser.decode 6 | import org.http4s._ 7 | import org.http4s.implicits._ 8 | import org.http4s.client.Client 9 | 10 | class Http4sMetascoreRepository(httpClient: Client[IO], apiKey: String) { 11 | 12 | /** 13 | * For the purpose of this exercise, we return a `None` if we are unable 14 | * to decode a `Metascore` out of the response from OMDB. 15 | */ 16 | def apply(movieName: String): IO[Option[Metascore]] = { 17 | val omdbURI: Uri = uri"http://www.omdbapi.com/" 18 | .withQueryParam("apikey", apiKey) 19 | .withQueryParam("t", movieName) 20 | val ioStr: IO[String] = httpClient.expect[String](omdbURI) 21 | ??? 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /src/test/scala/com/reagroup/appliedscala/urls/fetchallmovies/FetchAllMoviesServiceSpec.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.appliedscala.urls.fetchallmovies 2 | 3 | import cats.effect.IO 4 | import cats.effect.unsafe.implicits.global 5 | import com.reagroup.appliedscala.models._ 6 | import org.specs2.mutable.Specification 7 | 8 | class FetchAllMoviesServiceSpec extends Specification { 9 | 10 | "fetchAllMovies" should { 11 | 12 | "return all movies" in { 13 | 14 | val movie1 = Movie("Batman Returns", "Bats are cool!", Vector.empty[Review]) 15 | val movie2 = Movie("Titanic", "Can't sink this!", Vector.empty[Review]) 16 | val allMovies = Vector(movie1, movie2) 17 | 18 | val repo = IO.pure(allMovies) 19 | 20 | val service = new FetchAllMoviesService(repo) 21 | 22 | val actual = service.fetchAll 23 | 24 | actual.unsafeRunSync() must_=== allMovies 25 | 26 | } 27 | 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /src/test/scala/com/reagroup/appliedscala/Http4sSpecHelpers.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.appliedscala 2 | 3 | import cats.effect.IO 4 | import cats.effect.unsafe.implicits.global 5 | import org.http4s.{Method, Request, Response, Uri} 6 | import org.typelevel.ci.CIString 7 | 8 | object Http4sSpecHelpers { 9 | def header(response: IO[Response[IO]], headerName: String): Option[String] = { 10 | response.unsafeRunSync().headers.get(CIString(headerName)).map(_.head.value) 11 | } 12 | 13 | def body(response: IO[Response[IO]]): String = { 14 | val bytes = response.unsafeRunSync().body.compile.toVector.unsafeRunSync() 15 | new String(bytes.toArray, "utf-8") 16 | } 17 | 18 | def request(path: String, method: Method): Request[IO] = { 19 | Request[IO](method = method, uri = Uri(path = Uri.Path.unsafeFromString(path))) 20 | } 21 | 22 | def request(uri: Uri, method: Method): Request[IO] = { 23 | Request[IO](method = method, uri = uri) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/main/scala/com/reagroup/appliedscala/config/Config.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.appliedscala.config 2 | 3 | import cats.data.Validated.{Invalid, Valid} 4 | import cats.data.ValidatedNel 5 | import cats.effect.IO 6 | import cats.implicits._ 7 | 8 | case class Config( 9 | omdbApiKey: String, 10 | version: String, 11 | databaseConfig: DatabaseConfig 12 | ) 13 | 14 | object Config { 15 | def apply(environment: Environment): ValidatedNel[ConfigError, Config] = { 16 | val omdbApiKey = environment.required("OMDB_API_KEY") 17 | val version = environment.optional("VERSION", "(unknown)") 18 | val databaseConfig = DatabaseConfig(environment) 19 | (omdbApiKey, version, databaseConfig).mapN(Config.apply) 20 | } 21 | 22 | def fromEnvironment(): IO[Config] = { 23 | val env = Environment(sys.env) 24 | Config(env) match { 25 | case Invalid(errors) => IO.raiseError(new IllegalStateException(ConfigError.show(errors.toList))) 26 | case Valid(c) => IO.pure(c) 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/main/scala/com/reagroup/appliedscala/urls/fetchmovie/FetchMovieController.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.appliedscala.urls.fetchmovie 2 | 3 | import cats.effect.IO 4 | import com.reagroup.appliedscala.models._ 5 | import com.reagroup.appliedscala.urls.ErrorHandler 6 | import io.circe.syntax._ 7 | import org.http4s._ 8 | import org.http4s.circe.CirceEntityCodec._ 9 | import org.http4s.dsl.Http4sDsl 10 | 11 | class FetchMovieController(fetchMovie: MovieId => IO[Option[Movie]]) extends Http4sDsl[IO] { 12 | 13 | /** 14 | * 1. Convert `movieId` into a value of type `MovieId` 15 | * 2. Call `fetchMovie` and we need to call `attempt` on the result so we can handle errors 16 | * 3. Pattern match on the results and convert each possible case into an HTTP response. 17 | * 4. If the movie does not exist, we want to return a 404. 18 | * 19 | * Hint: You can use `NotFound()` to construct a 404 and `Ok()` to construct a 200. 20 | * Delegate the error case to the `ErrorHandler`. 21 | */ 22 | def fetch(movieId: Long): IO[Response[IO]] = 23 | ??? 24 | 25 | } 26 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | 3 | services: 4 | dev: 5 | image: hseeberger/scala-sbt:eclipse-temurin-11.0.14.1_1.6.2_2.13.8 6 | working_dir: /work 7 | command: sbt run 8 | environment: 9 | PORT: 9200 10 | OMDB_API_KEY: 7f9b5b06 11 | DATABASE_HOST: database 12 | DATABASE_NAME: moviedb 13 | DATABASE_USERNAME: moviedb 14 | DATABASE_PASSWORD: moviedb 15 | ports: 16 | - "9200:9200" 17 | volumes: 18 | - .:/work 19 | - coursier-cache:/root/.cache/coursier 20 | - docker-scala-sbt-cache:/root/.sbt 21 | - ivy-cache:/root/.ivy2 22 | depends_on: 23 | - database 24 | 25 | database: 26 | image: postgres:9.6 27 | environment: 28 | POSTGRES_USER: moviedb 29 | POSTGRES_PASSWORD: moviedb 30 | POSTGRES_DB: moviedb 31 | ports: 32 | - 5432:5432 33 | volumes: 34 | - ./db:/docker-entrypoint-initdb.d 35 | 36 | volumes: 37 | coursier-cache: 38 | external: true 39 | docker-scala-sbt-cache: 40 | external: true 41 | ivy-cache: 42 | external: true 43 | -------------------------------------------------------------------------------- /src/main/scala/com/reagroup/appliedscala/urls/fetchenrichedmovie/EnrichedMovie.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.appliedscala.urls.fetchenrichedmovie 2 | 3 | import com.reagroup.appliedscala.models.Movie 4 | import io.circe.syntax._ 5 | import io.circe.{Encoder, Json} 6 | 7 | case class EnrichedMovie(movie: Movie, metascore: Metascore) 8 | 9 | object EnrichedMovie { 10 | 11 | /** 12 | * Add an Encoder instance here 13 | * 14 | * We want the Json to look like: 15 | * 16 | * { 17 | * "name": "Batman", 18 | * "synopsis": "Great movie for the family", 19 | * "reviews": [] 20 | * "metascore": 75 21 | * } 22 | * 23 | * not: 24 | * 25 | * { 26 | * "movie": { 27 | * "name": "Batman", 28 | * "synopsis": "Great movie for the family", 29 | * "reviews": [] 30 | * }, 31 | * "metascore": 75 32 | * } 33 | * 34 | * which is what we would get if we used `deriveEncoder[EnrichedMovie]` 35 | * 36 | * Hint: You will need to create a custom encoder (see how we did it for `MovieId`). 37 | */ 38 | 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 realestate.com.au 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 | -------------------------------------------------------------------------------- /src/main/scala/com/reagroup/appliedscala/urls/fetchenrichedmovie/FetchEnrichedMovieController.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.appliedscala.urls.fetchenrichedmovie 2 | 3 | import cats.effect.IO 4 | import com.reagroup.appliedscala.models._ 5 | import com.reagroup.appliedscala.urls.ErrorHandler 6 | import io.circe.syntax._ 7 | import org.http4s._ 8 | import org.http4s.circe.CirceEntityCodec._ 9 | import org.http4s.dsl.Http4sDsl 10 | 11 | class FetchEnrichedMovieController(fetchEnrichedMovie: MovieId => IO[Option[EnrichedMovie]]) extends Http4sDsl[IO] { 12 | 13 | /** 14 | * 1. Convert `movieId` to a value of `MovieId` type 15 | * 2. Call `fetchEnrichedMovie` and don't forget to `attempt` so you can handle errors! 16 | * 3. Pattern match on the results and convert each possible case into an HTTP response 17 | * 18 | * Hint: Refer to `FetchMovieController` if you're stuck. 19 | */ 20 | def fetch(movieId: Long): IO[Response[IO]] = { 21 | val id: MovieId = ??? 22 | 23 | fetchEnrichedMovie(id).attempt.flatMap { 24 | case Left(error) => ??? 25 | case Right(Some(enrichedMovie)) => ??? 26 | case Right(None) => ??? 27 | } 28 | 29 | } 30 | 31 | } -------------------------------------------------------------------------------- /src/main/scala/com/reagroup/appliedscala/urls/savemovie/SaveMovieController.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.appliedscala.urls.savemovie 2 | 3 | import cats.data.Validated._ 4 | import cats.data.{NonEmptyList, ValidatedNel} 5 | import cats.effect.IO 6 | import com.reagroup.appliedscala.models._ 7 | import io.circe.{Encoder, Json} 8 | import io.circe.syntax._ 9 | import org.http4s._ 10 | import org.http4s.circe.CirceEntityCodec._ 11 | import org.http4s.dsl.Http4sDsl 12 | 13 | class SaveMovieController(saveNewMovie: NewMovieRequest => IO[ValidatedNel[MovieValidationError, MovieId]]) extends Http4sDsl[IO] { 14 | 15 | /** 16 | * 1. Decode the `req` into a `NewMovieRequest` (refer to the decoding exercises in CirceExercises) 17 | * 2. Call `saveNewMovie` and don't forget to `attempt` to deal with errors! 18 | * 3. Pattern match and convert every case into an HTTP response. To Pattern match on `Validated`, use `Invalid` and `Valid`. 19 | * Hint: Use `Created(...)` to return a 201 response when the movie is successfully saved and `BadRequest(...)` to return a 403 response when there are errors. 20 | */ 21 | def save(req: Request[IO]): IO[Response[IO]] = 22 | ??? 23 | 24 | } 25 | -------------------------------------------------------------------------------- /src/main/scala/com/reagroup/appliedscala/urls/savereview/SaveReviewService.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.appliedscala.urls.savereview 2 | 3 | import cats.data.{NonEmptyList, Validated, ValidatedNel} 4 | import cats.effect.IO 5 | import cats.implicits._ 6 | import com.reagroup.appliedscala.models.{Movie, MovieId} 7 | 8 | class SaveReviewService(saveReview: (MovieId, ValidatedReview) => IO[ReviewId], 9 | fetchMovie: MovieId => IO[Option[Movie]]) { 10 | 11 | /** 12 | * Before saving a `NewReviewRequest`, we want to check that the movie exists and then 13 | * validate the request in order to get a `ValidatedReview`. 14 | * Complete `NewReviewValidator`, then use it here before calling `saveReview`. 15 | * Return all errors encountered whether the movie exists or not. 16 | * 17 | */ 18 | def save(movieId: MovieId, review: NewReviewRequest): IO[ValidatedNel[ReviewValidationError, ReviewId]] = ??? 19 | 20 | private def validateMovie(maybeMovie: Option[Movie]): ValidatedNel[ReviewValidationError, Movie] = ??? 21 | 22 | private def validateReview(review: NewReviewRequest): ValidatedNel[ReviewValidationError, ValidatedReview] = ??? 23 | 24 | } 25 | -------------------------------------------------------------------------------- /src/main/scala/com/reagroup/appliedscala/urls/savereview/SaveReviewController.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.appliedscala.urls.savereview 2 | 3 | import cats.data.Validated._ 4 | import cats.data.ValidatedNel 5 | import cats.effect.IO 6 | import com.reagroup.appliedscala.models._ 7 | import com.reagroup.appliedscala.urls.ErrorHandler 8 | import io.circe.Json 9 | import io.circe.syntax._ 10 | import org.http4s._ 11 | import org.http4s.circe.CirceEntityCodec._ 12 | import org.http4s.dsl.Http4sDsl 13 | 14 | class SaveReviewController(saveNewReview: (MovieId, NewReviewRequest) => IO[ValidatedNel[ReviewValidationError, ReviewId]]) extends Http4sDsl[IO] { 15 | 16 | def save(movieId: Long, req: Request[IO]): IO[Response[IO]] = { 17 | for { 18 | newReviewRequest <- req.as[NewReviewRequest] 19 | eitherValidatedReviewId <- saveNewReview(MovieId(movieId), newReviewRequest).attempt 20 | response <- eitherValidatedReviewId match { 21 | case Left(err) => ErrorHandler(err) 22 | case Right(Valid(reviewId)) => Created(reviewId) 23 | case Right(Invalid(validationErrors)) => BadRequest(validationErrors) 24 | } 25 | 26 | } yield response 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /src/test/scala/com/reagroup/appliedscala/urls/savemovie/SaveMovieServiceSpec.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.appliedscala.urls.savemovie 2 | 3 | import cats.data.NonEmptyList 4 | import cats.effect.IO 5 | import cats.effect.unsafe.implicits.global 6 | import cats.implicits._ 7 | import com.reagroup.appliedscala.models._ 8 | import org.specs2.mutable.Specification 9 | 10 | class SaveMovieServiceSpec extends Specification { 11 | 12 | "saveMovie" should { 13 | 14 | "return both errors" in { 15 | 16 | val newMovieReq = NewMovieRequest("", "") 17 | 18 | val repo = (movie: ValidatedMovie) => ??? 19 | 20 | val service = new SaveMovieService(repo) 21 | 22 | val actual = service.save(newMovieReq) 23 | 24 | actual.unsafeRunSync() must_=== NonEmptyList.of(MovieNameTooShort, MovieSynopsisTooShort).invalid 25 | 26 | } 27 | 28 | "return saved movieId" in { 29 | 30 | val newMovieReq = NewMovieRequest("badman returns", "nananana badman") 31 | 32 | val repo = (movie: ValidatedMovie) => IO.pure(MovieId(123)) 33 | 34 | val service = new SaveMovieService(repo) 35 | 36 | val actual = service.save(newMovieReq) 37 | 38 | actual.unsafeRunSync() must_=== MovieId(123).valid 39 | 40 | } 41 | 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /src/main/scala/com/reagroup/appliedscala/urls/fetchenrichedmovie/FetchEnrichedMovieService.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.appliedscala.urls.fetchenrichedmovie 2 | 3 | import cats.effect.IO 4 | import com.reagroup.appliedscala.models._ 5 | import cats.implicits._ 6 | 7 | class FetchEnrichedMovieService(fetchMovie: MovieId => IO[Option[Movie]], 8 | fetchMetascore: MovieName => IO[Option[Metascore]]) { 9 | 10 | /** 11 | * In order to construct an `EnrichedMovie`, we need to first get a `Movie` and a `Metascore`. 12 | * We can do so using the functions that are passed in as dependencies. 13 | * 14 | * For the purpose of this exercise, let's raise an `EnrichmentFailure` if the `Metascore` does not exist. 15 | * 16 | * Hint: We know we are going to be chaining multiple effects in `IO` so let's start a for-comprehension. 17 | * Also pattern match on `Option` if you're stuck! 18 | **/ 19 | def fetch(movieId: MovieId): IO[Option[EnrichedMovie]] = ??? 20 | 21 | /** 22 | * Given a `Movie`, we can call `fetchMetascore` using the `name` of the `Movie`. 23 | * If no `Metascore` is found, raise an `EnrichmentFailure` using `IO.raiseError`. 24 | **/ 25 | private def enrichMovieWithMetascore(movie: Movie): IO[EnrichedMovie] = ??? 26 | 27 | } -------------------------------------------------------------------------------- /src/main/scala/com/reagroup/appliedscala/urls/savemovie/MovieValidationError.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.appliedscala.urls.savemovie 2 | 3 | import io.circe.Encoder 4 | import io.circe.Json 5 | import io.circe.syntax._ 6 | 7 | sealed trait MovieValidationError 8 | 9 | case object MovieNameTooShort extends MovieValidationError 10 | 11 | case object MovieSynopsisTooShort extends MovieValidationError 12 | 13 | object MovieValidationError { 14 | 15 | /** 16 | * This function turns an `MovieValidationError` to a `String`. 17 | * This will be used in our `Encoder`. 18 | * 19 | * `MovieNameTooShort` -> "MOVIE_NAME_TOO_SHORT" 20 | * `MovieSynopsisTooShort` -> "MOVIE_SYNOPSIS_TOO_SHORT" 21 | */ 22 | def show(error: MovieValidationError): String = 23 | error match { 24 | case MovieNameTooShort => "MOVIE_NAME_TOO_SHORT" 25 | case MovieSynopsisTooShort => "MOVIE_SYNOPSIS_TOO_SHORT" 26 | } 27 | 28 | /** 29 | * Here is the Encoder instance. 30 | * 31 | * We want the resulting Json to look like this: 32 | * 33 | * { 34 | * "error": "MOVIE_NAME_TOO_SHORT" 35 | * } 36 | */ 37 | implicit val encoder: Encoder[MovieValidationError] = 38 | Encoder { err => 39 | Json.obj("error" -> MovieValidationError.show(err).asJson) 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /src/main/scala/com/reagroup/appliedscala/urls/savemovie/NewMovieValidator.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.appliedscala.urls.savemovie 2 | 3 | import cats.data.Validated._ 4 | import cats.data.{NonEmptyList, Validated, ValidatedNel} 5 | import cats.implicits._ 6 | 7 | object NewMovieValidator { 8 | 9 | /** 10 | * How do we combine two validations together? Refer to `ValidationExercises` for a refresher! 11 | * 12 | * Hint: `Validated` has an Applicative instance. 13 | */ 14 | def validate(newMovie: NewMovieRequest): ValidatedNel[MovieValidationError, ValidatedMovie] = 15 | ??? 16 | 17 | /** 18 | * If `name` is empty, return an `InvalidNel` containing `MovieNameTooShort`, 19 | * else return a `Valid` containing the `name`. 20 | * 21 | * Hint: You can use `.isEmpty` or `.nonEmpty` on `String` 22 | */ 23 | private def validateMovieName(name: String): ValidatedNel[MovieValidationError, String] = 24 | ??? 25 | 26 | /** 27 | * If `synopsis` is empty, return an `InvalidNel` containing `MovieSynopsisTooShort`, 28 | * else return a `Valid` containing the `synopsis`. 29 | * 30 | * Hint: You can use `.isEmpty` or `.nonEmpty` on `String` 31 | */ 32 | private def validateMovieSynopsis(synopsis: String): ValidatedNel[MovieValidationError, String] = 33 | ??? 34 | 35 | } 36 | -------------------------------------------------------------------------------- /src/main/scala/com/reagroup/appliedscala/AppRoutes.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.appliedscala 2 | 3 | import cats.effect._ 4 | import org.http4s._ 5 | import org.http4s.dsl.Http4sDsl 6 | 7 | /** 8 | * The `AppRoutes` class defines the routes for our app. 9 | */ 10 | class AppRoutes(fetchAllMoviesHandler: IO[Response[IO]], 11 | fetchMovieHandler: Long => IO[Response[IO]], 12 | fetchEnrichedMovieHandler: Long => IO[Response[IO]], 13 | saveMovieHandler: Request[IO] => IO[Response[IO]], 14 | saveReviewHandler: (Long, Request[IO]) => IO[Response[IO]]) extends Http4sDsl[IO] { 15 | 16 | /** 17 | * This Matcher is used to find a query parameter called `enriched` that can be set to `true` or `false` 18 | * e.g. myurl.com/movies?enriched=true 19 | * It will be used when working through the `fetchenrichedmovie` package. 20 | */ 21 | object OptionalEnrichedMatcher extends OptionalQueryParamDecoderMatcher[Boolean]("enriched") 22 | 23 | val openRoutes: HttpRoutes[IO] = HttpRoutes.of { 24 | case GET -> Root / "movies" => fetchAllMoviesHandler 25 | case GET -> Root / "movies" / LongVar(id) => ??? 26 | case req @ POST -> Root / "movies" => saveMovieHandler(req) 27 | case req @ POST -> Root / "movies" / LongVar(id) / "reviews" => saveReviewHandler(id, req) 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /src/test/scala/com/reagroup/appliedscala/urls/fetchenrichedmovie/FetchEnrichedMovieServiceSpec.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.appliedscala.urls.fetchenrichedmovie 2 | 3 | import cats.effect.IO 4 | import cats.effect.unsafe.implicits.global 5 | import com.reagroup.appliedscala.models._ 6 | import org.specs2.mutable.Specification 7 | 8 | class FetchEnrichedMovieServiceSpec extends Specification { 9 | 10 | "fetchEnrichedMovie" should { 11 | 12 | "return movie enriched with star rating" in { 13 | 14 | val expectedMovie = Movie("badman", "nananana", Vector.empty[Review]) 15 | val expectedMetascore = Metascore(100) 16 | 17 | val repo = (movieId: MovieId) => IO.pure(Some(expectedMovie)) 18 | 19 | val starRatingsRepo = (movieName: String) => IO.pure(Some(expectedMetascore)) 20 | 21 | val service = new FetchEnrichedMovieService(repo, starRatingsRepo) 22 | 23 | val actual = service.fetch(MovieId(123)) 24 | 25 | actual.unsafeRunSync() must beSome(EnrichedMovie(expectedMovie, expectedMetascore)) 26 | 27 | } 28 | 29 | "return error if star rating does not exist" in { 30 | 31 | val movie = Movie("badman", "nananana", Vector.empty[Review]) 32 | 33 | val repo = (movieId: MovieId) => IO.pure(Some(movie)) 34 | 35 | val starRatingsRepo = (movieName: String) => IO.pure(None) 36 | 37 | val service = new FetchEnrichedMovieService(repo, starRatingsRepo) 38 | 39 | val actual = service.fetch(MovieId(123)) 40 | 41 | actual.unsafeRunSync() must throwA[EnrichmentFailure] 42 | 43 | } 44 | 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /src/main/scala/com/reagroup/appliedscala/urls/fetchallmovies/FetchAllMoviesController.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.appliedscala.urls.fetchallmovies 2 | 3 | import cats.effect.IO 4 | import com.reagroup.appliedscala.models.Movie 5 | import com.reagroup.appliedscala.urls.ErrorHandler 6 | import io.circe.Json 7 | import io.circe.syntax._ 8 | import org.http4s._ 9 | import org.http4s.circe.CirceEntityCodec._ 10 | import org.http4s.dsl.Http4sDsl 11 | 12 | class FetchAllMoviesController(fetchMovies: IO[Vector[Movie]]) extends Http4sDsl[IO] { 13 | 14 | /** 15 | * 1. Execute the `fetchMovies` function to retrieve all our movies. 16 | * 2. Call `attempt` "at the end of the world" (right before we serve responses to the client) 17 | * because every `IO` can fail and we want to handle errors gracefully. 18 | * 3. Pattern match on the results and convert each possible case into an HTTP response. 19 | * 4. We have an `ErrorHandler` that handles all the errors in our program. 20 | */ 21 | def fetchAll: IO[Response[IO]] = for { 22 | errorOrMovies <- fetchMovies.attempt 23 | resp <- errorOrMovies match { 24 | case Right(movies) => Ok(movies.map(movieToJson)) 25 | case Left(e) => ErrorHandler(e) 26 | } 27 | } yield resp 28 | 29 | /** 30 | * The reason we aren't using an `Encoder` instance for this conversion here is because 31 | * we want you to write your own `Encoder` instance for the `GET movie/id` endpoint. 32 | * Don't want to giveaway the answer :) 33 | */ 34 | def movieToJson(movie: Movie): Json = 35 | Json.obj("name" -> movie.name.asJson) 36 | 37 | } 38 | -------------------------------------------------------------------------------- /src/main/scala/com/reagroup/appliedscala/urls/savereview/NewReviewValidator.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.appliedscala.urls.savereview 2 | 3 | import cats.data.Validated._ 4 | import cats.data.ValidatedNel 5 | import cats.implicits._ 6 | 7 | object NewReviewValidator { 8 | 9 | /** 10 | * How do we combine two validations together? Refer to `ValidationExercises` for a refresher! 11 | * 12 | * Hint: `Validated` has an Applicative instance. 13 | */ 14 | def validate(review: NewReviewRequest): ValidatedNel[ReviewValidationError, ValidatedReview] = 15 | (validateAuthor(review.author), validateComment(review.comment)).mapN(ValidatedReview) 16 | 17 | /** 18 | * If `author` is empty, return an `InvalidNel` containing `ReviewAuthorTooShort`, 19 | * else return a `Valid` containing the `author`. 20 | * 21 | * Hint: You can use `.isEmpty` or `.nonEmpty` on `String` 22 | */ 23 | private def validateAuthor(author: String): ValidatedNel[ReviewValidationError, String] = 24 | if(author.nonEmpty) { 25 | author.validNel 26 | } else { 27 | ReviewAuthorTooShort.invalidNel 28 | } 29 | 30 | /** 31 | * If `comment` is empty, return an `InvalidNel` containing `ReviewCommentTooShort`, 32 | * else return a `Valid` containing the `comment`. 33 | * 34 | * Hint: You can use `.isEmpty` or `.nonEmpty` on `String` 35 | */ 36 | private def validateComment(comment: String): ValidatedNel[ReviewValidationError, String] = 37 | if(comment.nonEmpty) { 38 | comment.validNel 39 | } else { 40 | ReviewCommentTooShort.invalidNel 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /src/main/scala/com/reagroup/appliedscala/urls/savereview/ReviewValidationError.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.appliedscala.urls.savereview 2 | 3 | import io.circe.Json 4 | import io.circe.syntax._ 5 | import io.circe.Encoder 6 | 7 | sealed trait ReviewValidationError 8 | 9 | case object ReviewAuthorTooShort extends ReviewValidationError 10 | 11 | case object ReviewCommentTooShort extends ReviewValidationError 12 | 13 | case object MovieDoesNotExist extends ReviewValidationError 14 | 15 | object ReviewValidationError { 16 | 17 | /** 18 | * Write a function that turns an `ReviewValidationError` to a `String`. 19 | * This will be used in our `Encoder`. 20 | * 21 | * `ReviewAuthorTooShort` -> "REVIEW_AUTHOR_TOO_SHORT" 22 | * `ReviewCommentTooShort` -> "REVIEW_COMMENT_TOO_SHORT" 23 | * `MovieDoesNotExist` -> "MOVIE_DOES_NOT_EXIST" 24 | * 25 | * Hint: Use pattern matching 26 | */ 27 | def show(error: ReviewValidationError): String = error match { 28 | case ReviewAuthorTooShort => "REVIEW_AUTHOR_TOO_SHORT" 29 | case ReviewCommentTooShort => "REVIEW_COMMENT_TOO_SHORT" 30 | case MovieDoesNotExist => "MOVIE_DOES_NOT_EXIST" 31 | } 32 | 33 | /** 34 | * Add an Encoder instance here 35 | * 36 | * We want the resulting Json to look like this: 37 | * 38 | * { 39 | * "error": "REVIEW_AUTHOR_TOO_SHORT" 40 | * } 41 | * 42 | * Hint: You don't want to use `deriveEncoder` here 43 | */ 44 | 45 | implicit val encoder: Encoder[ReviewValidationError] = 46 | Encoder { err => 47 | Json.obj("error" -> ReviewValidationError.show(err).asJson) 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /src/test/scala/com/reagroup/appliedscala/urls/repositories/Http4sMetascoreRepositorySpec.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.appliedscala.urls.repositories 2 | 3 | import org.specs2.mutable.Specification 4 | import cats.effect.IO 5 | import cats.effect.kernel.Resource 6 | import io.circe.Json 7 | import io.circe.syntax._ 8 | import org.http4s.circe.CirceEntityEncoder._ 9 | import org.http4s.client.Client 10 | import org.http4s.dsl.Http4sDsl 11 | import cats.effect.unsafe.implicits.global 12 | import com.reagroup.appliedscala.urls.fetchenrichedmovie.Metascore 13 | 14 | final class Http4sMetascoreRepositorySpec extends Specification with Http4sDsl[IO] { 15 | 16 | "Http4sMetascoreRepository" should { 17 | 18 | val validJson = Json.obj("name" -> "Encanto".asJson, "Metascore" -> 91.asJson) 19 | val client = stubClient(validJson) 20 | val apiKey = "Some api key" 21 | val repo = new Http4sMetascoreRepository(client, apiKey) 22 | 23 | "return Some if the metascore for the movie exists" in { 24 | val actual = repo("Encanto") 25 | actual.unsafeRunSync() must beSome(Metascore(91)) 26 | 27 | } 28 | 29 | "return None if the metascore for the movie does not exist" in { 30 | val invalidJson = Json.obj("name" -> "Shark Tale".asJson) 31 | val client = stubClient(invalidJson) 32 | val apiKey = "Some api key" 33 | val repo = new Http4sMetascoreRepository(client, apiKey) 34 | val actual = repo("Shark Tale") 35 | 36 | actual.unsafeRunSync() must beNone 37 | } 38 | } 39 | 40 | private def stubClient(responseJson: Json): Client[IO] = { 41 | Client[IO](_ => Resource.make(Ok(responseJson))(_ => IO.unit)) 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /src/main/scala/com/reagroup/appliedscala/urls/savereview/README.md: -------------------------------------------------------------------------------- 1 | ## POST movies/id/reviews 2 | 3 | This one is very similar to `POST movie`, there are some differences however: 4 | 5 | 1. When saving a review, the client needs to provide the `MovieId` associated with the review. 6 | 1. If the `MovieId` is for a movie that does not exist, we want to return an error. 7 | 1. We want to collect all errors for the movie and the review in the response 8 | 9 | This exercise is made up for three parts. Work through them in order: 10 | 11 | ### Part 1 12 | 13 | 1. Review the `ReviewValidationError` ADT (already implemented) 14 | 1. Review `ValidatedReview` (already implemented) 15 | 1. Review `NewReviewValidator` (already implemented) 16 | 17 | 18 | ### Part 2 19 | 20 | 1. Review `ReviewId` (already implemented) 21 | 1. Implement `SaveReviewService` and get the unit tests to pass 22 | 23 | 24 | ### Part 3 25 | 1. Review `SaveReviewController` (already implemented) 26 | 1. Review the wiring in `AppRuntime`and `AppRoutes` 27 | 1. Run curls to verify the application has been wired correctly 28 | 29 | 30 | Start the app using ./auto/start-local and use `curl` to test it out! 31 | 32 | #### Invalid movieId, author and comment 33 | 34 | ``` 35 | curl -v -H "Accept: application/json" -X POST -d "{\"author\": \"\", \"comment\": \"\"}" http://localhost:9200/movies/100/reviews | jq . 36 | ``` 37 | 38 | Should return all errors. 39 | 40 | #### Invalid movieId with valid author and comment 41 | 42 | ``` 43 | curl -v -H "Accept: application/json" -X POST -d "{\"author\": \"The Phantom Reviewer\", \"comment\": \"Boo\"}" http://localhost:9200/movies/100/reviews | jq . 44 | ``` 45 | 46 | Should return an error for movie id only. 47 | 48 | #### Valid movieId, author and comment 49 | 50 | ``` 51 | curl -v -H "Accept: application/json" -X POST -d "{\"author\": \"The Phantom Reviewer\", \"comment\": \"Boo\"}" http://localhost:9200/movies/1/reviews | jq . 52 | ``` 53 | 54 | Should succeed and return a 201 and a review id. 55 | 56 | #### Verify that the movie comes back with the reviews 57 | 58 | ``` 59 | curl -v -H "Accept: application/json" http://localhost:9200/movies/1 | jq . 60 | ``` 61 | 62 | Should return the movie and the reviews just created 63 | -------------------------------------------------------------------------------- /src/test/scala/com/reagroup/appliedscala/urls/savereview/SaveReviewServiceSpec.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.appliedscala.urls.savereview 2 | 3 | import cats.data.NonEmptyList 4 | import cats.effect.IO 5 | import cats.effect.unsafe.implicits.global 6 | import cats.implicits._ 7 | import com.reagroup.appliedscala.models._ 8 | import org.specs2.mutable.Specification 9 | 10 | class SaveReviewServiceSpec extends Specification { 11 | val movie = Movie("Empire Strikes Back", "Guy unwittingly is kissed by sister", Vector.empty) 12 | 13 | "saveReview" should { 14 | 15 | "return errors for invalid review" in { 16 | 17 | val saveReview = (movieId: MovieId, review: ValidatedReview) => IO.pure(ReviewId(12345)) 18 | val fetchMovie = (movieId: MovieId) => IO.pure(Option(movie)) 19 | 20 | 21 | val service = new SaveReviewService(saveReview, fetchMovie) 22 | 23 | val reviewToSave = NewReviewRequest("", "") 24 | 25 | val result = service.save(MovieId(12345), reviewToSave) 26 | 27 | result.unsafeRunSync() must_=== NonEmptyList.of(ReviewAuthorTooShort, ReviewCommentTooShort).invalid 28 | 29 | } 30 | 31 | "return errors for non-existent movie" in { 32 | 33 | val saveReview = (movieId: MovieId, review: ValidatedReview) => IO.pure(ReviewId(12345)) 34 | val fetchMovie = (movieId: MovieId) => IO.pure(None) 35 | 36 | 37 | val service = new SaveReviewService(saveReview, fetchMovie) 38 | 39 | val reviewToSave = NewReviewRequest("", "") 40 | 41 | val result = service.save(MovieId(12345), reviewToSave) 42 | 43 | result.unsafeRunSync() must_=== NonEmptyList.of(MovieDoesNotExist, ReviewAuthorTooShort, ReviewCommentTooShort).invalid 44 | 45 | } 46 | 47 | "return saved reviewId" in { 48 | 49 | val saveReview = (movieId: MovieId, review: ValidatedReview) => IO.pure(ReviewId(12345)) 50 | val fetchMovie = (movieId: MovieId) => IO.pure(Option(movie)) 51 | 52 | 53 | val service = new SaveReviewService(saveReview, fetchMovie) 54 | 55 | val reviewToSave = NewReviewRequest("bob", "good movie") 56 | 57 | val result = service.save(MovieId(12345), reviewToSave) 58 | 59 | result.unsafeRunSync() must_=== ReviewId(12345).valid 60 | 61 | } 62 | 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /src/test/scala/com/reagroup/appliedscala/urls/fetchmovie/FetchMovieControllerSpec.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.appliedscala.urls.fetchmovie 2 | 3 | import cats.effect.IO 4 | import cats.effect.unsafe.implicits.global 5 | import com.reagroup.appliedscala.models._ 6 | import io.circe.literal._ 7 | import org.http4s._ 8 | import org.specs2.mutable.Specification 9 | 10 | class FetchMovieControllerSpec extends Specification { 11 | 12 | "when fetching a movie that exists" should { 13 | 14 | val expectedMovie = Movie("badman", "the first in the series", Vector(Review("bob", "great movie"))) 15 | 16 | val controller = new FetchMovieController((_: MovieId) => IO.pure(Some(expectedMovie))) 17 | 18 | val actual = controller.fetch(123).unsafeRunSync() 19 | 20 | "return status code OK" in { 21 | 22 | actual.status must beEqualTo(Status.Ok) 23 | 24 | } 25 | 26 | "return movie in response body" in { 27 | 28 | val expectedJson = 29 | json""" 30 | {"name": "badman", "synopsis": "the first in the series", "reviews": [ { "author": "bob", "comment": "great movie" } ] } 31 | """ 32 | actual.as[String].unsafeRunSync() must beEqualTo(expectedJson.noSpaces) 33 | 34 | } 35 | 36 | } 37 | 38 | "when fetching a movie that does not exist" should { 39 | 40 | val controller = new FetchMovieController((_: MovieId) => IO.pure(None)) 41 | 42 | val actual = controller.fetch(123).unsafeRunSync() 43 | 44 | "return status code NotFound" in { 45 | 46 | actual.status must beEqualTo(Status.NotFound) 47 | 48 | } 49 | 50 | } 51 | 52 | "when encountered an error" should { 53 | 54 | val controller = new FetchMovieController((_: MovieId) => IO.raiseError(new RuntimeException("unknown error"))) 55 | 56 | val actual = controller.fetch(123).unsafeRunSync() 57 | 58 | "return status code InternalServerError" in { 59 | 60 | actual.status must beEqualTo(Status.InternalServerError) 61 | 62 | } 63 | 64 | "return error message in response body" in { 65 | 66 | val expectedJson = 67 | json""" 68 | { "error": "Unexpected error has occurred: unknown error" } 69 | """ 70 | actual.as[String].unsafeRunSync() must beEqualTo(expectedJson.noSpaces) 71 | 72 | } 73 | 74 | } 75 | 76 | } 77 | -------------------------------------------------------------------------------- /src/test/scala/com/reagroup/appliedscala/urls/savemovie/SaveMovieControllerSpec.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.appliedscala.urls.savemovie 2 | 3 | import cats.data.NonEmptyList 4 | import cats.effect.IO 5 | import cats.effect.unsafe.implicits.global 6 | import cats.implicits._ 7 | import com.reagroup.appliedscala.models._ 8 | import io.circe.literal._ 9 | import org.http4s._ 10 | import org.specs2.mutable.Specification 11 | 12 | class SaveMovieControllerSpec extends Specification { 13 | 14 | "when saving a valid movie" should { 15 | 16 | val json = 17 | json""" 18 | { 19 | "name": "Jurassic Park", 20 | "synopsis": "Why does pterodactyl start with a p?" 21 | } 22 | """ 23 | 24 | val request = Request[IO](method = Method.POST).withEntity(json.noSpaces) 25 | 26 | val controller = new SaveMovieController((_: NewMovieRequest) => IO.pure(MovieId(1).valid)) 27 | 28 | val actual = controller.save(request).unsafeRunSync() 29 | 30 | "return status code Created" in { 31 | 32 | actual.status must beEqualTo(Status.Created) 33 | 34 | } 35 | 36 | "return movieId in response body" in { 37 | 38 | val expectedJson = 39 | json""" 40 | { "id": 1 } 41 | """ 42 | actual.as[String].unsafeRunSync() must beEqualTo(expectedJson.noSpaces) 43 | 44 | } 45 | 46 | } 47 | 48 | "when saving an invalid movie" should { 49 | 50 | val invalidJson = 51 | json""" 52 | { 53 | "name": "", 54 | "synopsis": "" 55 | } 56 | """ 57 | 58 | val request = Request[IO](method = Method.POST).withEntity(invalidJson.noSpaces) 59 | 60 | val saveNewMovie = (_: NewMovieRequest) => IO.pure(NonEmptyList.of(MovieNameTooShort, MovieSynopsisTooShort).invalid) 61 | 62 | val controller = new SaveMovieController(saveNewMovie) 63 | 64 | val actual = controller.save(request).unsafeRunSync() 65 | 66 | "return status code BadRequest" in { 67 | 68 | actual.status must beEqualTo(Status.BadRequest) 69 | 70 | } 71 | 72 | "return errors in response body" in { 73 | 74 | val expectedJson = 75 | json""" 76 | [ { "error": "MOVIE_NAME_TOO_SHORT" }, { "error": "MOVIE_SYNOPSIS_TOO_SHORT"} ] 77 | """ 78 | actual.as[String].unsafeRunSync() must beEqualTo(expectedJson.noSpaces) 79 | 80 | } 81 | 82 | } 83 | 84 | } 85 | -------------------------------------------------------------------------------- /src/test/scala/com/reagroup/appliedscala/urls/savereview/SaveReviewControllerSpec.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.appliedscala.urls.savereview 2 | 3 | import cats.data.NonEmptyList 4 | import cats.effect.IO 5 | import cats.effect.unsafe.implicits.global 6 | import cats.implicits._ 7 | import com.reagroup.appliedscala.models._ 8 | import io.circe.literal._ 9 | import org.http4s._ 10 | import org.specs2.mutable.Specification 11 | 12 | class SaveReviewControllerSpec extends Specification { 13 | 14 | "when saving a valid review" should { 15 | 16 | val json = 17 | json""" 18 | { 19 | "author": "Bob", 20 | "comment": "This is a good movie" 21 | } 22 | """ 23 | 24 | val request = Request[IO](method = Method.POST).withEntity(json.noSpaces) 25 | 26 | val controller = new SaveReviewController((_: MovieId, _: NewReviewRequest) => IO.pure(ReviewId(1).valid)) 27 | 28 | val actual = controller.save(100, request).unsafeRunSync() 29 | 30 | "return status code Created" in { 31 | 32 | actual.status must beEqualTo(Status.Created) 33 | 34 | } 35 | 36 | "return reviewId in response body" in { 37 | 38 | val expectedJson = 39 | json""" 40 | { "id": 1 } 41 | """ 42 | actual.as[String].unsafeRunSync() must beEqualTo(expectedJson.noSpaces) 43 | 44 | } 45 | 46 | } 47 | 48 | "when saving an invalid review" should { 49 | 50 | val invalidJson = 51 | json""" 52 | { 53 | "author": "", 54 | "comment": "" 55 | } 56 | """ 57 | 58 | val request = Request[IO](method = Method.POST).withEntity(invalidJson.noSpaces) 59 | 60 | val saveNewReview = (_: MovieId, _: NewReviewRequest) => IO.pure(NonEmptyList.of(ReviewAuthorTooShort, ReviewCommentTooShort).invalid) 61 | 62 | val controller = new SaveReviewController(saveNewReview) 63 | 64 | val actual = controller.save(100, request).unsafeRunSync() 65 | 66 | "return status code BadRequest" in { 67 | 68 | actual.status must beEqualTo(Status.BadRequest) 69 | 70 | } 71 | 72 | "return errors in response body" in { 73 | 74 | val expectedJson = 75 | json""" 76 | [ { "error": "REVIEW_AUTHOR_TOO_SHORT" }, { "error": "REVIEW_COMMENT_TOO_SHORT"} ] 77 | """ 78 | actual.as[String].unsafeRunSync() must beEqualTo(expectedJson.noSpaces) 79 | 80 | } 81 | 82 | } 83 | 84 | } 85 | -------------------------------------------------------------------------------- /src/test/scala/com/reagroup/appliedscala/urls/fetchenrichedmovie/FetchEnrichedMovieControllerSpec.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.appliedscala.urls.fetchenrichedmovie 2 | 3 | import cats.effect.IO 4 | import cats.effect.unsafe.implicits.global 5 | import com.reagroup.appliedscala.models._ 6 | import io.circe.literal._ 7 | import org.http4s._ 8 | import org.specs2.mutable.Specification 9 | 10 | class FetchEnrichedMovieControllerSpec extends Specification { 11 | 12 | "when fetching a movie that exists" should { 13 | 14 | val expectedMovie = EnrichedMovie(Movie("badman", "the first in the series", Vector.empty), Metascore(100)) 15 | 16 | val fetchMovie = (_: MovieId) => IO.pure(Some(expectedMovie)) 17 | 18 | val controller = new FetchEnrichedMovieController(fetchMovie) 19 | 20 | val actual = controller.fetch(123).unsafeRunSync() 21 | 22 | "return status code OK" in { 23 | 24 | actual.status must beEqualTo(Status.Ok) 25 | 26 | } 27 | 28 | "return enriched movie in response body" in { 29 | 30 | val expectedJson = 31 | json""" 32 | { 33 | "name": "badman", 34 | "synopsis": "the first in the series", 35 | "reviews": [], 36 | "metascore": 100 37 | } 38 | """ 39 | actual.as[String].unsafeRunSync() must beEqualTo(expectedJson.noSpaces) 40 | 41 | } 42 | 43 | } 44 | 45 | "when fetching a movie that does not exist" should { 46 | 47 | val fetchEnrichedMovie = (_: MovieId) => IO.pure(None) 48 | 49 | val controller = new FetchEnrichedMovieController(fetchEnrichedMovie) 50 | 51 | val actual = controller.fetch(123).unsafeRunSync() 52 | 53 | "return status code NotFound" in { 54 | 55 | actual.status must beEqualTo(Status.NotFound) 56 | 57 | } 58 | 59 | } 60 | 61 | "when encountered an error" should { 62 | 63 | val fetchEnrichedMovie = (_: MovieId) => IO.raiseError(new RuntimeException("unknown error")) 64 | 65 | val controller = new FetchEnrichedMovieController(fetchEnrichedMovie) 66 | 67 | val actual = controller.fetch(123).unsafeRunSync() 68 | 69 | "return status code InternalServerError" in { 70 | 71 | actual.status must beEqualTo(Status.InternalServerError) 72 | 73 | } 74 | 75 | "return error message in response body" in { 76 | 77 | val expectedJson = 78 | json""" 79 | { "error": "Unexpected error has occurred: unknown error" } 80 | """ 81 | actual.as[String].unsafeRunSync() must beEqualTo(expectedJson.noSpaces) 82 | 83 | } 84 | 85 | } 86 | 87 | } 88 | -------------------------------------------------------------------------------- /src/main/scala/com/reagroup/appliedscala/urls/fetchmovie/README.md: -------------------------------------------------------------------------------- 1 | ## GET movies/id 2 | 3 | We will now add an endpoint to fetch a movie. 4 | 5 | ### 1. `PostgresRepository` 6 | 7 | You are provided a `PostgresqlRepository` that contains all the SQL needed to work with Postgres. We have done this for you so that you do not have to write any of the SQL on your own. 8 | 9 | We are using a library called [doobie](https://tpolecat.github.io/doobie/). If we look at this file, we have one function for each of our endpoints already implemented. Keep in mind that the return type of each function is an `IO`. 10 | 11 | The function that is useful for us for this endpoint is `fetchMovie`, which has type signature of `MovieId => IO[Option[Movie]]`. 12 | 13 | ### 2. `FetchMovieService` (exercise) 14 | 15 | The `Service` typically has business logic. For example, it may call multiple repositories and then validate their responses to construct another response. 16 | 17 | Pay attention to the types we are using in the `Service`. We have a `MovieId` (not a `Long`!) and we are returning a `Movie`. Not only do these types improve readability, they provide additional safety. We can't accidentally supply a `CustomerId` where a `MovieId` is expected. 18 | 19 | There's nothing to do with HTTP or Json responses here. 20 | 21 | _**Complete exercise**_ 22 | 23 | _**Run unit test: `FetchMovieServiceSpec`**_ 24 | 25 | ### 3. `FetchMovieController` (exercise) 26 | 27 | The `fetch` method has a `movieId` passed in as a `Long`. This is the id that is in the path of the request `GET movie/123`. The return type is `IO[Response[IO]]`. This is Http4s' response type. 28 | 29 | The `Controller` layer takes care of HTTP request and response so the `Service` layer does not need to. 30 | 31 | To complete this exercise, you need to complete the following first: 32 | 33 | - Encoder for `Review` 34 | - Encoder for `Movie` 35 | 36 | _**Complete exercise**_ 37 | 38 | _**Run unit test: `FetchMovieControllerSpec`**_ 39 | 40 | ### 4. Update `AppRoutes` (exercise) 41 | 42 | Notice that `AppRoutes` has two unused handlers: `fetchMovieHandler` and `fetchEnrichedMovieHandler`. Ignore the latter for now. 43 | 44 | There is an unimplemented route for fetching a single movie, where the `id` of the movie is extracted out using `LongVar`. 45 | 46 | (`LongVar` is an extractor. If a `Long` matches in that spot, we get access to it on the right hand side.) 47 | 48 | Implement this route by calling `fetchMovieHandler`. 49 | 50 | _**Run unit test: `AppRoutesSpec`**_ (ignore the failure in the `enriched` route test) 51 | 52 | ### 5. Wire it all up in `AppRuntime` (exercise) 53 | 54 | Now let's wire the `Service` and `Controller` up in `AppRuntime`. 55 | 56 | Pass in the handler from the newly instantiated `fetchMovieController` into `appRoutes`. 57 | 58 | ### 6. Manual test 59 | 60 | Start the app using `./auto/start-local` and `curl http://localhost:9200/movies/1` to get `Titanic` back! 61 | -------------------------------------------------------------------------------- /src/test/scala/com/reagroup/exercises/validated/ValidationExercisesSpec.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.exercises.validated 2 | 3 | import org.specs2.mutable.Specification 4 | import ValidationExercises._ 5 | import cats.data.NonEmptyList 6 | import cats.data.Validated._ 7 | 8 | class ValidationExercisesSpec extends Specification { 9 | 10 | "passwordLengthValidation" should { 11 | "return the valid password when the password is not too short" in { 12 | passwordLengthValidation("abcdef123") must_=== Valid("abcdef123") 13 | } 14 | 15 | "return an error when the password is too short" in { 16 | passwordLengthValidation("crim3a") must_=== Invalid(NonEmptyList.of(PasswordTooShort)) 17 | } 18 | } 19 | 20 | "passwordStrengthValidation" should { 21 | "return the valid password when the password is not too weak" in { 22 | passwordStrengthValidation("abcdef123") must_=== Valid("abcdef123") 23 | } 24 | 25 | "return an error when password is too weak" in { 26 | passwordStrengthValidation("crimeaasd") must_=== Invalid(NonEmptyList.of(PasswordTooWeak)) 27 | } 28 | } 29 | 30 | "passwordValidation" should { 31 | "return the valid password when the password is not too short and not too weak" in { 32 | passwordValidation("abcdef123") must_=== Valid("abcdef123") 33 | } 34 | 35 | "return two errors when the password is too short and too weak" in { 36 | passwordValidation("") must_=== Invalid(NonEmptyList.of(PasswordTooWeak, PasswordTooShort)) 37 | } 38 | } 39 | 40 | "nameValidation" should { 41 | "return the valid name when the name is not empty" in { 42 | nameValidation("bob", "firstName") must_=== Valid("bob") 43 | } 44 | 45 | "return an error when given an empty name" in { 46 | nameValidation("", "someLabel") must_=== Invalid(NonEmptyList.of(NameIsEmpty("someLabel"))) 47 | } 48 | } 49 | 50 | "validatePerson" should { 51 | "return a valid person if all fields are valid" in { 52 | validatePerson("bob", "smith", "abc1234567") must_=== Valid(Person("bob", "smith", "abc1234567")) 53 | } 54 | 55 | "return all errors when given empty names and no password" in { 56 | validatePerson("", "", "") must_=== Invalid(NonEmptyList.of(NameIsEmpty("firstName"), NameIsEmpty("lastName"), PasswordTooWeak, PasswordTooShort)) 57 | } 58 | } 59 | 60 | "validatePeople" should { 61 | "return all valid people if there are no errors" in { 62 | val inputs = List(("jimm", "smith", "hunter1234567"), ("hulk", "smash", "abcc33332")) 63 | 64 | val errsOrPeople = validatePeople(inputs) 65 | errsOrPeople must_=== Valid(List(Person("jimm", "smith", "hunter1234567"), Person("hulk", "smash", "abcc33332"))) 66 | } 67 | 68 | "return all errors if there is any" in { 69 | val inputs = List(("jimm", "", ""), ("", "smith", "abcc33332")) 70 | 71 | val errsOrPeople = validatePeople(inputs) 72 | errsOrPeople must_=== Invalid(NonEmptyList.of(NameIsEmpty("lastName"), PasswordTooWeak, PasswordTooShort, NameIsEmpty("firstName"))) 73 | } 74 | } 75 | 76 | } 77 | -------------------------------------------------------------------------------- /src/main/scala/com/reagroup/appliedscala/urls/fetchallmovies/README.md: -------------------------------------------------------------------------------- 1 | ## GET movies 2 | 3 | _There is no exercise for this endpoint._ 4 | 5 | We have already implemented an endpoint for you! Let's have a look at it in `AppRoutes`. 6 | 7 | ### 1. `AppRoutes` 8 | 9 | We can see that `AppRoutes` already has a `FetchAllMoviesController` in scope. If the incoming request is `GET movies`, we call the `FetchAllMoviesController`. 10 | 11 | Let's look at the `Controller` and `Service` implementations now. Open up the `fetchallmovies` package in the project. This package contains the `Service` and `Controller` for fetching all movies. 12 | 13 | ### 2. `FetchAllMoviesService` 14 | 15 | The `Service` has a `fetchAllMovies` passed in as dependency. The `Service` is typically where your business logic is, but fetching all movies is extremely simple. All we need to do here is call `fetchAllMovies`. 16 | 17 | More concretely, `fetchAllMovies` is the function defined in `PostgresRepository` for fetching all the movies from the database. Let's have a quick look at `PostgresRepository`, which contains all the logic needed to interact with our database. 18 | 19 | ### 3. `PostgresRepository` 20 | 21 | We have implemented all the SQL needed to work with Postgres in this file. We are using a library called `Doobie`. If we look at this file, we have one function for each of our endpoints already implemented. Keep in mind that the return type of each function is an `IO`. 22 | 23 | The function that this endpoint uses is `fetchAllMovies`. 24 | 25 | ### 4. `FetchAllMoviesController` 26 | 27 | The `Controller` takes the function from the `Service` in as a dependency. Every `IO` has potential for failure by definition. At this point, we call `fetchAll` and then we attempt it here, before serving any result to the client. We typically want to defer error handling to the end of our program so we do not have to handle it all over the place. 28 | 29 | Now we can pattern match on the result. If we have `Right(movies)`, we construct an 200 `Ok(...)` response with the `movies` converted to `Json`. Otherwise, we pass the error to our `ErrorHandler`, which we will explain later. We should really be using an `Encoder[Movie]` here and do `movies.asJson` but we aren't because we want you to write the `Encoder` instance for `Movie` later on. We don't want to give away the answer! 30 | 31 | ### 5. `ErrorHandler` detour 32 | 33 | We have written this error handler to convert errors into a nice `Json` response. Every time you get a `Left` in your `Controller`, call this function. 34 | 35 | Because `AppError` is an ADT, if we add a new `AppError`, we get a compilation error in `encodeAppError` because of non-exhaustiveness. 36 | 37 | ### 6. Hook it all up in `AppRuntime` and `AppRoutes` 38 | 39 | In `AppRuntime`, we instantiate our `PostgresqlRepository`, our `FetchAllMoviesService` and `FetchAllMoviesController`. Then we pass the `Controller` into `AppRoutes`. 40 | 41 | If we have a look at `AppRoutes`, we can see that when we call `GET movies`, we run `fetchAllMovies`, which calls the `apply` method in the `FetchAllMoviesController`. 42 | 43 | ### 7. Start up the app and test 44 | 45 | Run `./auto/start-local` 46 | 47 | `curl http://localhost:9200/movies` -------------------------------------------------------------------------------- /src/main/scala/com/reagroup/appliedscala/urls/repositories/PostgresqlRepository.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.appliedscala.urls.repositories 2 | 3 | import cats.effect.IO 4 | import com.reagroup.appliedscala.config.DatabaseConfig 5 | import com.reagroup.appliedscala.models._ 6 | import com.reagroup.appliedscala.urls.savemovie.ValidatedMovie 7 | import com.reagroup.appliedscala.urls.savereview.{ReviewId, ValidatedReview} 8 | import doobie._ 9 | import doobie.implicits._ 10 | import org.postgresql.ds.PGSimpleDataSource 11 | 12 | class PostgresqlRepository(transactor: Transactor[IO]) { 13 | 14 | case class MovieRow(name: String, synopsis: String, review: Option[Review]) 15 | 16 | def fetchMovie(movieId: MovieId): IO[Option[Movie]] = { 17 | 18 | def toMovie(rows: Vector[MovieRow]): Option[Movie] = rows.headOption.map { 19 | case MovieRow(name, synopsis, _) => Movie(name, synopsis, rows.flatMap(_.review)) 20 | } 21 | 22 | for { 23 | rows <- sql""" 24 | SELECT m.name, m.synopsis, r.author, r.comment 25 | FROM movie m 26 | LEFT OUTER JOIN review r ON r.movie_id = m.id 27 | WHERE m.id = ${movieId.value} 28 | ORDER BY m.id 29 | """.query[MovieRow].to[Vector].transact(transactor) 30 | } yield toMovie(rows) 31 | } 32 | 33 | def fetchAllMovies: IO[Vector[Movie]] = { 34 | 35 | def toMovies(rows: Vector[MovieRow]): Vector[Movie] = rows.groupBy(r => (r.name, r.synopsis)).map { 36 | case ((name, synopsis), movieRows) => Movie(name, synopsis, movieRows.flatMap(_.review)) 37 | }.toVector 38 | 39 | for { 40 | rows <- sql""" 41 | SELECT m.name, m.synopsis, r.author, r.comment 42 | FROM movie m 43 | LEFT OUTER JOIN review r ON r.movie_id = m.id 44 | ORDER BY m.id 45 | """.query[MovieRow].to[Vector].transact(transactor) 46 | } yield toMovies(rows) 47 | 48 | } 49 | 50 | def saveMovie(movie: ValidatedMovie): IO[MovieId] = { 51 | val insertMovie: ConnectionIO[MovieId] = 52 | for { 53 | movieId <- sql""" 54 | INSERT INTO movie (name, synopsis) VALUES (${movie.name}, ${movie.synopsis}) 55 | RETURNING id 56 | """.query[MovieId].unique 57 | } yield movieId 58 | 59 | insertMovie.transact(transactor) 60 | } 61 | 62 | def saveReview(movieId: MovieId, review: ValidatedReview): IO[ReviewId] = { 63 | val insertMovie: ConnectionIO[ReviewId] = 64 | for { 65 | reviewId <- sql""" 66 | INSERT INTO review (movie_id, author, comment) VALUES (${movieId.value}, ${review.author}, ${review.comment}) 67 | RETURNING id 68 | """.query[ReviewId].unique 69 | } yield reviewId 70 | 71 | insertMovie.transact(transactor) 72 | } 73 | } 74 | 75 | object PostgresqlRepository { 76 | @SuppressWarnings(Array("org.wartremover.warts.GlobalExecutionContext")) 77 | def apply(config: DatabaseConfig): PostgresqlRepository = { 78 | val ds = new PGSimpleDataSource() 79 | ds.setServerNames(Array(config.host)) 80 | ds.setUser(config.username) 81 | ds.setPassword(config.password.value) 82 | ds.setDatabaseName(config.databaseName) 83 | 84 | val transactor = Transactor.fromDataSource[IO](ds, scala.concurrent.ExecutionContext.global) 85 | new PostgresqlRepository(transactor) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/main/scala/com/reagroup/exercises/validated/ValidationExercises.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.exercises.validated 2 | 3 | import cats.data.Validated 4 | import cats.data.ValidatedNel 5 | import cats.implicits._ 6 | 7 | /** 8 | * These exercises are repurposed from https://github.com/cwmyers/FunctionalTraining 9 | * 10 | * For further reading: Refer to Cats docs below. You can replace all references of `ValidatedNec` with `ValidatedNel`. 11 | * `Nec` stands for `NonEmptyChain`, which is very similar to `NonEmptyList`. 12 | * Cats implemented their own version of Scala's `List` called `Chain`, and `NonEmptyChain` is the `NonEmptyList` equivalent. 13 | * 14 | * https://typelevel.org/cats/datatypes/validated.html 15 | * 16 | * Here's an REA tech blog post on the same topic, but using Scalaz instead of Cats: 17 | * https://www.rea-group.com/blog/feeling-validated-a-different-way-to-validate-your-input 18 | */ 19 | object ValidationExercises { 20 | 21 | case class Person(firstName: String, lastName: String, password: String) 22 | 23 | sealed trait ValidationError 24 | 25 | case object PasswordTooShort extends ValidationError 26 | 27 | case object PasswordTooWeak extends ValidationError 28 | 29 | case class NameIsEmpty(label: String) extends ValidationError 30 | 31 | /** 32 | * If the `name` is empty, return a `NameIsEmpty(label)` in an `Invalid(NonEmptyList(...)`. 33 | * 34 | * `label` will be something like `firstName` or `lastName`. 35 | * 36 | * If the `name` is not empty, return it in a `Valid`. 37 | * 38 | * Hint: Use the `.invalidNel` and `.validNel` combinators 39 | */ 40 | def nameValidation(name: String, label: String): ValidatedNel[ValidationError, String] = 41 | ??? 42 | 43 | /** 44 | * If the `password` does not contain a numeric character, return a `PasswordTooWeak`. 45 | * 46 | * Otherwise, return the `password`. 47 | * 48 | * Hint: Use `password.exists(Character.isDigit)` 49 | */ 50 | def passwordStrengthValidation(password: String): ValidatedNel[ValidationError, String] = 51 | ??? 52 | 53 | /** 54 | * If the `password` length is not greater than 8 characters, return `PasswordTooShort`. 55 | * 56 | * Otherwise, return the `password`. 57 | */ 58 | def passwordLengthValidation(password: String): ValidatedNel[ValidationError, String] = 59 | ??? 60 | 61 | /** 62 | * Compose `passwordStrengthValidation` and `passwordLengthValidation` using Applicative `productR` 63 | * to construct a larger `passwordValidation`. 64 | */ 65 | def passwordValidation(password: String): ValidatedNel[ValidationError, String] = 66 | ??? 67 | 68 | /** 69 | * Compose `nameValidation` and `passwordValidation` to construct a function to `validatePerson`. 70 | * 71 | * Take a look at `.mapN` for this one, to map a tuple of ValidatedNels to a singular ValidatedNel 72 | */ 73 | def validatePerson(firstName: String, lastName: String, password: String): ValidatedNel[ValidationError, Person] = 74 | ??? 75 | 76 | 77 | /** 78 | * Given a list of `(firstName, lastName, password)`, return either a `List[Person]` or 79 | * all the `ValidationErrors` if there are any. 80 | */ 81 | type FirstName = String 82 | type LastName = String 83 | type Password = String 84 | def validatePeople(inputs: List[(FirstName, LastName, Password)]): ValidatedNel[ValidationError, List[Person]] = 85 | ??? 86 | 87 | } 88 | -------------------------------------------------------------------------------- /src/test/scala/com/reagroup/appliedscala/AppRoutesSpec.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.appliedscala 2 | 3 | import cats.effect.IO 4 | import cats.effect.unsafe.implicits.global 5 | import com.reagroup.appliedscala.Http4sSpecHelpers._ 6 | import org.http4s._ 7 | import org.http4s.dsl.Http4sDsl 8 | import org.http4s.implicits._ 9 | import org.specs2.mutable.Specification 10 | import org.specs2.specification.core.Fragment 11 | 12 | class AppRoutesSpec extends Specification with Http4sDsl[IO] { 13 | 14 | /* 15 | * We can test our routes independently of our controllers. 16 | * 17 | * Here we create the AppRoutes object using functions defined in this test, 18 | * instead of using the "real" controllers. 19 | */ 20 | 21 | private val testAppRoutes = new AppRoutes( 22 | fetchAllMoviesHandler = fetchAllMovies, 23 | fetchMovieHandler = fetchMovie, 24 | fetchEnrichedMovieHandler = fetchEnrichedMovie, 25 | saveMovieHandler = saveMovie, 26 | saveReviewHandler = saveReview 27 | ) 28 | 29 | /* 30 | * The functions standing in for the controllers can be very simple. 31 | * 32 | * Their purpose is to return a distinctive response, so we can 33 | * confirm that AppRoutes delegates to the right controller for each route. 34 | * 35 | * They can also check whether or not the pattern matchers in the route pattern 36 | * extracted parameters from the URL correctly. 37 | */ 38 | 39 | def fetchAllMovies: IO[Response[IO]] = Ok("great titles") 40 | 41 | def fetchMovie(rawMovieId: Long): IO[Response[IO]] = { 42 | if (rawMovieId == 123) { 43 | Ok("got movie 123") 44 | } else { 45 | BadRequest(s"Expected movieId 123, but received $rawMovieId") 46 | } 47 | } 48 | 49 | def fetchEnrichedMovie(rawMovieId: Long): IO[Response[IO]] = { 50 | if (rawMovieId == 123) { 51 | Ok("got enriched movie 123") 52 | } else { 53 | BadRequest(s"Expected movieId 123, but received $rawMovieId") 54 | } 55 | } 56 | 57 | def saveMovie(req: Request[IO]): IO[Response[IO]] = Ok("movie 456 created") 58 | 59 | def saveReview(rawMovieId: Long, req: Request[IO]): IO[Response[IO]] = { 60 | if (rawMovieId == 456) { 61 | Ok("review 7 for movie 456 created") 62 | } else { 63 | BadRequest(s"Expected movieId 456, but received $rawMovieId") 64 | } 65 | } 66 | 67 | "AppRoutes" should { 68 | val endpoints: List[(Request[IO], String)] = List( 69 | request(path = "/movies", method = Method.GET) -> "great titles", 70 | request(path = "/movies/123", method = Method.GET) -> "got movie 123", 71 | request(uri = uri"/movies/123?enriched=true", method = Method.GET) -> "got enriched movie 123", 72 | request(path = "/movies", method = Method.POST) -> "movie 456 created", 73 | request(path = "/movies/456/reviews", method = Method.POST) -> "review 7 for movie 456 created" 74 | ) 75 | 76 | Fragment.foreach(endpoints) { endpoint => 77 | val (req, expectedResponse) = endpoint 78 | 79 | s"for ${req.method} ${req.uri}" in { 80 | 81 | "return OK" in { 82 | testAppRoutes.openRoutes(req).getOrElse(Response[IO](status = Status.NotFound)).unsafeRunSync().status must beEqualTo(Status.Ok) 83 | } 84 | 85 | "return expected response" in { 86 | body(testAppRoutes.openRoutes(req).getOrElse(Response[IO](Status.NotFound))) must beEqualTo(expectedResponse) 87 | } 88 | } 89 | } 90 | } 91 | 92 | } 93 | 94 | -------------------------------------------------------------------------------- /src/main/scala/com/reagroup/appliedscala/urls/savemovie/README.md: -------------------------------------------------------------------------------- 1 | ## POST movies 2 | 3 | This endpoint is different than the `GET` ones because there is a request body that we receive from the client that we need to decode and turn into a Movie. 4 | 5 | ### 1. `MovieValidationError` (2 min) 6 | 7 | We have built an ADT that represents all possible validation errors for a `NewMovieRequest`. 8 | 9 | If the `name` is empty, return a `MovieNameTooShort` and if the `synopsis` is empty, return a `MovieSynopsisTooShort`. 10 | 11 | ### 2. `NewMovieRequest` vs `ValidatedMovie` (3 min) 12 | 13 | We have two different models here. A `NewMovieRequest` represents a request that has been successfully decoded containing a `name` and `synopsis` to save into the database. 14 | 15 | However, at the point of decoding, we do not know whether the `name` and `synopsis` obey our business rules. 16 | 17 | We need to validate this model and if it is valid, we create a different type `ValidatedMovie` that represents this. The two types contain the same information, but by making them two distinct types, we can enforce additional type safety and better readability. 18 | 19 | ### 3. `NewMovieValidator` (exercise) (5 min) 20 | 21 | Build a `validate` function that takes a `NewMovieRequest` and returns either an `Invalid[NonEmptyList[MovieValidationError]]` or a `Valid[ValidatedMovie]`. 22 | 23 | Remember what you learned from `ValidationExercises`. 24 | 25 | _**Complete exercise**_ 26 | 27 | _**Run unit test: `NewMovieValidatorSpec`**_ 28 | 29 | ### 4. `SaveMovieService` (exercise) (5 min) 30 | 31 | We can see `SaveMovieService` has a `saveMovie` function taken in as a dependency. It is of type `ValidatedMovie => IO[MovieId]`. 32 | 33 | The `save` function accepts a `NewMovieRequest` and returns a `IO[ValidatedNel[MovieValidationError, MovieId]]`. We want to validate the request, if it is valid, we save the movie and return the `MovieId`, otherwise we return all the errors. 34 | 35 | _**Complete exercise**_ 36 | 37 | _**Run unit test: `SaveMovieServiceSpec`**_ 38 | 39 | ### 5. `SaveMovieController` (exercise) (15 min) 40 | 41 | The `Controller` is a little different this time. We have the entire request as an argument to the function. We want to decode the request into a `NewMovieRequest` and then pass that into the `saveNewMovie` function in the class constructor. 42 | 43 | After that, we want to `attempt` as usual and handle each possibility. 44 | 45 | The `Decoder`s and `Encoder`s that are necessary for this `Controller` to work have been completed ahead of time: 46 | 47 | - `Decoder[NewMovieRequest]` 48 | - `Encoder[MovieValidationError]` 49 | - `Encoder[MovieId]` 50 | 51 | _**Complete exercise**_ 52 | 53 | _**Run unit test: `SaveMovieControllerSpec`**_ 54 | 55 | ### Review `AppRuntime` and `AppRoutes` (5 min) 56 | 57 | The `Service` and `Controller` have been wired up in `AppRuntime` and passed into `AppRoutes`. 58 | 59 | There is a `POST` route that calls the `saveMovieHandler`. 60 | 61 | Note that `req@POST...` means that `req` is an alias for the value on the right hand side. 62 | 63 | Start the app using `./auto/start-local` and test it out! 64 | 65 | ### Test queries: 66 | 67 | ``` 68 | curl -H "Accept: application/json" -X POST -d "{\"name\": \"\", \"synopsis\": \"\"}" http://localhost:9200/movies 69 | 70 | curl -H "Accept: application/json" -X POST -d "{\"name\": \"Space Jam\", \"synopsis\": \"A movie about basketball\"}" http://localhost:9200/movies 71 | 72 | curl http://localhost:9200/movies/2 73 | ``` 74 | -------------------------------------------------------------------------------- /src/main/scala/com/reagroup/appliedscala/urls/fetchenrichedmovie/README.md: -------------------------------------------------------------------------------- 1 | ## GET movies/id/?enriched=true 2 | 3 | Let's add an endpoint to fetch a movie, which is enriched with some metascore info from the OMDB API. 4 | 5 | ### 1. Intro to OMDB 6 | 7 | Go to [http://www.omdbapi.com/](http://www.omdbapi.com/), scroll down to _By ID or Title_. 8 | 9 | We are going to search for a movie using the `?t` query parameter. If we go to [http://www.omdbapi.com/?t=Titanic&apikey=7f9b5b06](http://www.omdbapi.com/?t=Titanic&apikey=7f9b5b06), we will see the following response: 10 | 11 | ``` 12 | { 13 | Title: "Titanic", 14 | Year: "1997", 15 | Rated: "PG-13", 16 | Released: "19 Dec 1997", 17 | Metascore: "75" 18 | ... 19 | ... 20 | ... 21 | } 22 | ``` 23 | 24 | We want to extract the `Metascore` and return it along with our `Movie`. 25 | 26 | ### 2. `Metascore` - decoder (exercise) 27 | 28 | We can now implement a `Decoder` instance to convert a `Json` response from OMDB to a `Metascore`. 29 | 30 | _**Complete exercise**_ 31 | 32 | _**Run unit test: `MetascoreSpec`**_ 33 | 34 | ### 3. `Http4sMetascoreRepository` (exercise) 35 | 36 | This has mostly been implemented for you. We use Http4s' `Uri` type to encode the `movieName` so spaces become `%20`, for instance, and then we make a request using an Http4s HTTP Client. 37 | 38 | Hint: We want to start by converting the `String` in the response body into a `Metascore`. For the purpose of this exercise, let's convert any failures from Circe into a `None`. 39 | 40 | _**Complete exercise**_ 41 | 42 | _**Run unit test: `Http4sMetascoreRepositorySpec`**_ 43 | 44 | ### 4. `FetchEnrichedMovieService` (exercise) 45 | 46 | Moving on to the `Service`, we can see it has access to _two_ functions. The first one is to fetch a `Movie` and the second is to fetch a `Metascore`. More concretely, the first is the Postgresql database call and the second one is the OMDB API call. 47 | 48 | For the purpose of this exercise, if we get no `Metascore`, we want to error. 49 | 50 | _**Complete exercise**_ 51 | 52 | _**Run unit test: `FetchEnrichedMovieServiceSpec`**_ 53 | 54 | ### 5. `FetchEnrichedMovieController` and `Encoder[EnrichedMovie]` (exercise) 55 | 56 | Again, this is not much different than what we've seen. If we have `Some(enrichedMovie)` we want to return `Ok(...)`. If we have `None`, we want to return `NotFound()`. If we have a `Left`, we want to call the `ErrorHandler`. 57 | 58 | If you try to convert an `EnrichedMovie` to `Json` using `.asJson`, you will get a compilation error. This is because we have not created the `Encoder` instance for `EnrichedMovie`. 59 | 60 | Work on the `Controller` and also the `Encoder`. You will have to create your own custom encoder this time because we want to return a flat `Json`, even though `EnrichedMovie` is a nested structure. 61 | 62 | _**Complete exercise**_ 63 | 64 | _**Run unit test: `FetchEnrichedMovieControllerSpec`**_ 65 | 66 | ### 6. Review the wiring in `AppRuntime` and update `AppRoutes` (exercise) 67 | 68 | In `AppRuntime`, `FetchEnrichedMovieController` has been constructed using `FetchEnrichedMovieService`, and passed into `AppRoutes`. 69 | 70 | `AppRoutes` has an unused handler called `fetchEnrichedMovieHandler`. Update the `GET /movies/{id}` route to check for the existence of a `?enriched=true` query parameter and call `fetchEnrichedMovieHandler` if the query parameter exists. 71 | 72 | You can pattern match on a query parameter as such: 73 | 74 | ``` 75 | case GET -> Root / "movies" / LongVar(id) :? OptionalEnrichedMatcher(maybeEnriched) => ??? 76 | ``` 77 | 78 | _**Run unit test: `AppRoutesSpec`**_ 79 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # applied-scala 2 | 3 | [![Build Status](https://travis-ci.org/realestate-com-au/applied-scala.svg?branch=master)](https://travis-ci.org/github/realestate-com-au/applied-scala) 4 | 5 | ## Getting Started 6 | 7 | Similar to [Intro to Scala](https://github.com/wjlow/intro-to-scala#pre-requisites) 8 | 9 | 1. Skip this step if you have already done Intro to Scala on your current machine. If you're going to use [IntelliJ IDEA](https://www.jetbrains.com/idea/download/) (Community edition is fine), you need to install Java 11 even if you have a newer version of Java installed. 10 | 11 | ``` 12 | brew tap AdoptOpenJDK/openjdk 13 | brew cask install adoptopenjdk11 14 | ``` 15 | 16 | 2. Before the course, please run the following: 17 | 18 | ``` 19 | git clone git@github.com:realestate-com-au/applied-scala.git 20 | cd applied-scala 21 | ./auto/test 22 | ./auto/start-local 23 | ``` 24 | 25 | This should start the app. Note that the tests should be failing at this point. 26 | 27 | 3. Now test this out in a new tab. 28 | 29 | ``` 30 | curl http://localhost:9200/movies 31 | ``` 32 | 33 | You should get back `[{"name":"Titanic"}]`. Now press `ctrl+c` in the previous tab to shut down the app. 34 | 35 | 4. Open up the project in IntelliJ IDEA and make sure it all compiles. Now you're ready to go! 36 | 37 | ## Open up SBT 38 | 39 | Using Docker 40 | ``` 41 | ./auto/sbt 42 | ``` 43 | 44 | or 45 | 46 | Using portable SBT 47 | ``` 48 | ./sbt 49 | ``` 50 | 51 | ## Run test 52 | 53 | ``` 54 | ./auto/test 55 | ``` 56 | 57 | ## How to start app 58 | 59 | ``` 60 | ./auto/start-local 61 | ``` 62 | 63 | ## Suggested Format 64 | 65 | ### Day 1 66 | 67 | - IO Exercises 68 | - Http4s overview + Endpoint 1: Hello World 69 | - Circe Exercises 70 | - [Code walkthrough: GET all movies (no exercises)](./src/main/scala/com/reagroup/appliedscala/urls/fetchallmovies/README.md) 71 | - [Endpoint 2: GET movie](./src/main/scala/com/reagroup/appliedscala/urls/fetchmovie/README.md) 72 | - [Endpoint 3: GET movie?enriched=true](./src/main/scala/com/reagroup/appliedscala/urls/fetchenrichedmovie/README.md) 73 | 74 | ### Day 2 75 | 76 | - Validated Exercises 77 | - [Endpoint 4: POST movie](./src/main/scala/com/reagroup/appliedscala/urls/savemovie/README.md) 78 | - [Endpoint 5: POST movie/id/review](./src/main/scala/com/reagroup/appliedscala/urls/savereview/README.md) 79 | - Wrap up 80 | 81 | ## Further reading 82 | 83 | - [FAQ](docs/faq.md) 84 | - [Scala Refresher](docs/refresher.md) 85 | 86 | ## Test queries 87 | 88 | Fetch all movies 89 | ``` 90 | $ curl http://localhost:9200/movies 91 | ``` 92 | 93 | Fetch movie 94 | ``` 95 | $ curl http://localhost:9200/movies/1 96 | ``` 97 | 98 | Fetch enriched movie 99 | 100 | ``` 101 | $ curl http://localhost:9200/movies/1?enriched=true 102 | ``` 103 | 104 | Save movie 105 | 106 | 1. Successful save 107 | ``` 108 | $ curl -H "Accept: application/json" -X POST -d "{\"name\": \"Cars 3\", \"synopsis\": \"Great movie about cars\"}" http://localhost:9200/movies 109 | ``` 110 | 111 | 2. Validation errors 112 | ``` 113 | $ curl -H "Accept: application/json" -X POST -d "{\"name\": \"\", \"synopsis\": \"\"}" http://localhost:9200/movies 114 | ``` 115 | 116 | Save review 117 | 118 | 1. Successful save 119 | ``` 120 | $ curl -H "Accept: application/json" -X POST -d "{\"author\": \"Jack\", \"comment\": \"Great movie huh\"}" http://localhost:9200/movies/1/reviews 121 | ``` 122 | 123 | 2. Validation errors 124 | 125 | ``` 126 | $ curl -H "Accept: application/json" -X POST -d "{\"author\": \"\", \"comment\": \"\"}" http://localhost:9200/movies/1/reviews 127 | ``` 128 | -------------------------------------------------------------------------------- /src/main/scala/com/reagroup/appliedscala/AppRuntime.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.appliedscala 2 | 3 | import cats.effect.{Clock, IO} 4 | import cats.implicits._ 5 | import com.reagroup.appliedscala.config.Config 6 | import com.reagroup.appliedscala.urls.repositories.{Http4sMetascoreRepository, PostgresqlRepository} 7 | import com.reagroup.appliedscala.urls.fetchallmovies.{FetchAllMoviesController, FetchAllMoviesService} 8 | import com.reagroup.appliedscala.urls.fetchenrichedmovie.{FetchEnrichedMovieController, FetchEnrichedMovieService} 9 | import com.reagroup.appliedscala.urls.fetchmovie.{FetchMovieController, FetchMovieService} 10 | import com.reagroup.appliedscala.urls.savemovie.{SaveMovieController, SaveMovieService} 11 | import com.reagroup.appliedscala.urls.savereview.{SaveReviewController, SaveReviewService} 12 | import org.http4s._ 13 | import org.http4s.client.Client 14 | 15 | class AppRuntime(config: Config, httpClient: Client[IO]) { 16 | 17 | /** 18 | * This is the repository that talks to Postgresql 19 | */ 20 | private val pgsqlRepo = PostgresqlRepository(config.databaseConfig) 21 | 22 | private val http4sMetascoreRepo = new Http4sMetascoreRepository(httpClient, config.omdbApiKey) 23 | 24 | /** 25 | * This is where we instantiate our `Service` and `Controller` for each endpoint. 26 | * You will need to implement a similar block for the `fetchmovies` endpoint when you work on it later. 27 | * The rest of the endpoints have been completed for you. 28 | */ 29 | private val fetchAllMoviesController: FetchAllMoviesController = { 30 | val fetchAllMoviesService: FetchAllMoviesService = new FetchAllMoviesService(pgsqlRepo.fetchAllMovies) 31 | new FetchAllMoviesController(fetchAllMoviesService.fetchAll) 32 | } 33 | 34 | /** 35 | * Construct a `FetchMovieController` by first constructing a `FetchMovieService` using the `pgsqlRepo` from above. 36 | * You need to use the `new` keyword in order to call the constructor of a class. 37 | * Refer to the construction of `fetchAllMoviesController` as a hint. 38 | * 39 | * After constructing a `FetchMovieController`, pass it into `AppRoutes` at the bottom of this file to get it 40 | * all wired up correctly. 41 | */ 42 | private val fetchMovieController: FetchMovieController = { 43 | new FetchMovieController(_ => ???) // Construct a `FetchMovieService`, then fill this out 44 | } 45 | 46 | private val fetchEnrichedMovieController: FetchEnrichedMovieController = { 47 | val fetchEnrichedMovieService = new FetchEnrichedMovieService(pgsqlRepo.fetchMovie, http4sMetascoreRepo.apply) 48 | new FetchEnrichedMovieController(fetchEnrichedMovieService.fetch) 49 | } 50 | 51 | private val saveMovieController: SaveMovieController = { 52 | val saveMovieService = new SaveMovieService(pgsqlRepo.saveMovie) 53 | new SaveMovieController(saveMovieService.save) 54 | } 55 | 56 | private val saveReviewController: SaveReviewController = { 57 | val saveReviewService = new SaveReviewService(pgsqlRepo.saveReview, pgsqlRepo.fetchMovie) 58 | new SaveReviewController(saveReviewService.save) 59 | } 60 | 61 | private val appRoutes = new AppRoutes( 62 | fetchAllMoviesHandler = fetchAllMoviesController.fetchAll, 63 | fetchMovieHandler = _ => IO(Response[IO](status = Status.NotImplemented)), // Fill this out after constructing `FetchMovieController` 64 | fetchEnrichedMovieHandler = fetchEnrichedMovieController.fetch, 65 | saveMovieHandler = saveMovieController.save, 66 | saveReviewHandler = saveReviewController.save 67 | ) 68 | 69 | /* 70 | * All routes that make up the application are exposed by AppRuntime here. 71 | */ 72 | def routes: HttpApp[IO] = HttpApp((req: Request[IO]) => appRoutes.openRoutes(req).getOrElse(Response[IO](status = Status.NotFound))) 73 | 74 | } 75 | -------------------------------------------------------------------------------- /src/test/scala/com/reagroup/exercises/circe/CirceExercisesSpec.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.exercises.circe 2 | 3 | import org.specs2.mutable.Specification 4 | import CirceExercises._ 5 | import io.circe.{DecodingFailure, Json, ParsingFailure} 6 | import io.circe.syntax._ 7 | import io.circe.literal._ 8 | 9 | class CirceExercisesSpec extends Specification { 10 | 11 | "strToJson" should { 12 | 13 | "parse valid Json" in { 14 | val json = json"""{"name": "scala"}""" 15 | val errOrJson = strToJson(json.noSpaces) 16 | errOrJson must_=== Right(json) 17 | } 18 | 19 | "return error for invalid Json" in { 20 | val errOrJson = strToJson("""{"scala"}""") 21 | errOrJson must beLeft 22 | } 23 | 24 | } 25 | 26 | "encodePerson" should { 27 | 28 | "convert Person to Json" in { 29 | val person = Person("scala", 20) 30 | val actual = encodePerson(person) 31 | val expected = Json.obj("name" -> "scala".asJson, "age" -> 20.asJson) 32 | 33 | actual must_=== expected 34 | } 35 | 36 | } 37 | 38 | "encodePersonDifferently" should { 39 | "convert Person to Json" in { 40 | val person = Person("scala", 20) 41 | val actual = encodePersonDifferently(person) 42 | val expected = Json.obj("different_name" -> "scala".asJson, "different_age" -> 20.asJson) 43 | 44 | actual must_=== expected 45 | } 46 | } 47 | 48 | "encodePersonSemiAuto" should { 49 | 50 | "convert Person to Json" in { 51 | val person = Person("scala", 20) 52 | val actual = encodePersonSemiAuto(person) 53 | val expected = Json.obj("name" -> "scala".asJson, "age" -> 20.asJson) 54 | 55 | actual must_=== expected 56 | } 57 | 58 | } 59 | 60 | "decodePerson" should { 61 | 62 | "convert valid Json to Person" in { 63 | val json = Json.obj("name" -> "scala".asJson, "age" -> 20.asJson) 64 | val errOrPerson = decodePerson(json) 65 | 66 | errOrPerson must_=== Right(Person("scala", 20)) 67 | } 68 | 69 | "convert invalid Json to error" in { 70 | val json = Json.obj("foo" -> "bar".asJson) 71 | val errOrPerson = decodePerson(json) 72 | 73 | errOrPerson must beLeft 74 | } 75 | 76 | } 77 | 78 | "decodePersonSemiAuto" should { 79 | 80 | "convert valid Json to Person" in { 81 | val json = Json.obj("name" -> "scala".asJson, "age" -> 20.asJson) 82 | val errOrPerson = decodePersonSemiAuto(json) 83 | 84 | errOrPerson must_=== Right(Person("scala", 20)) 85 | } 86 | 87 | "convert invalid Json to error" in { 88 | val json = Json.obj("foo" -> "bar".asJson) 89 | val errOrPerson = decodePersonSemiAuto(json) 90 | 91 | errOrPerson must beLeft 92 | } 93 | 94 | } 95 | 96 | "strToPerson" should { 97 | 98 | "convert valid Json String to Person" in { 99 | val jsonStr = Json.obj("name" -> "scala".asJson, "age" -> 20.asJson).noSpaces 100 | val errOrPerson = strToPerson(jsonStr) 101 | 102 | errOrPerson must_=== Right(Person("scala", 20)) 103 | } 104 | 105 | "convert invalid Json String to ParsingFailure" in { 106 | val invalidJsonStr = "..." 107 | val errOrPerson = strToPerson(invalidJsonStr) 108 | 109 | errOrPerson match { 110 | case Left(ParsingFailure(_, _)) => ok 111 | case other => ko(s"Expected ParsingFailure, but received: $other") 112 | } 113 | } 114 | 115 | "convert valid Json String that doesn't contain correct info to DecodingFailure" in { 116 | val invalidJsonStr = """{"name": 12345}""" 117 | val errOrPerson = strToPerson(invalidJsonStr) 118 | 119 | errOrPerson match { 120 | case Left(DecodingFailure(_, _)) => ok 121 | case other => ko(s"Expected DecodingFailure, but received: $other") 122 | } 123 | } 124 | 125 | } 126 | 127 | } 128 | -------------------------------------------------------------------------------- /src/test/scala/com/reagroup/exercises/io/IOExercisesSpec.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.exercises.io 2 | 3 | import cats.effect.IO 4 | import cats.effect.unsafe.implicits.global 5 | import com.reagroup.exercises.io.IOExercises._ 6 | import org.specs2.mutable.Specification 7 | 8 | class IOExercisesSpec extends Specification { 9 | 10 | "immediatelyExecutingIO" should { 11 | "return an IO that would return number 43" in { 12 | val result = immediatelyExecutingIO() 13 | 14 | result.unsafeRunSync() === 43 15 | } 16 | } 17 | 18 | "helloWorld" should { 19 | "not execute immediately" in { 20 | val logger = new TestLogger 21 | helloWorld(logger) 22 | 23 | logger.loggedMessages === List.empty 24 | } 25 | 26 | "return an IO that would log 'hello world' using the `logger` provided" in { 27 | val logger = new TestLogger 28 | val result = helloWorld(logger) 29 | 30 | result.unsafeRunSync() 31 | logger.loggedMessages.toList === List("hello world") 32 | } 33 | } 34 | 35 | "alwaysFailingTask" should { 36 | "return an IO containing an Exception" in { 37 | alwaysFailingTask().attempt.unsafeRunSync() match { 38 | case Left(_: Exception) => ok 39 | case otherwise => ko(s"Expected a Left(Exception()) but received a $otherwise") 40 | } 41 | } 42 | } 43 | 44 | "logMessageOrFailIfEmpty" should { 45 | "run `logger` if `msg` is not empty" in { 46 | val logger = new TestLogger 47 | val msg = "message" 48 | 49 | val program = logMessageOrFailIfEmpty(msg, logger) 50 | logger.loggedMessages === List.empty 51 | 52 | program.unsafeRunSync() 53 | 54 | logger.loggedMessages.toList === List(msg) 55 | 56 | } 57 | 58 | "return AppException if `msg` is empty" in { 59 | val logger = new TestLogger 60 | val msg = "" 61 | val result = logMessageOrFailIfEmpty(msg, logger).attempt.unsafeRunSync() 62 | 63 | result === Left(AppException("Log must not be empty")) && logger.loggedMessages.toList === List() 64 | } 65 | } 66 | 67 | "getCurrentTempInF" should { 68 | "convert the current temperature to Fahrenheit" in { 69 | val currentTemp = IO.pure(Celsius(100)) 70 | val result = getCurrentTempInF(currentTemp).unsafeRunSync() 71 | 72 | result === Fahrenheit(212) 73 | } 74 | } 75 | 76 | "getCurrentTempInFAgain" should { 77 | "convert the current temperature to Fahrenheit using an external converter" in { 78 | val currentTemp = IO.pure(Celsius(100)) 79 | val converter = (c: Celsius) => IO(Fahrenheit(c.value * 9 / 5 + 32) 80 | ) 81 | val result = getCurrentTempInFAgain(currentTemp, converter).unsafeRunSync() 82 | 83 | result === Fahrenheit(212) 84 | } 85 | } 86 | 87 | "showCurrentTempInF" should { 88 | "return the current temperature in a sentence" in { 89 | val currentTemp = IO.pure(Celsius(100)) 90 | val converter = (c: Celsius) => IO(Fahrenheit(c.value * 9 / 5 + 32)) 91 | val result = showCurrentTempInF(currentTemp, converter).unsafeRunSync() 92 | 93 | result === "The temperature is 212" 94 | } 95 | 96 | "return an error if the converter fails" in { 97 | val currentTemp = IO.pure(Celsius(100)) 98 | val error = new Throwable("error") 99 | val converter: Celsius => IO[Fahrenheit] = _ => IO.raiseError(error) 100 | val result = showCurrentTempInF(currentTemp, converter).unsafeRunSync() 101 | 102 | result === error.getMessage 103 | } 104 | } 105 | 106 | "mkUsernameThenPrint" should { 107 | "print a username if it is not empty" in { 108 | val username = "scalauser" 109 | val logger = new TestLogger 110 | 111 | mkUsernameThenPrint(username, logger).unsafeRunSync() 112 | 113 | logger.loggedMessages.toList === List(username) 114 | } 115 | 116 | "return UserNameError if the username is empty" in { 117 | val username = "" 118 | val logger = new TestLogger 119 | 120 | val result = mkUsernameThenPrint(username, logger).attempt.unsafeRunSync() 121 | 122 | result === Left(UsernameError("Username cannot be empty")) 123 | } 124 | } 125 | 126 | "explain" should { 127 | "write logs in the correct order" in { 128 | val logger = new TestLogger 129 | 130 | explain(logger).unsafeRunSync() 131 | logger.loggedMessages.toList ==== List("executing step 1", "executing step 2", "executing step 3") 132 | } 133 | 134 | "should not write logs if the IO is not run" in { 135 | val logger = new TestLogger 136 | 137 | explain(logger) 138 | logger.loggedMessages.toList ==== List.empty 139 | } 140 | } 141 | 142 | "execute" should { 143 | "run an IO program" in { 144 | val logger = new TestLogger 145 | val result = helloWorld(logger) 146 | 147 | execute(result) 148 | 149 | logger.loggedMessages.toList === List("hello world") 150 | } 151 | } 152 | 153 | } 154 | 155 | -------------------------------------------------------------------------------- /docs/refresher.md: -------------------------------------------------------------------------------- 1 | # Intro to Scala Refresher 2 | 3 | You are expected to have completed [Intro to Scala](https://github.com/wjlow/intro-to-scala) in some form before attending this course. 4 | 5 | This document serves as a refresher for the Intro to Scala material. 6 | 7 | Check out the [cheat sheet in Intro to Scala for a more comprehensive rundown](https://github.com/wjlow/intro-to-scala/blob/master/cheat-sheet.md). 8 | 9 | ## Functions 10 | 11 | [Definition](https://github.com/wjlow/intro-to-scala/blob/master/cheat-sheet.md#what-are-pure-functions) 12 | 13 | ### What isn't a function: 14 | 15 | ```scala 16 | def updateRobot(robot: Robot, command: Command): Unit 17 | ``` 18 | 19 | This function presumably mutates the state of `robot` based on what `command` is. You can make this assumption based on the return type of `Unit`, which often indicates an unadvertised side-effect. 20 | 21 | Calling this function multiple times on the same `robot` gives you a different program. 22 | 23 | ### What is a function: 24 | 25 | ```scala 26 | def updateRobot(robot: Robot, command: Command): Robot 27 | ``` 28 | 29 | This function presumably creates a value of type `Robot` based on the value of `robot` and `command`. 30 | 31 | Calling this function multiple times on the same `robot` _always_ gives you the same program. 32 | 33 | ## Algebraic Data Types (ADTs) 34 | 35 | ```scala 36 | sealed trait UserId // data type 37 | 38 | case class SignedInUserId(value: String) extends UserId // data constructor 39 | 40 | case class AnonymousUserId(value: String) extends UserId // data constructor 41 | ``` 42 | 43 | Here is an ADT that represents a valid `UserId`. 44 | 45 | We often create safe constructors because not all `String`s are valid `UserId`s. 46 | 47 | ```scala 48 | def mkUserId(str: String): Option[UserId] = 49 | if (str.length == 10) Some(SignedInUserId(str)) 50 | else if (str.length == 20) Some(AnonymousUserId(str)) 51 | else None 52 | ``` 53 | 54 | ### Pattern matching on ADTs 55 | 56 | We pattern match on the _constructors_ of the ADTs. 57 | 58 | ```scala 59 | def describeUserId(userId: UserId): String = 60 | userId match { 61 | case SignedInUserId(value) => s"Signed in user with id: $value" 62 | case AnonymousUserId(value) => s"Anonymous user with id: $value" 63 | } 64 | ``` 65 | 66 | ## Option 67 | 68 | ```scala 69 | sealed trait Option[A] 70 | case class Some(a: A) extends Option[A] 71 | case object None extends Option[Nothing] 72 | ``` 73 | 74 | Pattern match on the _constructors_ 75 | 76 | ```scala 77 | maybeNumber match { 78 | case Some(num) => s"You got a number, and it is: $num" 79 | case None => "You do not have a number" 80 | } 81 | ``` 82 | 83 | We use `Option` to represent _non-existence_. Think of it as the functional equivalent of `null` (Java), `nil` (Ruby) or `undefined` (JavaScript). 84 | 85 | ## Either 86 | 87 | ```scala 88 | sealed trait Either[E, A] 89 | case class Right(a: A) extends Either[Nothing, A] 90 | case class Left(e: E) extends Either[E, Nothing] 91 | ``` 92 | 93 | Pattern match on the _constructors_ 94 | 95 | ```scala 96 | errorOrNumber match { 97 | case Right(num) => s"You got a number, and it is: $num" 98 | case Left(err) => s"You do not have a number, because of some error: ${err.getMessage}" 99 | } 100 | ``` 101 | 102 | By convention, we use `Either` for handling errors. The `Left` type would have some sort of `Error` type and the `Right` type would have some sort of success value. 103 | 104 | Think of `Either` as the functional equivalent of raising or throwing an exception. Instead of `throw SQLError`, we would return `Left(SqlError)`. 105 | 106 | ## map 107 | 108 | For converting the "inner type": 109 | 110 | ```scala 111 | F[A].map(A => B) // F[B] 112 | ``` 113 | 114 | For example: 115 | 116 | ```scala 117 | val maybePerson: Option[Person] = findPerson(id) 118 | maybePerson.map(person => person.firstName) // Option[FirstName] 119 | ``` 120 | 121 | ```scala 122 | val errorOrPerson: Either[Error, Person] = fetchPerson(id) 123 | errorOrPerson.map(person => person.firstName) // Either[Error, FirstName] 124 | ``` 125 | 126 | [Further reading](https://github.com/wjlow/intro-to-scala/blob/master/cheat-sheet.md#1-functor) 127 | 128 | ## flatMap 129 | 130 | For chaining multiple operations resulting in the same structure: 131 | 132 | ```scala 133 | F[A].flatMap(A => F[B]) // F[B] 134 | ``` 135 | 136 | For example: 137 | 138 | ```scala 139 | val maybePerson: Option[Person] = findPerson(id) 140 | maybePerson.flatMap(person => fetchJob(person.jobId)) // Option[Job] 141 | ``` 142 | 143 | ```scala 144 | val errorOrUserId: Either[Error, UserId] = parseUserId("abc123") 145 | errorOrUserId.flatMap(userId => fetchPerson(userId)) // Either[Error, Person] 146 | ``` 147 | 148 | [Further reading](https://github.com/wjlow/intro-to-scala/blob/master/cheat-sheet.md#3-monad) 149 | 150 | ## for-comprehension 151 | 152 | To chain multiple `flatMap`s and `map`s, a lot of people prefer using for-comprehension. 153 | 154 | ```scala 155 | for { 156 | userId <- parseUserId("abc123") 157 | person <- fetchPerson(userId) 158 | job <- fetchJob(person.jobId) 159 | } yield job.description 160 | 161 | // Either[Error, JobDescription] 162 | ``` 163 | 164 | This is equivalent to: 165 | 166 | ```scala 167 | parseUserId("abc123") 168 | .flatMap(userId => fetchPerson(userId) 169 | .flatMap(person => fetchJob(person.jobId) 170 | .map(job => job.description))) 171 | ``` 172 | 173 | Every expression in the for-comprehension must return the same outer structure, e.g. `Option` or `Either[Error, ?]`. 174 | 175 | Every line but the final line is a `flatMap` and the final line is a `map`. 176 | -------------------------------------------------------------------------------- /src/main/scala/com/reagroup/exercises/circe/CirceExercises.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.exercises.circe 2 | 3 | import io.circe.Decoder.Result 4 | import io.circe._ 5 | import io.circe.syntax._ 6 | 7 | /** 8 | * Circe (pronounced SUR-see, or KEER-kee in classical Greek, or CHEER-chay in Ecclesiastical Latin) is a JSON library for Scala. 9 | * 10 | * We like Circe as opposed to other libraries because it is functional, type-safe and very idiomatic. 11 | * It integrates very well with the Cats ecosystem. 12 | * 13 | * For more comprehensive docs on Circe: 14 | * https://circe.github.io/circe/ 15 | * 16 | * There are 3 parts to these exercises. 17 | * 18 | * 1. Parsing (`String => Json`) 19 | * 2. Encoding (`A => Json`) 20 | * 3. Decoding (`Json => A`) 21 | */ 22 | object CirceExercises { 23 | 24 | /** 25 | * Json Parsing 26 | * 27 | * Hint: `parser.parse` is already in scope (imported through `io.circe._`) 28 | * 29 | * Why is the return type an `Either`? 30 | */ 31 | def strToJson(str: String): Either[ParsingFailure, Json] = { 32 | ??? 33 | } 34 | 35 | /** 36 | * Try make a syntax error in the following Json document and compile. 37 | * What happens? 38 | */ 39 | val validJson: Json = { 40 | import io.circe.literal._ 41 | 42 | json""" 43 | { 44 | "someKey": "someValue", 45 | "anotherKey": "anotherValue" 46 | } 47 | """ 48 | } 49 | 50 | case class Person(name: String, age: Int) 51 | 52 | /** 53 | * Defining encoders and decoders in the companion object means that Scala will always be able to find them. 54 | * 55 | * Note: they may be "shadowed" by a higher priority implicit 56 | */ 57 | object Person { 58 | 59 | /** 60 | * Create an `Encoder` instance for `Person` by implementing the `apply` method below. 61 | * 62 | * Make `personEncoder` an `implicit` to avoid having to pass the `Encoder` instance 63 | * into `asJson` explicitly. 64 | * 65 | * Bonus content (if you have time): 66 | * 67 | * You can read the code below as "Person is an instance of the Encoder type class" 68 | * 69 | * More info on type classes: 70 | * 71 | * - https://typelevel.org/cats/typeclasses.html 72 | * - https://www.parsonsmatt.org/2017/01/07/how_do_type_classes_differ_from_interfaces.html 73 | */ 74 | implicit val personEncoder: Encoder[Person] = Encoder { (p: Person) => 75 | ??? 76 | } 77 | 78 | /** 79 | * Sometimes you might want several encoders for the same type. 80 | * 81 | * Why can't we define this as implicit as well? How would Scala know which one to pick? 82 | */ 83 | val differentPersonEncoder: Encoder[Person] = Encoder { (p: Person) => 84 | Json.obj( 85 | "different_name" -> p.name.asJson, 86 | "different_age" -> p.age.asJson 87 | ) 88 | } 89 | } 90 | 91 | /** 92 | * Scala will look for an implicit `Encoder[Person]` in the following places: 93 | * 94 | * - The current scope (current method, class, file) 95 | * - Imports 96 | * - The companion object of `Encoder` 97 | * - The companion object of `Person` (bingo!) 98 | */ 99 | def encodePerson(person: Person): Json = { 100 | person.asJson 101 | } 102 | 103 | /** 104 | * Use `differentPersonEncoder` explicitly to encode the person 105 | */ 106 | def encodePersonDifferently(person: Person): Json = { 107 | person.asJson(???) 108 | } 109 | 110 | /** 111 | * Sick of writing custom encoders? You can use "semiauto derivation" 112 | * to create an `Encoder` instance for you using a Scala feature called macros. 113 | * 114 | * The downside to this is the keys of your `Json` are now tightly coupled with 115 | * how you have named the fields inside `Person` 116 | * 117 | * Hint: Use `deriveEncoder` 118 | * 119 | * For more comprehensive examples: 120 | * https://circe.github.io/circe/codecs/semiauto-derivation.html 121 | */ 122 | def encodePersonSemiAuto(person: Person): Json = { 123 | import io.circe.generic.semiauto._ 124 | 125 | implicit val personEncoder: Encoder[Person] = ??? 126 | person.asJson 127 | } 128 | 129 | /** 130 | * Decoding 131 | */ 132 | 133 | /** 134 | * Remember: `Result[A]` is an alias for `Either[DecodingFailure, A]` 135 | * 136 | * Question: Why is the return type an `Either`? 137 | * 138 | * Construct a `Decoder` instance for `Person`, that uses the `HCursor` to 139 | * navigate through the `Json`. 140 | * 141 | * Use the provided `HCursor` to navigate through the `Json`, and try to 142 | * create an instance of `Person`. 143 | * 144 | * Hint: Use `cursor.downField("name")` to navigate to the `"name"` field. 145 | * `cursor.downField("name").as[String]` will navigate to the `"name"` field 146 | * and attempt to decode the value as a `String`. 147 | * 148 | * Alternatively, you can use `cursor.get[String]("name")` to do the same thing. 149 | * 150 | * Once you have retrieved the `name` and `age`, construct a `Person`! 151 | * 152 | * For more comprehensive cursor docs: 153 | * https://circe.github.io/circe/api/io/circe/HCursor.html 154 | * 155 | * For more comprehensive examples: 156 | * https://circe.github.io/circe/codecs/custom-codecs.html 157 | */ 158 | def decodePerson(json: Json): Either[DecodingFailure, Person] = { 159 | import cats.implicits._ 160 | 161 | implicit val personDecoder: Decoder[Person] = new Decoder[Person] { 162 | override def apply(cursor: HCursor): Result[Person] = ??? 163 | } 164 | // note: a lot of boilerplate can be removed. Try pressing alt-enter with your 165 | // cursor over "new Decoder[Person]" above. This works because Decoder is a trait with 166 | // a single abstract method. 167 | 168 | // This says "Turn this Json to a Person" 169 | json.as[Person] 170 | } 171 | 172 | /** 173 | * You can use "semiauto derivation" for decoders too. 174 | * 175 | * Hint: Use deriveDecoder 176 | */ 177 | def decodePersonSemiAuto(json: Json): Either[DecodingFailure, Person] = { 178 | import io.circe.generic.semiauto._ 179 | 180 | implicit val personDecoder: Decoder[Person] = ??? 181 | 182 | json.as[Person] 183 | } 184 | 185 | /** 186 | * Parse and then decode 187 | * 188 | * Hint: Use `parser.decode`, which does both at the same time. 189 | */ 190 | def strToPerson(str: String): Either[Error, Person] = { 191 | import io.circe.generic.semiauto._ 192 | 193 | implicit val personDecoder: Decoder[Person] = ??? 194 | 195 | ??? 196 | } 197 | 198 | } 199 | -------------------------------------------------------------------------------- /src/main/scala/com/reagroup/exercises/io/IOExercises.scala: -------------------------------------------------------------------------------- 1 | package com.reagroup.exercises.io 2 | 3 | import cats.effect.IO 4 | 5 | /** 6 | * These exercises are repurposed from https://github.com/lukestephenson/monix-adventures 7 | * 8 | * A value of type `IO[A]` may return a value of type `A` (or fail) when you execute it. It implies that there is a side-effect 9 | * that needs to be carried out before you may or may not get a value of type `A`. 10 | * 11 | * Unfortunately, by looking at the type signature alone you do not know precisely what side-effect will be carried out. 12 | * 13 | * Here is some comprehensive documentation on `IO`: 14 | * https://typelevel.org/cats-effect/docs/2.x/datatypes/io 15 | */ 16 | object IOExercises { 17 | 18 | /** 19 | * Create an IO which returns the number 43 and eagerly (rather than lazily) evaluates the argument. 20 | * 21 | * Hint: You want to look for a function in IO with the type signature A => IO[A]. 22 | * Here is some relevant documentation https://typelevel.org/cats-effect/docs/2.x/datatypes/io#describing-effects 23 | */ 24 | def immediatelyExecutingIO(): IO[Int] = 25 | ??? 26 | 27 | /** 28 | * Create an IO which when executed logs “hello world” (using `logger`) 29 | * 30 | * Remember this is merely a description of logging, nothing has been executed yet. 31 | * 32 | * Hint: You want to look for a function in IO with the type signature (=> A) => IO[A]. 33 | * This means that the input will be lazily evaluated. 34 | * 35 | * Note: By "injecting" `logger` as a dependency to this function, we are able to use a test logger in our unit test 36 | * instead of relying on a mocking framework. 37 | */ 38 | def helloWorld(logger: String => Unit): IO[Unit] = 39 | ??? 40 | 41 | /** 42 | * Difference between `IO.apply` and `IO.pure`: 43 | * 44 | * You want to use `IO.apply` to wrap anything that is a side-effect. If you write `IO.pure(println("hello"))`, the print will occur 45 | * immediately and you will have no control over when you want to run it. If the side-effect throws an exception, it terminates 46 | * your program instead of being caught in the `IO`. 47 | * 48 | * Use `IO.pure` for values that are not side-effects. 49 | * 50 | * Using `IO.apply` will always work, but understanding the distinction is important when you want to 51 | * take your FP knowledge past this course into the next level. 52 | */ 53 | 54 | /** 55 | * An IO is a description of a side-effect, ande side-effects can potentially fail. So, we need to ba 56 | * able to describe failures. 57 | * 58 | * Create an IO which always fails with a `new Exception()` 59 | * 60 | * Do NOT use `throw` 61 | * 62 | * Hint: https://typelevel.org/cats-effect/docs/2.x/datatypes/io#raiseerror 63 | */ 64 | def alwaysFailingTask(): IO[Unit] = 65 | ??? 66 | 67 | /** 68 | * This is a data type that represents an exception in our program. 69 | */ 70 | case class AppException(msg: String) extends Exception 71 | 72 | /** 73 | * If `msg` is empty, create a failing IO using `AppException` with the following error: 74 | * `AppException("Log must not be empty")` 75 | * 76 | * If `msg` is not empty, log out the message using the `logger` 77 | */ 78 | def logMessageOrFailIfEmpty(msg: String, logger: String => Unit): IO[Unit] = 79 | ??? 80 | 81 | /** 82 | * We're going to work with temperature next. We start off by creating tiny types for `Fahrenheit` and `Celsius`. 83 | * By doing this, we can differentiate between the two easily. 84 | */ 85 | case class Fahrenheit(value: Int) 86 | 87 | case class Celsius(value: Int) 88 | 89 | /** 90 | * You're gonna need this for the next exercise. 91 | */ 92 | private def cToF(c: Celsius): Fahrenheit = Fahrenheit(c.value * 9 / 5 + 32) 93 | 94 | /** 95 | * Create an IO which gets the current temperature in Celsius and if successful, converts it to Fahrenheit 96 | * using `cToF` defined above. 97 | */ 98 | def getCurrentTempInF(getCurrentTemp: IO[Celsius]): IO[Fahrenheit] = 99 | ??? 100 | 101 | /** 102 | * Suppose the Celsius to Fahrenheit conversion is complex so we have decided to refactor it out to a remote 103 | * microservice. 104 | * 105 | * Similar to the previous exercise, create an IO which gets the current temperature in Celsius and if successful, 106 | * converts it to Fahrenheit by using the remote service call `converter`. 107 | * 108 | * Again, our remote service call is passed in as an input argument so we can easily unit test this function 109 | * without the need for a mocking framework. 110 | */ 111 | def getCurrentTempInFAgain(getCurrentTemp: IO[Celsius], converter: Celsius => IO[Fahrenheit]): IO[Fahrenheit] = 112 | ??? 113 | 114 | 115 | /** 116 | * Using what we just wrote above, we will convert the result into a `String` describing the temperature, 117 | * or in the case of a failure, we report the error as a `String`. 118 | * 119 | * Note that every IO has potential of failure. 120 | * Try defer error handling until the very end of your program (here!). 121 | * We want to convert this IO into an IO containing an Either. 122 | * 123 | * If successful, this program should return `"The temperature is xyz"` 124 | * 125 | * If unsuccessful, this program should return the error's message (use `.getMessage`). 126 | * 127 | * Hint: https://typelevel.org/cats-effect/docs/2.x/datatypes/io#attempt 128 | */ 129 | def showCurrentTempInF(currentTemp: IO[Celsius], converter: Celsius => IO[Fahrenheit]): IO[String] = 130 | ??? 131 | 132 | /** 133 | * `UsernameError` and `Username` are tiny types we are going to use for the next exercise. 134 | */ 135 | case class UsernameError(value: String) extends Exception 136 | 137 | case class Username(value: String) 138 | 139 | /** 140 | * You will need this function in the next exercise 141 | */ 142 | private def mkUsername(username: String): Either[UsernameError, Username] = 143 | if (username.nonEmpty) Right(Username(username)) else Left(UsernameError("Username cannot be empty")) 144 | 145 | /** 146 | * Use `mkUsername` to create a `Username` and if successful print the username, otherwise fail with a UsernameError. 147 | */ 148 | def mkUsernameThenPrint(username: String, logger: String => Unit): IO[Unit] = 149 | ??? 150 | 151 | 152 | /** 153 | * What is the output of the following program? 154 | * Is it different to what you expected? 155 | * 156 | * Change it so that it outputs the following when run: 157 | * > executing step 1 158 | * > executing step 2 159 | * > executing step 3 160 | */ 161 | def explain(logger: String => Unit): IO[Unit] = { 162 | IO(logger("executing step 1")) 163 | IO(logger("executing step 2")) 164 | IO(logger("executing step 3")) 165 | } 166 | 167 | /** 168 | * Finally, we want to learn how to execute an IO. We are not going to need to do this when writing a REST API however, 169 | * the library will take care of the IO for you. 170 | * 171 | * Hint: https://typelevel.org/cats-effect/docs/2.x/datatypes/io#unsaferunsync 172 | */ 173 | def execute[A](io: IO[A]): A = 174 | ??? 175 | 176 | } 177 | -------------------------------------------------------------------------------- /docs/faq.md: -------------------------------------------------------------------------------- 1 | # Cats 2 | 3 | ## Should only cats-effects be used from the cats libraries? Are others considered good/bad? 4 | 5 | Most libraries under the [Typelevel](https://typelevel.org/) family of libraries work well with Cats and are well written. Cats Effect is one of them. It is good for doing effectful programming (working with side-effects, etc.). Monix Task is another good one but it has a lot more features that we do not need in this course. 6 | 7 | # IO 8 | 9 | ## Should Logging be in IO? 10 | 11 | 100% yes. Otherwise you are going to log while you're constructing your program, instead of logging when the program runs. 12 | 13 | ## What is the difference between `IO.pure` and `IO.apply`? 14 | 15 | Short answer: If you have an expression that can throw exceptions (all side-effecting calls, e.g. `println`), use `IO.apply`. For everything else, use `IO.pure` (e.g. `IO.pure(123)`). 16 | 17 | Long answer: Scala is a strict language, which means that the arguments are evaluated before they are passed into the functions. If you use `IO.pure` for an expression that may throw an exception, e.g. `IO.pure(println("abc"))`, the `println` will be run before it is passed into the `IO`. This means it can also throw an exception outside of the `IO` and terminate your program! You need to use `IO.apply` to "suspend/delay" the expression. 18 | 19 | So shouldn't we just use `IO.apply` for everything? If you did - you'd be fine. But they both come from different abstractions and understanding the difference will allow you to take your FP knowledge to the next level (not covered in this course, sorry!). 20 | 21 | `pure` is a function that is defined on all data types that have an `Applicative` instance. `delay` function that is defined on the `Sync` instance, which is a much more powerful abstraction than `Applicative`. The `Sync` instance of `IO` uses `IO.apply` to implement `delay`. 22 | 23 | When you start programming to typeclasses instead of concrete data types, knowing the difference allows you to write code that can be tested more easily. 24 | 25 | This blog post by Ken Scambler briefly touches upon this: [The worst thing in our Scala code: Futures](https://www.rea-group.com/blog/the-worst-thing-in-our-scala-code-futures/) 26 | 27 | # http4s 28 | 29 | ## Does http4s follow a reactive programming model or does it block? 30 | 31 | http4s is built on top of the fs2 ("functional streams for Scala") library. fs2 uses Java's NIO2 library for TCP/IP connections, which has AsynchronousSocketChannel and AsynchronousServerSocketChannel classes which have methods that can accept a callback or return a Future. 32 | 33 | So it's not reactive in the same sense as ReactiveX or similar libraries, but it does use non-blocking I/O, and working with fs2's Stream type is similar to working with Observables. 34 | 35 | # Misc 36 | 37 | ## What's wrong with mocks? 38 | 39 | [Ken Scambler - To Kill a Mockingtest](https://www.rea-group.com/blog/to-kill-a-mockingtest/) 40 | 41 | [Ken Scambler - Mocks and Stubs from MelbJVM](https://www.youtube.com/watch?v=EaxDl5NPuCA) 42 | 43 | ## Why don't we use a dependency injection framework instead of having to create everything manually? 44 | 45 | In short, we don't want our app to take a few minutes to start up, and then fail because it cannot find a dependency! We want these problems detected at compile time. 46 | 47 | This talk by Ken Scambler may give you some more insight. 48 | 49 | [Ken Scambler - Responsible DI](https://www.youtube.com/watch?v=YMII3Lki9uo) 50 | 51 | ## When should we use implicits? 52 | 53 | There are two good usages of implicits. 54 | 55 | 1. When creating a typeclass instance. 56 | 57 | ``` 58 | implicit val personEncoder: Encoder[Person] = ... 59 | ``` 60 | 61 | 2. When creating extension methods. You rarely have to do so unless you're writing a library. Extension methods add methods to pre-existing types, for instance being able to do `.asJson` or a `String`. 62 | 63 | ## What is a `Kleisli`? 64 | 65 | `Kleisli` is a data type that takes 3 type parameters, `F[_], A, B`. It is a function `A => F[B]`. 66 | 67 | Some concrete examples? 68 | 69 | `Kleisli[IO, UserId, User]` is the same as `UserId => IO[User]` 70 | `Kleisli[Option, PropertyId, Property]` is the same as `PropertyId => Option[Property]` 71 | 72 | Why does it even exist? It's because there are useful combinators/functions that has been implemented on Kleisli. 73 | 74 | ## What are Functor, Applicative and Monad? 75 | 76 | [Read here](https://github.com/wjlow/intro-to-scala/blob/master/cheat-sheet.md#what-are-functor-applicative-and-monad) 77 | 78 | ## Implicits 79 | 80 | Use implicit parameters only for typeclasses. 81 | 82 | Example: 83 | 84 | - Monad 85 | - Functor 86 | - Encoder 87 | - Decoder 88 | 89 | Don't use implicits because you're too lazy to pass an argument in explicitly.​ 90 | 91 | [Further reading](https://docs.scala-lang.org/tutorials/FAQ/finding-implicits.html) 92 | 93 | ## Monoids - Addition / Subtraction of numbers 94 | 95 | Subtraction of numbers is NOT a monoid 96 | `1 - 0` != `0 - 1` 97 | 98 | Addition of negative numbers IS still a monoid! 99 | 100 | `-1 + 0` = `0 + -1` = `-1` 101 | 102 | `-1 + (-2 + -3)` = `(-1 + -2) + -3` 103 | `-1 + -5` = `-3 + -3` 104 | `-6` = `-6` 105 | 106 | # IO 107 | 108 | ## Http4s types 109 | 110 | _What is IO[Response[IO]]?_ 111 | 112 | The inner `Response[IO]` contains the response `body` which has the type `EntityBody[IO]` which is an alias for `Stream[IO, Byte]` (`Stream` here belongs to a library called `fs2`). This `Stream` can then be converted to an `IO[...]`, which is a pure value _describing_ a potential side-effect. Therefore, the `IO` in `Response[IO]` allows the body to converted to an instance of `IO`. 113 | 114 | As for the outer `IO` wrapping `Response[IO]`, the reason given is it allows the many routes to be more efficiently combined into "one big route" (via a typeclass called `SemigroupK`). This "one big route" functions similarly to the many routes we defined, in that an incoming request will try to be matched to the correct handler. 115 | 116 | You're writing a function that takes a request and returns a response. The function can perform effects (like talking to PostgreSQL and the OMDB API), so we wrap the return type in `IO` so we can represent those effects as values. 117 | But when you're reading the request body, or writing the response body, you might need to perform effects too. 118 | 119 | The example I like to use is the "give me all of the buy listings" from the Listing API. We don't load all the listings into memory and then turn them into bytes. We write one listing at a time, interleaving reading a row from the database with writing its JSON representation into the response. _http4s and fs2 make this very easy._ Because we're still using the database, we need our `IO` type in the `Stream[IO, Byte]`; and then, because the `Response` class contains that `Stream`, `Response` needs `IO` too. 120 | 121 | TL;DR: "outer" `IO`: effects to decide how to respond. "inner" `IO`: effects to produce the response stream. 122 | 123 | # Either 124 | 125 | ## Using .sequence on a List[Either[Error, Int]] 126 | 127 | Say you have List[Either[E, Int]], for example it will be like `[Right(3), Left(error1), Left(error2)]`, when you do sequence you will get Either[E, List[Int]], so what happens to all the errors in the list, are they squashed in some kind way? 128 | 129 | If you open up `./auto/sbt console`, you can test this out: 130 | 131 | ```scala 132 | scala> import cats.implicits._ 133 | import cats.implicits._ 134 | 135 | scala> val list: List[Either[String,Int]] = List(Right(1), Left("error1"), Right(2), Left("error2")) 136 | list: List[Either[String,Int]] = List(Right(1), Left(error1), Right(2)) 137 | 138 | scala> list.sequence 139 | res0: Either[String,List[Int]] = Left(error1) 140 | ``` 141 | 142 | also see: [Further reading](https://typelevel.org/cats/typeclasses/traverse.html#a-note-on-sequencing) 143 | 144 | ## What responsibilities does a Service have? [Repo, Controller] 145 | 146 | _FetchMovieService for example, seems to only serve as a wire-up between controller and repository. What other responsibilities usually go inside a Service?_ 147 | 148 | You may fetch a Listing from a DB, and then fetch the images from an API. The definition of these 2 calls would be in the Service. For the rest of the course, we'll see more code in the Services. 149 | --------------------------------------------------------------------------------