diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index bde27b8..0befd92 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -65,7 +65,7 @@ jobs: - name: Migrate run: csbt flyway/flywayMigrate - name: Lint - run: csbt "scalafmtCheckAll;scalafixAll --check" + run: csbt lint-check - name: Compile run: | csbt "compile; test:compile" diff --git a/build.sbt b/build.sbt index 6db9403..ce2ecfd 100644 --- a/build.sbt +++ b/build.sbt @@ -217,6 +217,9 @@ inThisBuild( ) ) +addCommandAlias("lint-check", "scalafmtCheckAll; scalafixAll --check") +addCommandAlias("lint-run", "scalafmtAll; scalafixAll") + wartremoverErrors in (Compile, compile) ++= Warts.allBut( Wart.Any, diff --git a/src/main/scala/wow/doge/http4sdemo/Jokes.scala b/src/main/scala/wow/doge/http4sdemo/Jokes.scala deleted file mode 100644 index bc695f4..0000000 --- a/src/main/scala/wow/doge/http4sdemo/Jokes.scala +++ /dev/null @@ -1,47 +0,0 @@ -package wow.doge.http4sdemo - -import cats.Applicative -import cats.effect.Sync -import cats.implicits._ -import io.circe.Decoder -import io.circe.Encoder -import io.circe.generic.semiauto._ -import monix.bio.Task -import org.http4s.Method._ -import org.http4s._ -import org.http4s.circe._ -import org.http4s.client.Client -import org.http4s.client.dsl.Http4sClientDsl -import org.http4s.implicits._ - -sealed trait Jokes[F[_]] { - def get: F[Jokes.Joke] -} - -object Jokes { - def apply[F[_]](implicit ev: Jokes[F]): Jokes[F] = ev - - final case class Joke(joke: String) - object Joke { - implicit val jokeDecoder: Decoder[Joke] = deriveDecoder[Joke] - implicit def jokeEntityDecoder[F[_]: Sync]: EntityDecoder[F, Joke] = - jsonOf - implicit val jokeEncoder: Encoder[Joke] = deriveEncoder[Joke] - implicit def jokeEntityEncoder[F[_]: Applicative]: EntityEncoder[F, Joke] = - jsonEncoderOf - } - - final case class JokeError(e: Throwable) extends RuntimeException - - def impl(C: Client[Task]): Jokes[Task] = new Jokes[Task] { - val dsl = new Http4sClientDsl[Task] {} - import dsl._ - def get: Task[Jokes.Joke] = { - C.expect[Joke](GET(uri"https://icanhazdadjoke.com/")) - .adaptError { case t => - JokeError(t) - } // Prevent Client Json Decoding Failure Leaking - } - } - -} diff --git a/src/main/scala/wow/doge/http4sdemo/Main.scala b/src/main/scala/wow/doge/http4sdemo/Main.scala index e250857..c01c804 100755 --- a/src/main/scala/wow/doge/http4sdemo/Main.scala +++ b/src/main/scala/wow/doge/http4sdemo/Main.scala @@ -1,15 +1,38 @@ package wow.doge.http4sdemo +import scala.concurrent.duration.MILLISECONDS + import cats.effect.ExitCode import cats.effect.Resource +import io.odin.Level +import io.odin.consoleLogger +import io.odin.formatter.Formatter +import io.odin.syntax._ import monix.bio.BIOApp +import monix.bio.IO import monix.bio.Task import monix.bio.UIO import slick.jdbc.JdbcProfile object Main extends BIOApp { val profile: JdbcProfile = slick.jdbc.PostgresProfile - def app = for { + val app = for { + startTime <- Resource.liftF(IO.clock.realTime(MILLISECONDS)) + _ <- Resource.liftF(Task(println(""" + | .__ __ __ _____ .___ + | | |___/ |__/ |_______ / | | ______ __| _/____ _____ ____ + | | | \ __\ __\____ \ / | |_/ ___/ ______ / __ |/ __ \ / \ / _ \ + | | Y \ | | | | |_> > ^ /\___ \ /_____/ / /_/ \ ___/| Y Y ( <_> ) + | |___| /__| |__| | __/\____ |/____ > \____ |\___ >__|_| /\____/ + | \/ |__| |__| \/ \/ \/ \/ + """.stripMargin))) + logger <- consoleLogger[Task]( + formatter = Formatter.colorful, + minLevel = Level.Debug + ).withAsync() + _ <- Resource.liftF( + logger.info(s"Starting ${BuildInfo.name}-${BuildInfo.version}") + ) db <- SlickResource("myapp.database") _ <- Resource.liftF(for { config <- JdbcDatabaseConfig.loadFromGlobal("myapp.database") @@ -17,9 +40,10 @@ object Main extends BIOApp { } yield ()) _ <- Resource.liftF( Task.deferAction(implicit s => - Http4sdemoServer.stream(db, profile).compile.drain + new Server(db, profile, logger).stream.compile.drain ) ) + } yield () def run(args: List[String]) = { app diff --git a/src/main/scala/wow/doge/http4sdemo/Http4sdemoServer.scala b/src/main/scala/wow/doge/http4sdemo/Server.scala similarity index 54% rename from src/main/scala/wow/doge/http4sdemo/Http4sdemoServer.scala rename to src/main/scala/wow/doge/http4sdemo/Server.scala index ff267b9..d999d83 100644 --- a/src/main/scala/wow/doge/http4sdemo/Http4sdemoServer.scala +++ b/src/main/scala/wow/doge/http4sdemo/Server.scala @@ -2,6 +2,7 @@ package wow.doge.http4sdemo import cats.implicits._ import fs2.Stream +import io.odin import monix.bio.Task import monix.execution.Scheduler import org.http4s.client.blaze.BlazeClientBuilder @@ -10,36 +11,29 @@ import org.http4s.server.blaze.BlazeServerBuilder import org.http4s.server.middleware.Logger import slick.jdbc.JdbcBackend.DatabaseDef import slick.jdbc.JdbcProfile +import wow.doge.http4sdemo.routes.LibraryRoutes import wow.doge.http4sdemo.services.LibraryDbio import wow.doge.http4sdemo.services.LibraryServiceImpl -object Http4sdemoServer { +final class Server(db: DatabaseDef, p: JdbcProfile, logger: odin.Logger[Task]) { - def stream( - db: DatabaseDef, - p: JdbcProfile - )(implicit s: Scheduler): Stream[Task, Nothing] = { + def stream(implicit s: Scheduler): Stream[Task, Nothing] = { + val logger = io.odin.consoleLogger[Task](formatter = + io.odin.formatter.Formatter.colorful + ) + val log: String => Task[Unit] = str => logger.debug(str) for { client <- BlazeClientBuilder[Task](s).stream - helloWorldAlg = HelloWorld.impl - jokeAlg = Jokes.impl(client) - // Combine Service Routes into an HttpApp. - // Can also be done via a Router if you - // want to extract a segments not checked - // in the underlying routes. - libraryDbio = new LibraryDbio(p) libraryService = new LibraryServiceImpl(p, libraryDbio, db) httpApp = ( - Http4sdemoRoutes.helloWorldRoutes[Task](helloWorldAlg) <+> - Http4sdemoRoutes.jokeRoutes[Task](jokeAlg) <+> - Http4sdemoRoutes.libraryRoutes(libraryService) + new LibraryRoutes(libraryService, logger).routes ).orNotFound - - // With Middlewares in place - finalHttpApp = Logger.httpApp(true, true)(httpApp) - // _ = {finalHttpApp.run(Request.)} - + finalHttpApp = Logger.httpApp( + true, + true, + logAction = log.pure[Option] + )(httpApp) exitCode <- BlazeServerBuilder[Task](s) .bindHttp(8081, "0.0.0.0") .withHttpApp(finalHttpApp) diff --git a/src/main/scala/wow/doge/http4sdemo/SlickResource.scala b/src/main/scala/wow/doge/http4sdemo/SlickResource.scala index 9458913..d98b0a6 100644 --- a/src/main/scala/wow/doge/http4sdemo/SlickResource.scala +++ b/src/main/scala/wow/doge/http4sdemo/SlickResource.scala @@ -6,7 +6,5 @@ import slick.jdbc.JdbcBackend.Database object SlickResource { def apply(confPath: String) = - Resource.make(Task(Database.forConfig(confPath)))(db => - Task(db.source.close()) >> Task(db.close()) - ) + Resource.make(Task(Database.forConfig(confPath)))(db => Task(db.close())) } diff --git a/src/main/scala/wow/doge/http4sdemo/dto/Library.scala b/src/main/scala/wow/doge/http4sdemo/dto/Library.scala index 2b5e34b..d4eb0ae 100644 --- a/src/main/scala/wow/doge/http4sdemo/dto/Library.scala +++ b/src/main/scala/wow/doge/http4sdemo/dto/Library.scala @@ -101,4 +101,5 @@ object BookSearchMode extends Enum[BookSearchMode] { withNameEither(s).leftMap(e => ParseFailure(e.getMessage, e.getMessage)) ) object Matcher extends QueryParamDecoderMatcher[BookSearchMode]("mode") + } diff --git a/src/main/scala/wow/doge/http4sdemo/implicits/package.scala b/src/main/scala/wow/doge/http4sdemo/implicits/package.scala index 6613e27..a55822d 100644 --- a/src/main/scala/wow/doge/http4sdemo/implicits/package.scala +++ b/src/main/scala/wow/doge/http4sdemo/implicits/package.scala @@ -2,6 +2,8 @@ package wow.doge.http4sdemo import scala.util.Try +import io.odin.meta.Position +import io.odin.meta.Render import monix.bio.IO import monix.bio.Task import monix.reactive.Observable @@ -35,4 +37,18 @@ package object implicits { monix.eval.Task.deferAction(implicit s => monix.eval.Task.from(task)) } + implicit final class OdinLoggerExt(private val logger: io.odin.Logger[Task]) + extends AnyVal { + def debugU[M](msg: => M)(implicit render: Render[M], position: Position) = + logger.debug(msg).hideErrors + def infoU[M](msg: => M)(implicit render: Render[M], position: Position) = + logger.info(msg).hideErrors + def traceU[M](msg: => M)(implicit render: Render[M], position: Position) = + logger.trace(msg).hideErrors + def warnU[M](msg: => M)(implicit render: Render[M], position: Position) = + logger.warn(msg).hideErrors + def errorU[M](msg: => M)(implicit render: Render[M], position: Position) = + logger.error(msg).hideErrors + } + } diff --git a/src/main/scala/wow/doge/http4sdemo/Http4sdemoRoutes.scala b/src/main/scala/wow/doge/http4sdemo/routes/LibraryRoutes.scala similarity index 53% rename from src/main/scala/wow/doge/http4sdemo/Http4sdemoRoutes.scala rename to src/main/scala/wow/doge/http4sdemo/routes/LibraryRoutes.scala index 15bad58..e6349db 100755 --- a/src/main/scala/wow/doge/http4sdemo/Http4sdemoRoutes.scala +++ b/src/main/scala/wow/doge/http4sdemo/routes/LibraryRoutes.scala @@ -1,53 +1,29 @@ -package wow.doge.http4sdemo +package wow.doge.http4sdemo.routes -import cats.effect.Sync -import cats.implicits._ import fs2.interop.reactivestreams._ import io.circe.Codec import io.circe.generic.semiauto._ +import io.odin.Logger import monix.bio.IO import monix.bio.Task -import monix.bio.UIO import org.http4s.HttpRoutes import org.http4s.dsl.Http4sDsl import wow.doge.http4sdemo.dto.Book import wow.doge.http4sdemo.dto.BookSearchMode import wow.doge.http4sdemo.dto.BookUpdate import wow.doge.http4sdemo.dto.NewBook +import wow.doge.http4sdemo.implicits._ import wow.doge.http4sdemo.services.LibraryService -object Http4sdemoRoutes { +class LibraryRoutes(libraryService: LibraryService, logger: Logger[Task]) { - def jokeRoutes[F[_]: Sync](J: Jokes[F]): HttpRoutes[F] = { - val dsl = Http4sDsl[F] - import dsl._ - HttpRoutes.of[F] { case GET -> Root / "joke" => - for { - joke <- J.get - resp <- Ok(joke) - } yield resp - } - } - - def helloWorldRoutes[F[_]: Sync](H: HelloWorld[F]): HttpRoutes[F] = { - val dsl = new Http4sDsl[F] {} - import dsl._ - HttpRoutes.of[F] { case GET -> Root / "hello" / name => - for { - greeting <- H.hello(HelloWorld.Name(name)) - resp <- Ok(greeting) - r2 <- BadRequest("Bad request") - } yield r2 - } - } - - def libraryRoutes(libraryService: LibraryService): HttpRoutes[Task] = { + val routes: HttpRoutes[Task] = { val dsl = Http4sDsl[Task] import dsl._ object Value extends QueryParamDecoderMatcher[String]("value") HttpRoutes.of[Task] { - case GET -> Root / "api" / "get" / "book" :? + case GET -> Root / "api" / "books" :? BookSearchMode.Matcher(mode) +& Value(value) => import org.http4s.circe.streamJsonArrayEncoder import io.circe.syntax._ @@ -63,7 +39,7 @@ object Http4sdemoRoutes { } yield res ) - case GET -> Root / "api" / "get" / "books" => + case GET -> Root / "api" / "books" => import org.http4s.circe.streamJsonArrayEncoder import io.circe.syntax._ Task.deferAction(implicit s => @@ -73,67 +49,50 @@ object Http4sdemoRoutes { .toStream[Task] ) res <- Ok(books.map(_.asJson)) - // res <- Ok(streamJsonArrayEncoderOf[Task, Book].(books)) } yield res ) - case GET -> Root / "blah" => Ok().hideErrors - - case GET -> Root / "api" / "get" / "book" / IntVar(id) => + case GET -> Root / "api" / "books" / IntVar(id) => import org.http4s.circe.CirceEntityCodec._ - // import org.http4s.circe.jsonEncoder - // import io.circe.syntax._ for { bookJson <- libraryService.getBookById(id) res <- Ok(bookJson) } yield res - case req @ POST -> Root / "api" / "post" / "book" => + case req @ PUT -> Root / "api" / "books" => import org.http4s.circe.CirceEntityCodec._ for { newBook <- req.as[NewBook] - // .onErrorHandleWith { - // case ParseF - // } res <- libraryService .insertBook(newBook) + .tapError(err => logger.errorU(err.toString)) .flatMap(book => Created(book).hideErrors) - .mapErrorPartialWith { - case LibraryService.EntityDoesNotExist(message) => - BadRequest(message).hideErrors - case LibraryService.EntityAlreadyExists(message) => - BadRequest(message).hideErrors - // case LibraryService.MyError2(_) => Ok().hideErrors - // case C3 => Ok().hideErrors - } + .onErrorHandleWith(_.toResponse) } yield res - case req @ PATCH -> Root / "api" / "update" / "book" / IntVar(id) => + case req @ PATCH -> Root / "api" / "books" / IntVar(id) => import org.http4s.circe.CirceEntityCodec._ for { updateData <- req.as[BookUpdate] res <- libraryService .updateBook(id, updateData) - .flatMap(_ => Ok().hideErrors) - .tapError(err => UIO(println(s"Handled -> ${err.toString}"))) - .mapErrorPartialWith { - case e @ LibraryService.EntityDoesNotExist(message) => - BadRequest(e: LibraryService.Error).hideErrors - // case LibraryService.MyError2(_) => Ok().hideErrors - // case C3 => Ok().hideErrors - } + .flatMap(_ => NoContent().hideErrors) + .tapError(err => logger.errorU(err.toString)) + .onErrorHandleWith(_.toResponse) } yield res - case req @ DELETE -> Root / "api" / "delete" / "book" / IntVar(id) => + case req @ DELETE -> Root / "api" / "books" / IntVar(id) => for { _ <- libraryService.deleteBook(id) res <- Ok() } yield res - case req @ POST -> Root / "api" / "post" / "books" / "read" => + //TODO: use convenience method for decoding json stream + case req @ POST -> Root / "api" / "books" => import org.http4s.circe.CirceEntityCodec.circeEntityDecoder for { - newBook <- req.as[List[Book]] + newBooks <- req.as[List[Book]] + // obs = Observable.fromIterable(newBooks) // book <- libraryService.insertBook(newBook) res <- Ok("blah") } yield res diff --git a/src/main/scala/wow/doge/http4sdemo/services/LibraryService.scala b/src/main/scala/wow/doge/http4sdemo/services/LibraryService.scala index 2b0cca6..b4be40a 100755 --- a/src/main/scala/wow/doge/http4sdemo/services/LibraryService.scala +++ b/src/main/scala/wow/doge/http4sdemo/services/LibraryService.scala @@ -5,6 +5,7 @@ import monix.bio.IO import monix.bio.Task import monix.bio.UIO import monix.reactive.Observable +import org.http4s.dsl.Http4sDsl import slick.jdbc.JdbcBackend import slick.jdbc.JdbcProfile import wow.doge.http4sdemo.dto.Author @@ -22,14 +23,31 @@ object LibraryService { sealed trait Error extends Exception { def message: String override def getMessage(): String = message + def toResponse = { + val dsl = Http4sDsl[Task] + import org.http4s.circe.CirceEntityCodec._ + import dsl._ + implicit val codec = Error.codec + this match { + case e @ LibraryService.EntityDoesNotExist(message) => + NotFound(e: LibraryService.Error).hideErrors + case e @ LibraryService.EntityAlreadyExists(message) => + BadRequest(e: LibraryService.Error).hideErrors + } + } } final case class EntityDoesNotExist(message: String) extends Error final case class EntityAlreadyExists(message: String) extends Error + // final case class MessageBodyError(cause: MessageBodyFailure) extends Error // final case class MyError2(message: String) extends Error // case object C3 extends Error { val message: String = "C3" } object Error { implicit val codec = deriveCodec[Error] + // def convert(e: MessageBodyFailure) = e match { + // case InvalidMessageBodyFailure(details, cause) => () + // case MalformedMessageBodyFailure(details, cause) => () + // } } } diff --git a/src/test/scala/wow/doge/http4sdemo/LibraryControllerSpec.scala b/src/test/scala/wow/doge/http4sdemo/LibraryControllerSpec.scala index bdf32eb..8526e2a 100644 --- a/src/test/scala/wow/doge/http4sdemo/LibraryControllerSpec.scala +++ b/src/test/scala/wow/doge/http4sdemo/LibraryControllerSpec.scala @@ -7,12 +7,14 @@ import monix.bio.UIO import monix.reactive.Observable import org.http4s.Method import org.http4s.Request +import org.http4s.Status import org.http4s.Uri import org.http4s.implicits._ import wow.doge.http4sdemo.MonixBioSuite import wow.doge.http4sdemo.dto.Book import wow.doge.http4sdemo.dto.BookSearchMode import wow.doge.http4sdemo.dto.BookUpdate +import wow.doge.http4sdemo.routes.LibraryRoutes import wow.doge.http4sdemo.services.LibraryService import wow.doge.http4sdemo.services.NoopLibraryService @@ -34,12 +36,12 @@ class LibraryControllerSpec extends MonixBioSuite { } for { _ <- UIO.unit - routes = Http4sdemoRoutes.libraryRoutes(service) + routes = new LibraryRoutes(service, noopLogger).routes res <- routes - .run(Request[Task](Method.GET, uri"/api/get/books")) + .run(Request[Task](Method.GET, uri"/api/books")) .value .hideErrors - body <- res.map(_.as[List[Book]]).sequence + body <- res.traverse(_.as[List[Book]]) _ <- UIO(assertEquals(body, Some(List(book)))) // _ <- logger2.debug(body.toString).hideErrors } yield () @@ -57,15 +59,16 @@ class LibraryControllerSpec extends MonixBioSuite { for { _ <- UIO.unit reqBody = BookUpdate(Some("blah"), None) - routes = Http4sdemoRoutes.libraryRoutes(service) + routes = new LibraryRoutes(service, noopLogger).routes res <- routes .run( - Request[Task](Method.PATCH, Root / "api" / "update" / "book" / "1") + Request[Task](Method.PATCH, Root / "api" / "books" / "1") .withEntity(reqBody) ) .value .hideErrors - body <- res.map(_.as[LibraryService.Error]).sequence + _ <- UIO(assertEquals(res.map(_.status), Some(Status.NotFound))) + body <- res.traverse(_.as[LibraryService.Error]) _ <- UIO( assertEquals( body, @@ -98,10 +101,10 @@ class LibraryControllerSpec extends MonixBioSuite { // logger2 = logger.withConstContext( // Map("Test" -> "get books by author name") // ) - routes = Http4sdemoRoutes.libraryRoutes(service) + routes = new LibraryRoutes(service, noopLogger).routes request = Request[Task]( Method.GET, - Root / "api" / "get" / "book" + Root / "api" / "books" withQueryParams Map( "mode" -> BookSearchMode.AuthorName.entryName, "value" -> "blah" @@ -109,7 +112,7 @@ class LibraryControllerSpec extends MonixBioSuite { ) // _ <- logger2.info(s"Request -> $request") res <- routes.run(request).value.hideErrors - body <- res.map(_.as[List[Book]]).sequence + body <- res.traverse(_.as[List[Book]]) _ <- UIO.pure(body).assertEquals(Some(books)) // _ <- logger2.debug(s"Response body -> $body").hideErrors } yield () @@ -134,10 +137,10 @@ class LibraryControllerSpec extends MonixBioSuite { // logger2 = logger.withConstContext( // Map("Test" -> "get books by book title") // ) - routes = Http4sdemoRoutes.libraryRoutes(service) + routes = new LibraryRoutes(service, noopLogger).routes request = Request[Task]( Method.GET, - Root / "api" / "get" / "book" + Root / "api" / "books" withQueryParams Map( "mode" -> BookSearchMode.BookTitle.entryName, "value" -> "blah" @@ -145,7 +148,7 @@ class LibraryControllerSpec extends MonixBioSuite { ) // _ <- logger2.info(s"Request -> $request") res <- routes.run(request).value.hideErrors - body <- res.map(_.as[List[Book]]).sequence + body <- res.traverse(_.as[List[Book]]) _ <- UIO.pure(body).assertEquals(Some(books)) // _ <- logger2.debug(s"Response body -> $body").hideErrors } yield ()