commit c702bb0773d2600d60f92d81447cbf73db364f7d Author: Rohan Sircar Date: Mon Mar 15 17:00:37 2021 +0530 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0083571 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +*.class +*.log + +# sbt specific +.cache/ +.history/ +.lib/ +dist/* +target/ +lib_managed/ +src_managed/ +project/boot/ +project/plugins/project/ +metals.sbt +.metals +.bloop +.ammonite +.bsp + +# Scala-IDE specific +.scala_dependencies +.worksheet + +.idea/ +.vscode +assets/ +.attach_pid* +hs_err_pid* +*.db \ No newline at end of file diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..ffbdff9 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1 @@ +version = "2.7.4" diff --git a/build.sbt b/build.sbt new file mode 100644 index 0000000..e26ff34 --- /dev/null +++ b/build.sbt @@ -0,0 +1,175 @@ +val Http4sVersion = "0.21.16" +val CirceVersion = "0.13.0" +val MunitVersion = "0.7.20" +val LogbackVersion = "1.2.3" +val MunitCatsEffectVersion = "0.13.0" +val FlywayVersion = "7.5.3" +scalaVersion in ThisBuild := "2.13.4" + +import com.github.tototoshi.sbt.slick.CodegenPlugin.autoImport.{ + slickCodegenDatabasePassword, + slickCodegenDatabaseUrl, + slickCodegenJdbcDriver +} + +import _root_.slick.codegen.SourceCodeGenerator +import _root_.slick.{model => m} + +lazy val databaseUrl = sys.env.getOrElse( + "DB_DEFAULT_URL", + "jdbc:postgresql://localhost:5432/test_db" +) +lazy val databaseUser = sys.env.getOrElse("DB_DEFAULT_USER", "test_user") +lazy val databasePassword = sys.env.getOrElse("DB_DEFAULT_PASSWORD", "password") + +lazy val flyway = (project in file("modules/flyway")) + .enablePlugins(FlywayPlugin) + .settings( + libraryDependencies += "org.flywaydb" % "flyway-core" % FlywayVersion, + flywayLocations := Seq("classpath:db/migration/default"), + flywayUrl := databaseUrl, + flywayUser := databaseUser, + flywayPassword := databasePassword, + flywayBaselineOnMigrate := true + ) + +lazy val root = (project in file(".")) + .enablePlugins(CodegenPlugin) + .settings( + organization := "wow.doge", + name := "http4s-demo", + version := "0.0.1-SNAPSHOT", + scalacOptions ++= Seq( + "-encoding", + "UTF-8", + "-deprecation", + "-feature", + "-language:existentials", + "-language:experimental.macros", + "-language:higherKinds", + "-language:implicitConversions", + "-unchecked", + "-Xlint", + "-Ywarn-numeric-widen", + "-Ymacro-annotations", + //silence warnings for by-name implicits + "-Wconf:cat=lint-byname-implicit:s", + //give errors on non exhaustive matches + "-Wconf:msg=match may not be exhaustive:e", + "-explaintypes" // Explain type errors in more detail. + ), + javacOptions ++= Seq("-source", "11", "-target", "11"), + //format: off + libraryDependencies ++= Seq( + "org.http4s" %% "http4s-blaze-server" % Http4sVersion, + "org.http4s" %% "http4s-blaze-client" % Http4sVersion, + "org.http4s" %% "http4s-circe" % Http4sVersion, + "org.http4s" %% "http4s-dsl" % Http4sVersion, + "io.circe" %% "circe-generic" % CirceVersion, + "org.scalameta" %% "munit" % MunitVersion % Test, + "org.typelevel" %% "munit-cats-effect-2" % MunitCatsEffectVersion % Test, + "ch.qos.logback" % "logback-classic" % LogbackVersion, + "org.scalameta" %% "svm-subs" % "20.2.0", + "co.fs2" %% "fs2-reactive-streams" % "2.5.0" + ), + //format: on + libraryDependencies ++= Seq( + "io.monix" %% "monix" % "3.3.0", + "io.monix" %% "monix-bio" % "1.1.0", + "io.circe" %% "circe-core" % "0.13.0", + "io.circe" %% "circe-generic" % "0.13.0", + "com.softwaremill.sttp.client" %% "core" % "2.2.9", + "com.softwaremill.sttp.client" %% "monix" % "2.2.9", + "com.softwaremill.sttp.client" %% "circe" % "2.2.9", + "com.softwaremill.sttp.client" %% "httpclient-backend-monix" % "2.2.9", + "com.softwaremill.quicklens" %% "quicklens" % "1.6.1", + "com.softwaremill.common" %% "tagging" % "2.2.1", + "com.softwaremill.macwire" %% "macros" % "2.3.6" % "provided", + "com.github.valskalla" %% "odin-monix" % "0.9.1", + "com.github.valskalla" %% "odin-slf4j" % "0.9.1", + "com.github.valskalla" %% "odin-json" % "0.9.1", + "com.github.valskalla" %% "odin-extras" % "0.9.1", + "com.typesafe.scala-logging" %% "scala-logging" % "3.9.2", + "com.lihaoyi" %% "os-lib" % "0.7.1", + "com.beachape" %% "enumeratum" % "1.6.1", + "com.chuusai" %% "shapeless" % "2.3.3", + "com.lihaoyi" %% "sourcecode" % "0.2.1", + "eu.timepit" %% "refined" % "0.9.19", + "com.zaxxer" % "HikariCP" % "3.4.2", + "com.typesafe.slick" %% "slick" % "3.3.2", + "com.typesafe.slick" %% "slick-hikaricp" % "3.3.2", + "com.h2database" % "h2" % "1.4.199", + "org.postgresql" % "postgresql" % "42.2.18", + "com.github.pureconfig" %% "pureconfig" % "0.14.0", + "io.scalaland" %% "chimney" % "0.6.0", + "com.rms.miu" %% "slick-cats" % "0.10.4", + "com.kubukoz" %% "slick-effect" % "0.3.0" + ), + addCompilerPlugin("org.typelevel" %% "kind-projector" % "0.10.3"), + addCompilerPlugin("com.olegpy" %% "better-monadic-for" % "0.3.1"), + ThisBuild / scalafixDependencies += "com.github.liancheng" %% "organize-imports" % "0.4.3", + inThisBuild( + List( + scalaVersion := scalaVersion.value, // 2.11.12, or 2.13.3 + semanticdbEnabled := true, // enable SemanticDB + semanticdbVersion := "4.4.2" // use Scalafix compatible version + ) + ), + testFrameworks += new TestFramework("munit.Framework"), + assemblyMergeStrategy in assembly := { + case PathList("javax", "servlet", xs @ _*) => MergeStrategy.first + case PathList(ps @ _*) if ps.last endsWith ".html" => MergeStrategy.first + case "application.conf" => MergeStrategy.concat + case "unwanted.txt" => MergeStrategy.discard + case x if Assembly.isConfigFile(x) => + MergeStrategy.concat + case PathList("META-INF", xs @ _*) => + (xs map { _.toLowerCase }) match { + case ("manifest.mf" :: Nil) | ("index.list" :: Nil) | + ("dependencies" :: Nil) => + MergeStrategy.discard + case ps @ (x :: xs) + if ps.last.endsWith(".sf") || ps.last.endsWith(".dsa") => + MergeStrategy.discard + case "plexus" :: xs => + MergeStrategy.discard + case "services" :: xs => + MergeStrategy.filterDistinctLines + case ("spring.schemas" :: Nil) | ("spring.handlers" :: Nil) => + MergeStrategy.filterDistinctLines + case _ => MergeStrategy.first // Changed deduplicate to first + } + case PathList(_*) => MergeStrategy.first + } + ) + .settings( + // libraryDependencies ++= Seq( + // "com.zaxxer" % "HikariCP" % "3.4.2", + // "com.typesafe.slick" %% "slick" % "3.3.2", + // "com.typesafe.slick" %% "slick-hikaricp" % "3.3.2", + // "com.h2database" % "h2" % "1.4.199" + // ), + slickCodegenDatabaseUrl := databaseUrl, + slickCodegenDatabaseUser := databaseUser, + slickCodegenDatabasePassword := databasePassword, + slickCodegenDriver := _root_.slick.jdbc.PostgresProfile, + slickCodegenJdbcDriver := "org.postgresql.Driver", + slickCodegenOutputPackage := "wow.doge.http4sdemo.slickcodegen", + slickCodegenExcludedTables := Seq("schema_version"), + slickCodegenCodeGenerator := { (model: m.Model) => + new SourceCodeGenerator(model) { + override def Table = new Table(_) { + override def Column = new Column(_) { + override def rawType = model.tpe match { + case "java.sql.Timestamp" => + "java.time.Instant" // kill j.s.Timestamp + case _ => + super.rawType + } + } + } + } + }, + sourceGenerators in Compile += slickCodegen.taskValue + ) + .dependsOn(flyway) diff --git a/modules/flyway/src/main/resources/db/migration/default/V1__create_users_table.sql b/modules/flyway/src/main/resources/db/migration/default/V1__create_users_table.sql new file mode 100644 index 0000000..8ac8241 --- /dev/null +++ b/modules/flyway/src/main/resources/db/migration/default/V1__create_users_table.sql @@ -0,0 +1,6 @@ +create table "users" ( + "id" VARCHAR(255) PRIMARY KEY NOT NULL, + "email" VARCHAR(1024) NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NULL +); diff --git a/modules/flyway/src/main/resources/db/migration/default/V2__add_user.sql b/modules/flyway/src/main/resources/db/migration/default/V2__add_user.sql new file mode 100644 index 0000000..7113f28 --- /dev/null +++ b/modules/flyway/src/main/resources/db/migration/default/V2__add_user.sql @@ -0,0 +1,6 @@ +INSERT INTO "users" VALUES ( + 'd074bce8-a8ca-49ec-9225-a50ffe83dc2f', + 'myuser@example.com', + (TIMESTAMP '2013-03-26T17:50:06Z'), + (TIMESTAMP '2013-03-26T17:50:06Z') +); diff --git a/modules/flyway/src/main/resources/db/migration/default/V3__create_cars_table.sql b/modules/flyway/src/main/resources/db/migration/default/V3__create_cars_table.sql new file mode 100644 index 0000000..6bcbda0 --- /dev/null +++ b/modules/flyway/src/main/resources/db/migration/default/V3__create_cars_table.sql @@ -0,0 +1,6 @@ +create table "cars" ( + "id" VARCHAR(255) PRIMARY KEY NOT NULL, + "model" VARCHAR(1024) NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NULL +); diff --git a/modules/flyway/src/main/resources/db/migration/default/V4__add_car.sql b/modules/flyway/src/main/resources/db/migration/default/V4__add_car.sql new file mode 100644 index 0000000..783d41c --- /dev/null +++ b/modules/flyway/src/main/resources/db/migration/default/V4__add_car.sql @@ -0,0 +1,6 @@ +INSERT INTO "cars" VALUES ( + 'd074bce8-a8ca-49ec-9225-a50ffe83dc2f', + 'gxxer', + (TIMESTAMP '2013-03-26T17:50:06Z'), + (TIMESTAMP '2013-03-26T17:50:06Z') +); diff --git a/modules/flyway/src/main/resources/db/migration/default/V5__authors_books_table.sql b/modules/flyway/src/main/resources/db/migration/default/V5__authors_books_table.sql new file mode 100644 index 0000000..5dd9ff2 --- /dev/null +++ b/modules/flyway/src/main/resources/db/migration/default/V5__authors_books_table.sql @@ -0,0 +1,12 @@ +create table authors ( + id SERIAL PRIMARY KEY, + name VARCHAR(15) NOT NULL +); + +create table books ( + id SERIAL PRIMARY KEY, + title VARCHAR(50) NOT NULL, + author_id INTEGER NOT NULL, + FOREIGN KEY(author_id) REFERENCES authors(id), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL +); \ No newline at end of file diff --git a/modules/flyway/src/main/resources/db/migration/default/V6__insert_books_and_authors.sql b/modules/flyway/src/main/resources/db/migration/default/V6__insert_books_and_authors.sql new file mode 100644 index 0000000..7d39cbc --- /dev/null +++ b/modules/flyway/src/main/resources/db/migration/default/V6__insert_books_and_authors.sql @@ -0,0 +1,14 @@ +-- create table authors ( +-- id INTEGER PRIMARY KEY NOT NULL, +-- name VARCHAR(15) +-- ); + +-- create table books ( +-- id INTEGER PRIMARY KEY NOT NULL, +-- title VARCHAR(15) NOT NULL, +-- author_id INTEGER NOT NULL, +-- FOREIGN KEY(author_id) REFERENCES authors(id) +-- ); + +INSERT INTO authors (name) VALUES ('Jane Austen'); +INSERT INTO books (title, author_id) VALUES ('Pride and Prejudice', 1); \ No newline at end of file diff --git a/native-image-readme.md b/native-image-readme.md new file mode 100644 index 0000000..9a0c413 --- /dev/null +++ b/native-image-readme.md @@ -0,0 +1,6 @@ +You can build a native-image binary as mentioned in the http4s deployment [section] (https://github.com/drocsid/http4s/blob/docs/deployment/docs/src/main/tut/deployment.md) . You will need to follow the directions there to provide GraalVM / native-image plugin and provide a muslC bundle. Then populate the UseMuslC path with it's location. + +``` +native-image --static -H:UseMuslC="/path.to/muslC" -H:+ReportExceptionStackTraces -H:+AddAllCharsets --allow-incomplete-classpath --no-fallback --initialize-at-build-time --enable-http --enable-https --enable-all-security-services --verbose -jar "./target/scala-2.13/http4s-demo-assembly-0.0.1-SNAPSHOT.jar" http4s-demoBinaryImage +``` + diff --git a/project/build.properties b/project/build.properties new file mode 100644 index 0000000..f9b06db --- /dev/null +++ b/project/build.properties @@ -0,0 +1,2 @@ +sbt.version=1.4.7 + diff --git a/project/plugins.sbt b/project/plugins.sbt new file mode 100644 index 0000000..7c6c372 --- /dev/null +++ b/project/plugins.sbt @@ -0,0 +1,12 @@ +// addSbtPlugin("io.github.davidgregory084" % "sbt-tpolecat" % "0.1.14") +addSbtPlugin("io.spray" % "sbt-revolver" % "0.9.1") +addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.14.10") + +// https://github.com/tototoshi/sbt-slick-codegen +libraryDependencies += "com.h2database" % "h2" % "1.4.196" +libraryDependencies += "org.postgresql" % "postgresql" % "42.2.18" +addSbtPlugin("com.github.tototoshi" % "sbt-slick-codegen" % "1.4.0") +// Database migration +// https://github.com/flyway/flyway-sbt +addSbtPlugin("io.github.davidmweber" % "flyway-sbt" % "7.4.0") +addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.9.23") diff --git a/src/main/resources/META-INF/native-image/wow/doge/http4sdemo/reflect-config.json b/src/main/resources/META-INF/native-image/wow/doge/http4sdemo/reflect-config.json new file mode 100644 index 0000000..d297bd6 --- /dev/null +++ b/src/main/resources/META-INF/native-image/wow/doge/http4sdemo/reflect-config.json @@ -0,0 +1,180 @@ +[ + { + "name": "org.slf4j.impl.StaticLoggerBinder", + "allDeclaredConstructors": true + }, + { + "name": "ch.qos.logback.classic.pattern.DateConverter", + "allDeclaredConstructors": true + }, + { + "name": "ch.qos.logback.classic.pattern.MessageConverter", + "allDeclaredConstructors": true + }, + { + "name": "ch.qos.logback.classic.pattern.ThrowableProxyConverter", + "allDeclaredConstructors": true + }, + { + "name": "ch.qos.logback.classic.pattern.NopThrowableInformationConverter", + "allDeclaredConstructors": true + }, + { + "name": "ch.qos.logback.classic.pattern.ContextNameConverter", + "allDeclaredConstructors": true + }, + { + "name": "ch.qos.logback.core.pattern.color.BoldYellowCompositeConverter", + "allDeclaredConstructors": true + }, + { + "name": "ch.qos.logback.classic.pattern.LoggerConverter", + "allDeclaredConstructors": true + }, + { + "name": "ch.qos.logback.core.pattern.ReplacingCompositeConverter", + "allDeclaredConstructors": true + }, + { + "name": "ch.qos.logback.core.pattern.color.BoldBlueCompositeConverter", + "allDeclaredConstructors": true + }, + { + "name": "ch.qos.logback.core.pattern.color.CyanCompositeConverter", + "allDeclaredConstructors": true + }, + { + "name": "ch.qos.logback.core.pattern.color.RedCompositeConverter", + "allDeclaredConstructors": true + }, + { + "name": "ch.qos.logback.core.pattern.color.WhiteCompositeConverter", + "allDeclaredConstructors": true + }, + { + "name": "ch.qos.logback.classic.pattern.PropertyConverter", + "allDeclaredConstructors": true + }, + { + "name": "ch.qos.logback.classic.pattern.ExtendedThrowableProxyConverter", + "allDeclaredConstructors": true + }, + { + "name": "ch.qos.logback.classic.pattern.RootCauseFirstThrowableProxyConverter", + "allDeclaredConstructors": true + }, + { + "name": "ch.qos.logback.classic.pattern.MethodOfCallerConverter", + "allDeclaredConstructors": true + }, + { + "name": "ch.qos.logback.classic.pattern.LevelConverter", + "allDeclaredConstructors": true + }, + { + "name": "ch.qos.logback.core.pattern.IdentityCompositeConverter", + "allDeclaredConstructors": true + }, + { + "name": "ch.qos.logback.core.pattern.color.BoldWhiteCompositeConverter", + "allDeclaredConstructors": true + }, + { + "name": "ch.qos.logback.classic.pattern.MarkerConverter", + "allDeclaredConstructors": true + }, + { + "name": "ch.qos.logback.core.pattern.color.BoldCyanCompositeConverter", + "allDeclaredConstructors": true + }, + { + "name": "ch.qos.logback.core.pattern.color.BoldMagentaCompositeConverter", + "allDeclaredConstructors": true + }, + { + "name": "ch.qos.logback.classic.pattern.RelativeTimeConverter", + "allDeclaredConstructors": true + }, + { + "name": "ch.qos.logback.core.pattern.color.MagentaCompositeConverter", + "allDeclaredConstructors": true + }, + { + "name": "ch.qos.logback.classic.pattern.ClassOfCallerConverter", + "allDeclaredConstructors": true + }, + { + "name": "ch.qos.logback.classic.pattern.LineOfCallerConverter", + "allDeclaredConstructors": true + }, + { + "name": "ch.qos.logback.classic.pattern.FileOfCallerConverter", + "allDeclaredConstructors": true + }, + { + "name": "ch.qos.logback.core.pattern.color.BoldGreenCompositeConverter", + "allDeclaredConstructors": true + }, + { + "name": "ch.qos.logback.classic.pattern.LocalSequenceNumberConverter", + "allDeclaredConstructors": true + }, + { + "name": "ch.qos.logback.core.pattern.color.YellowCompositeConverter", + "allDeclaredConstructors": true + }, + { + "name": "ch.qos.logback.classic.pattern.ExtendedThrowableProxyConverter", + "allDeclaredConstructors": true + }, + { + "name": "ch.qos.logback.classic.pattern.color.HighlightingCompositeConverter", + "allDeclaredConstructors": true + }, + { + "name": "ch.qos.logback.core.pattern.color.GrayCompositeConverter", + "allDeclaredConstructors": true + }, + { + "name": "ch.qos.logback.classic.pattern.MDCConverter", + "allDeclaredConstructors": true + }, + { + "name": "ch.qos.logback.classic.pattern.ClassOfCallerConverter", + "allDeclaredConstructors": true + }, + { + "name": "ch.qos.logback.core.pattern.color.BoldRedCompositeConverter", + "allDeclaredConstructors": true + }, + { + "name": "ch.qos.logback.core.pattern.color.GreenCompositeConverter", + "allDeclaredConstructors": true + }, + { + "name": "ch.qos.logback.core.pattern.color.BlackCompositeConverter", + "allDeclaredConstructors": true + }, + { + "name": "ch.qos.logback.classic.pattern.ThreadConverter", + "allDeclaredConstructors": true + }, + { + "name": "ch.qos.logback.classic.pattern.LineSeparatorConverter", + "allDeclaredConstructors": true + }, + { + "name": "ch.qos.logback.classic.encoder.PatternLayoutEncoder", + "allPublicMethods":true, + "allDeclaredConstructors": true + }, + { + "name": "ch.qos.logback.core.ConsoleAppender", + "allPublicMethods":true, + "allDeclaredConstructors": true + }, + { + "name": "com.sun.org.apache.xerces.internal.jaxp.SAXParserFactoryImpl", + "allDeclaredConstructors": true + } +] diff --git a/src/main/resources/application.conf b/src/main/resources/application.conf new file mode 100644 index 0000000..31a02a4 --- /dev/null +++ b/src/main/resources/application.conf @@ -0,0 +1,38 @@ + +myapp = { + database = { + driver = org.postgresql.Driver + url = "jdbc:postgresql://localhost:5432/test_db" + user = "test_user" + password = "password" + + // The number of threads determines how many things you can *run* in parallel + // the number of connections determines you many things you can *keep in memory* at the same time + // on the database server. + // numThreads = (core_count (hyperthreading included)) + numThreads = 20 + + // queueSize = ((core_count * 2) + effective_spindle_count) + // on a MBP 13, this is 2 cores * 2 (hyperthreading not included) + 1 hard disk + queueSize = 10 + + // https://blog.knoldus.com/2016/01/01/best-practices-for-using-slick-on-production/ + // make larger than numThreads + queueSize + maxConnections = 20 + + connectionTimeout = 5000 + validationTimeout = 5000 + + # connectionPool = disabled + keepAlive = true + + migrations-table = "flyway_schema_history" + + migrations-locations = [ + # "classpath:example/jdbc" + "classpath:db/migration/default" + ] + } +} + + diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml new file mode 100644 index 0000000..8ea8412 --- /dev/null +++ b/src/main/resources/logback.xml @@ -0,0 +1,16 @@ + + + + true + + [%thread] %highlight(%-5level) %cyan(%logger{15}) - %msg %n + + + + + + diff --git a/src/main/scala/wow/doge/http4sdemo/HelloWorld.scala b/src/main/scala/wow/doge/http4sdemo/HelloWorld.scala new file mode 100644 index 0000000..aa5fb2f --- /dev/null +++ b/src/main/scala/wow/doge/http4sdemo/HelloWorld.scala @@ -0,0 +1,40 @@ +package wow.doge.http4sdemo + +import cats.Applicative +import cats.implicits._ +import io.circe.Encoder +import io.circe.Json +import monix.bio.Task +import org.http4s.EntityEncoder +import org.http4s.circe._ + +trait HelloWorld[F[_]] { + def hello(n: HelloWorld.Name): F[HelloWorld.Greeting] +} + +object HelloWorld { + implicit def apply[F[_]](implicit ev: HelloWorld[F]): HelloWorld[F] = ev + + final case class Name(name: String) extends AnyVal + + /** More generally you will want to decouple your edge representations from + * your internal data structures, however this shows how you can + * create encoders for your data. + */ + final case class Greeting(greeting: String) extends AnyVal + object Greeting { + implicit val greetingEncoder: Encoder[Greeting] = new Encoder[Greeting] { + final def apply(a: Greeting): Json = Json.obj( + ("message", Json.fromString(a.greeting)) + ) + } + implicit def greetingEntityEncoder[F[_]: Applicative] + : EntityEncoder[F, Greeting] = + jsonEncoderOf[F, Greeting] + } + + def impl: HelloWorld[Task] = new HelloWorld[Task] { + def hello(n: HelloWorld.Name): Task[HelloWorld.Greeting] = + Greeting("Hello, " + n.name).pure[Task] + } +} diff --git a/src/main/scala/wow/doge/http4sdemo/Http4sdemoRoutes.scala b/src/main/scala/wow/doge/http4sdemo/Http4sdemoRoutes.scala new file mode 100644 index 0000000..b2b5dca --- /dev/null +++ b/src/main/scala/wow/doge/http4sdemo/Http4sdemoRoutes.scala @@ -0,0 +1,144 @@ +package wow.doge.http4sdemo + +import cats.effect.Sync +import cats.implicits._ +import fs2.interop.reactivestreams._ +import io.circe.Codec +import io.circe.generic.semiauto._ +import monix.bio.Task +import monix.reactive.Observable +import org.http4s.HttpRoutes +import org.http4s.dsl.Http4sDsl +import slick.jdbc.JdbcBackend.DatabaseDef +import slick.jdbc.JdbcProfile +import wow.doge.http4sdemo.dto.Book +import wow.doge.http4sdemo.dto.BookUpdate +import wow.doge.http4sdemo.dto.NewBook +import wow.doge.http4sdemo.services.LibraryService +import wow.doge.http4sdemo.slickcodegen.Tables._ +object Http4sdemoRoutes { + + 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 userRoutes(userService: UserService): HttpRoutes[Task] = { + val dsl = Http4sDsl[Task] + import dsl._ + import org.http4s.circe.CirceEntityCodec._ + HttpRoutes.of[Task] { case GET -> Root / "users" => + Task.deferAction(implicit s => + for { + _ <- Task.unit + users = userService.users.toReactivePublisher.toStream[Task] + res <- Ok(users) + } yield res + ) + } + } + + def libraryRoutes(libraryService: LibraryService): HttpRoutes[Task] = { + val dsl = Http4sDsl[Task] + import dsl._ + HttpRoutes.of[Task] { + case GET -> Root / "api" / "get" / "books" => + import org.http4s.circe.streamJsonArrayEncoder + import io.circe.syntax._ + Task.deferAction(implicit s => + for { + books <- Task.pure( + libraryService.getBooks.toReactivePublisher + .toStream[Task] + ) + res <- Ok(books.map(_.asJson)) + } yield res + ) + + case GET -> Root / "api" / "get" / "book" / IntVar(id) => + // import org.http4s.circe.CirceEntityCodec._ + import org.http4s.circe.jsonEncoder + import io.circe.syntax._ + for { + bookJson <- libraryService.getBookById(id).map(_.asJson) + res <- Ok(bookJson) + } yield res + + case req @ POST -> Root / "api" / "post" / "book" => + import org.http4s.circe.CirceEntityCodec._ + for { + newBook <- req.as[NewBook] + book <- libraryService.insertBook(newBook) + res <- Created(book) + } yield res + + case req @ PATCH -> Root / "api" / "update" / "book" / IntVar(id) => + import org.http4s.circe.CirceEntityCodec._ + for { + updateData <- req.as[BookUpdate] + _ <- libraryService + .updateBook(id, updateData) + .void + .onErrorHandleWith(ex => + Task(println(s"Handled -> ${ex.getMessage}")) + ) + // .mapError(e => new Exception(e)) + res <- Ok() + } yield res + + case req @ DELETE -> Root / "api" / "delete" / "book" / IntVar(id) => + for { + _ <- libraryService.deleteBook(id) + res <- Ok() + } yield res + + case req @ POST -> Root / "api" / "post" / "books" / "read" => + import org.http4s.circe.CirceEntityCodec.circeEntityDecoder + for { + newBook <- req.as[List[Book]] + // book <- libraryService.insertBook(newBook) + res <- Ok("blah") + } yield res + } + } + +} + +case class User(id: String, email: String) +object User { + val tupled = (this.apply _).tupled + // implicit val decoder: Decoder[User] = deriveDecoder + // implicit def entityDecoder[F[_]: Sync]: EntityDecoder[F, User] = + // jsonOf + // implicit val encoder: Encoder[User] = deriveEncoder + // implicit def entityEncoder[F[_]: Applicative]: EntityEncoder[F, User] = + // jsonEncoderOf + implicit val codec: Codec[User] = deriveCodec +} + +class UserService(profile: JdbcProfile, db: DatabaseDef) { + import profile.api._ + def users: Observable[User] = + Observable.fromReactivePublisher( + db.stream(Users.map(u => (u.id, u.email).mapTo[User]).result) + ) + +} diff --git a/src/main/scala/wow/doge/http4sdemo/Http4sdemoServer.scala b/src/main/scala/wow/doge/http4sdemo/Http4sdemoServer.scala new file mode 100644 index 0000000..f379aca --- /dev/null +++ b/src/main/scala/wow/doge/http4sdemo/Http4sdemoServer.scala @@ -0,0 +1,50 @@ +package wow.doge.http4sdemo + +import cats.implicits._ +import fs2.Stream +import monix.bio.Task +import monix.execution.Scheduler +import org.http4s.client.blaze.BlazeClientBuilder +import org.http4s.implicits._ +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.services.LibraryDbio +import wow.doge.http4sdemo.services.LibraryService + +object Http4sdemoServer { + + def stream( + db: DatabaseDef, + p: JdbcProfile + )(implicit s: Scheduler): Stream[Task, Nothing] = { + for { + client <- BlazeClientBuilder[Task](s).stream + helloWorldAlg = HelloWorld.impl + jokeAlg = Jokes.impl(client) + ss = new UserService(p, db) + // 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 LibraryService(p, libraryDbio, db) + httpApp = ( + Http4sdemoRoutes.helloWorldRoutes[Task](helloWorldAlg) <+> + Http4sdemoRoutes.jokeRoutes[Task](jokeAlg) <+> + Http4sdemoRoutes.userRoutes(ss) <+> + Http4sdemoRoutes.libraryRoutes(libraryService) + ).orNotFound + + // With Middlewares in place + finalHttpApp = Logger.httpApp(true, true)(httpApp) + + exitCode <- BlazeServerBuilder[Task](s) + .bindHttp(8081, "0.0.0.0") + .withHttpApp(finalHttpApp) + .serve + } yield exitCode + }.drain +} diff --git a/src/main/scala/wow/doge/http4sdemo/Jokes.scala b/src/main/scala/wow/doge/http4sdemo/Jokes.scala new file mode 100644 index 0000000..bc695f4 --- /dev/null +++ b/src/main/scala/wow/doge/http4sdemo/Jokes.scala @@ -0,0 +1,47 @@ +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 new file mode 100644 index 0000000..c71ee78 --- /dev/null +++ b/src/main/scala/wow/doge/http4sdemo/Main.scala @@ -0,0 +1,31 @@ +package wow.doge.http4sdemo + +import cats.effect.ExitCode +import cats.effect.Resource +import monix.bio.BIOApp +import monix.bio.Task +import monix.bio.UIO +import slick.jdbc.JdbcProfile +import wow.doge.http4sdemo.SlickResource + +object Main extends BIOApp { + val profile: JdbcProfile = _root_.slick.jdbc.H2Profile + def app = for { + db <- SlickResource("myapp.database") + _ <- Resource.liftF(for { + config <- JdbcDatabaseConfig.loadFromGlobal("myapp.database") + _ <- DBMigrations.migrate(config) + } yield ()) + _ <- Resource.liftF( + Task.deferAction(implicit s => + Http4sdemoServer.stream(db, profile).compile.drain + ) + ) + } yield () + def run(args: List[String]) = { + app + .use(_ => Task.never) + .onErrorHandleWith(ex => UIO(ex.printStackTrace())) + .as(ExitCode.Success) + } +} diff --git a/src/main/scala/wow/doge/http4sdemo/Migrate.scala b/src/main/scala/wow/doge/http4sdemo/Migrate.scala new file mode 100644 index 0000000..b221a54 --- /dev/null +++ b/src/main/scala/wow/doge/http4sdemo/Migrate.scala @@ -0,0 +1,98 @@ +package wow.doge.http4sdemo + +import scala.jdk.CollectionConverters._ + +import cats.effect.Sync +import com.typesafe.config.Config +import com.typesafe.config.ConfigFactory +import com.typesafe.scalalogging.LazyLogging +import org.flywaydb.core.Flyway +import org.flywaydb.core.api.Location +import org.flywaydb.core.api.configuration.FluentConfiguration +import pureconfig.ConfigConvert +import pureconfig.ConfigSource +import pureconfig.generic.semiauto._ + +final case class JdbcDatabaseConfig( + url: String, + driver: String, + user: Option[String], + password: Option[String], + migrationsTable: String, + migrationsLocations: List[String] +) + +object JdbcDatabaseConfig { + def loadFromGlobal[F[_]: Sync]( + configNamespace: String + ): F[JdbcDatabaseConfig] = + Sync[F].suspend { + val config = ConfigFactory.load() + load(config.getConfig(configNamespace)) + } + + // Integration with PureConfig + implicit val configConvert: ConfigConvert[JdbcDatabaseConfig] = + deriveConvert + + def load[F[_]: Sync](config: Config): F[JdbcDatabaseConfig] = + Sync[F].delay { + ConfigSource.fromConfig(config).loadOrThrow[JdbcDatabaseConfig] + } + +} + +object DBMigrations extends LazyLogging { + + def migrate[F[_]: Sync](config: JdbcDatabaseConfig): F[Int] = + Sync[F].delay { + logger.info( + "Running migrations from locations: " + + config.migrationsLocations.mkString(", ") + ) + val count = unsafeMigrate(config) + logger.info(s"Executed $count migrations") + count + } + + private def unsafeMigrate(config: JdbcDatabaseConfig): Int = { + val m: FluentConfiguration = Flyway.configure + .dataSource( + config.url, + config.user.orNull, + config.password.orNull + ) + .group(true) + .outOfOrder(false) + .table(config.migrationsTable) + .locations( + config.migrationsLocations + .map(new Location(_)) + .toList: _* + ) + .baselineOnMigrate(true) + + logValidationErrorsIfAny(m) + m.load() + .migrate() + .migrationsExecuted + } + + private def logValidationErrorsIfAny(m: FluentConfiguration): Unit = { + val validated = m + .ignorePendingMigrations(true) + .load() + .validateWithResult() + + if (!validated.validationSuccessful) + for (error <- validated.invalidMigrations.asScala) + logger.warn(s""" + |Failed validation: + | - version: ${error.version} + | - path: ${error.filepath} + | - description: ${error.description} + | - errorCode: ${error.errorDetails.errorCode} + | - errorMessage: ${error.errorDetails.errorMessage} + """.stripMargin.strip) + } +} diff --git a/src/main/scala/wow/doge/http4sdemo/SlickResource.scala b/src/main/scala/wow/doge/http4sdemo/SlickResource.scala new file mode 100644 index 0000000..9458913 --- /dev/null +++ b/src/main/scala/wow/doge/http4sdemo/SlickResource.scala @@ -0,0 +1,12 @@ +package wow.doge.http4sdemo + +import cats.effect.Resource +import monix.bio.Task +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()) + ) +} diff --git a/src/main/scala/wow/doge/http4sdemo/dto/Library.scala b/src/main/scala/wow/doge/http4sdemo/dto/Library.scala new file mode 100644 index 0000000..348f293 --- /dev/null +++ b/src/main/scala/wow/doge/http4sdemo/dto/Library.scala @@ -0,0 +1,82 @@ +package wow.doge.http4sdemo.dto + +import java.time.Instant + +import io.circe.Printer +import io.circe.generic.semiauto._ +import io.scalaland.chimney.dsl._ +import org.http4s.EntityEncoder +import org.http4s.circe.streamJsonArrayEncoderWithPrinterOf +import slick.jdbc.JdbcProfile +import wow.doge.http4sdemo.slickcodegen.Tables + +final case class Book( + id: Int, + title: String, + authorId: Int, + createdAt: Instant +) +object Book { + def tupled = (Book.apply _).tupled + implicit val ec = deriveCodec[Book] + // implicit def streamEntityEncoder[F[_]] + // : EntityEncoder[F, fs2.Stream[F, Book]] = + // streamJsonArrayEncoderWithPrinterOf(Printer.noSpaces) + def fromBooksRow(row: Tables.BooksRow) = row.transformInto[Book] + def fromBooksTableFn(implicit profile: JdbcProfile) = { + import profile.api._ + (b: Tables.Books) => (b.id, b.title, b.authorId, b.createdAt).mapTo[Book] + } + def fromBooksTable(implicit profile: JdbcProfile) = + Tables.Books.map(fromBooksTableFn) + +} + +final case class NewBook(title: String, authorId: Int) +object NewBook { + def tupled = (NewBook.apply _).tupled + implicit val decoder = deriveDecoder[NewBook] + def fromBooksTable(implicit profile: JdbcProfile) = { + import profile.api._ + + Tables.Books.map(b => (b.title, b.authorId).mapTo[NewBook]) + } +} + +final case class BookUpdate(title: Option[String], authorId: Option[Int]) { + import com.softwaremill.quicklens._ + def update(row: Tables.BooksRow): Tables.BooksRow = + row + .modify(_.title) + .setToIfDefined(title) + .modify(_.authorId) + .setToIfDefined(authorId) +} +object BookUpdate { + implicit val decoder = deriveDecoder[BookUpdate] +} + +final case class Author(id: Int, name: String) +object Author { + def tupled = (Author.apply _).tupled + implicit val codec = deriveCodec[Author] + implicit def streamEntityEncoder[F[_]] + : EntityEncoder[F, fs2.Stream[F, Author]] = + streamJsonArrayEncoderWithPrinterOf(Printer.noSpaces) +} + +final case class NewAuthor(name: String) + +final case class BookWithAuthor( + id: Int, + title: String, + author: Author, + createdAt: Instant +) +object BookWithAuthor { + def tupled = (BookWithAuthor.apply _).tupled + implicit val codec = deriveCodec[BookWithAuthor] + implicit def streamEntityEncoder[F[_]] + : EntityEncoder[F, fs2.Stream[F, BookWithAuthor]] = + streamJsonArrayEncoderWithPrinterOf(Printer.noSpaces) +} diff --git a/src/main/scala/wow/doge/http4sdemo/dto/User.scala b/src/main/scala/wow/doge/http4sdemo/dto/User.scala new file mode 100644 index 0000000..6d24798 --- /dev/null +++ b/src/main/scala/wow/doge/http4sdemo/dto/User.scala @@ -0,0 +1,3 @@ +package wow.doge.http4sdemo.dto + +final case class NewUser(email: String) diff --git a/src/main/scala/wow/doge/http4sdemo/implicits/package.scala b/src/main/scala/wow/doge/http4sdemo/implicits/package.scala new file mode 100644 index 0000000..3b998dd --- /dev/null +++ b/src/main/scala/wow/doge/http4sdemo/implicits/package.scala @@ -0,0 +1,31 @@ +package wow.doge.http4sdemo + +import monix.bio.IO +import monix.bio.Task +import monix.reactive.Observable +import slick.dbio.DBIOAction +import slick.dbio.NoStream +import slick.dbio.Streaming +import slick.jdbc.JdbcBackend.DatabaseDef + +package object implicits { + implicit class DatabaseDefExt(private val db: DatabaseDef) extends AnyVal { + def runL[R](a: DBIOAction[R, NoStream, Nothing]) = + Task.deferFuture(db.run(a)) + + def streamO[T](a: DBIOAction[_, Streaming[T], Nothing]) = + Observable.fromReactivePublisher(db.stream(a)) + } + + implicit final class MonixEvalTaskExt[T](private val task: monix.eval.Task[T]) + extends AnyVal { + def toIO = IO.deferAction(implicit s => IO.from(task)) + } + + implicit final class MonixBioTaskExt[T](private val task: monix.bio.Task[T]) + extends AnyVal { + def toTask = + monix.eval.Task.deferAction(implicit s => monix.eval.Task.from(task)) + } + +} diff --git a/src/main/scala/wow/doge/http4sdemo/services/LibraryService.scala b/src/main/scala/wow/doge/http4sdemo/services/LibraryService.scala new file mode 100644 index 0000000..31de27b --- /dev/null +++ b/src/main/scala/wow/doge/http4sdemo/services/LibraryService.scala @@ -0,0 +1,103 @@ +package wow.doge.http4sdemo.services + +import monix.bio.IO +import monix.bio.Task +import slick.jdbc.JdbcBackend +import slick.jdbc.JdbcProfile +import wow.doge.http4sdemo.dto.Book +import wow.doge.http4sdemo.dto.BookUpdate +import wow.doge.http4sdemo.dto.NewBook +import wow.doge.http4sdemo.implicits._ +import wow.doge.http4sdemo.slickcodegen.Tables + +class LibraryService( + profile: JdbcProfile, + dbio: LibraryDbio, + db: JdbcBackend.DatabaseDef +) { + import profile.api._ + + def getBooks = db.streamO(dbio.getBooks) + + def getBookById(id: Int) = db.runL(dbio.getBook(id)) + + // .map(b => + // (b.title, b.authorId, b.createdAt).mapTo[BookUpdateEntity] + // ) + + def updateBook(id: Int, updateData: BookUpdate) = + for { + action <- IO.deferAction { implicit s => + Task(for { + mbRow <- dbio.selectBook(id).result.headOption + updatedRow <- mbRow match { + case Some(value) => + println(s"Original value -> $value") + println(s"Value to be updated with -> $updateData") + DBIO.successful(updateData.update(value)) + case None => + DBIO.failed(new Exception(s"Book with id $id does not exist")) + } + updateAction = dbio.selectBook(id).update(updatedRow) + _ = println(s"SQL = ${updateAction.statements}") + _ <- updateAction + } yield ()) + } + _ <- db.runL(action.transactionally.asTry).flatMap(Task.fromTry) + } yield () + + def deleteBook(id: Int) = db.runL(dbio.deleteBook(id)) + + def insertBook(newBook: NewBook) = + Task.deferFutureAction { implicit s => + val action = for { + id <- dbio.insertBookAndGetId(newBook) + book <- dbio.getBook(id) + } yield book.get + db.run(action.transactionally) + } + + def booksForAuthor(authorId: Int) = + db.streamO(dbio.booksForAuthor(authorId)).map(Book.fromBooksRow) + +} + +class LibraryDbio(val profile: JdbcProfile) { + import profile.api._ + + def getBooks: StreamingDBIO[Seq[Book], Book] = Query.getBooksInner.result + + def insertBookAndGetId(newBook: NewBook): DBIO[Int] = + Query.insertBookGetId += newBook + + def insertBookAndGetBook(newBook: NewBook): DBIO[Book] = + Query.insertBookGetBook += newBook + + def selectBook(id: Int) = Tables.Books.filter(_.id === id) + + def deleteBook(id: Int) = selectBook(id).delete + + def getBook(id: Int) = selectBook(id) + .map(Book.fromBooksTableFn) + .result + .headOption + + def booksForAuthor(authorId: Int) = Query.booksForAuthorInner(authorId).result + + private object Query { + + val getBooksInner = Book.fromBooksTable + + val insertBookGetId = + NewBook.fromBooksTable.returning(Tables.Books.map(_.id)) + + val insertBookGetBook = NewBook.fromBooksTable.returning(getBooksInner) + + def booksForAuthorInner(authorId: Int) = for { + b <- Tables.Books + a <- selectAuthor(authorId) if b.authorId === a.id + } yield b + + def selectAuthor(authorId: Int) = Tables.Authors.filter(_.id === authorId) + } +} diff --git a/src/test/scala/wow/doge/http4sdemo/HelloWorldSpec.scala b/src/test/scala/wow/doge/http4sdemo/HelloWorldSpec.scala new file mode 100644 index 0000000..7afe463 --- /dev/null +++ b/src/test/scala/wow/doge/http4sdemo/HelloWorldSpec.scala @@ -0,0 +1,25 @@ +package wow.doge.http4sdemo + +import cats.effect.IO +import org.http4s._ +import org.http4s.implicits._ +import munit.CatsEffectSuite +class HelloWorldSpec extends CatsEffectSuite { + + // test("HelloWorld returns status code 200") { + // assertIO(retHelloWorld.map(_.status), Status.Ok) + // } + + // test("HelloWorld returns hello world message") { + // assertIO( + // retHelloWorld.flatMap(_.as[String]), + // "{\"message\":\"Hello, world\"}" + // ) + // } + + // private[this] val retHelloWorld: IO[Response[IO]] = { + // val getHW = Request[IO](Method.GET, uri"/hello/world") + // val helloWorld = HelloWorld.impl[IO] + // Http4sdemoRoutes.helloWorldRoutes(helloWorld).orNotFound(getHW) + // } +}