Compare commits

...

28 Commits

Author SHA1 Message Date
Rohan Sircar 01f2676ca3 minor improvements 3 years ago
Rohan Sircar c25f2e0810 make logback stfu + minor edits 3 years ago
Rohan Sircar ddefc489e7 Add app prefix to env variables 3 years ago
Rohan Sircar 4262e045f5 fix mistake in env variable 3 years ago
Rohan Sircar 17f51bf16a
Update ci.yaml 3 years ago
Rohan Sircar 66e4d09b1d
Update ci.yaml 3 years ago
Rohan Sircar b350b4130e revert formatting change 3 years ago
Rohan Sircar bfee57e339 update ci file 3 years ago
Rohan Sircar aaf2ddcd25 Add env variables for app db user and pass 3 years ago
Rohan Sircar 6155857873 Update buildinfo fields 3 years ago
Rohan Sircar 621a3d8b8f move some files around 3 years ago
Rohan Sircar 5543ffe33d Add readme 3 years ago
Rohan Sircar 584e61fdb0 Add linters and update ci file 3 years ago
Rohan Sircar 04a8e8fd2e Add lint stage 3 years ago
Rohan Sircar ddb2e45754 temp commit 3 years ago
Rohan Sircar fa0e598602 Remove commented code 3 years ago
Rohan Sircar 2ff30c8615 Add sbt dynver 3 years ago
Rohan Sircar 5d12bdd3ce
Update ci.yaml 3 years ago
Rohan Sircar f70b77fadb Update CI file 3 years ago
Rohan Sircar e4019c9a63 Change postgres image version in integration test 3 years ago
Rohan Sircar 2527e3198e Update CI 3 years ago
Rohan Sircar 887808508c
Update ci.yaml 3 years ago
Rohan Sircar 3dc945cc70
Update build.yaml (#1) 3 years ago
Rohan Sircar 7dc86fda8e
Update build.yaml 3 years ago
Rohan Sircar 4c71b0f9e8
Update build.yaml 3 years ago
Rohan Sircar c7860c6611 temp commit 3 years ago
Rohan Sircar b6070c9fe6 fix captain-def imagename field 3 years ago
Rohan Sircar 479b571201 temp 3 years ago
  1. 178
      .github/workflows/ci.yaml
  2. 1
      .gitignore
  3. 1
      .scalafix.conf
  4. 1
      README.MD
  5. 182
      build.sbt
  6. BIN
      lib/monix-bio_2.13.jar
  7. 6
      modules/flyway/src/main/resources/db/migration/default/V1__create_users_table.sql
  8. 36
      modules/flyway/src/main/resources/db/migration/default/V1__library_schema.sql
  9. 6
      modules/flyway/src/main/resources/db/migration/default/V2__add_user.sql
  10. 69
      modules/flyway/src/main/resources/db/migration/default/V2__sample_data.sql
  11. 6
      modules/flyway/src/main/resources/db/migration/default/V3__create_cars_table.sql
  12. 6
      modules/flyway/src/main/resources/db/migration/default/V4__add_car.sql
  13. 12
      modules/flyway/src/main/resources/db/migration/default/V5__authors_books_table.sql
  14. 14
      modules/flyway/src/main/resources/db/migration/default/V6__insert_books_and_authors.sql
  15. 26
      modules/test-common/src/main/scala/wow/doge/MonixBioSuite.scala
  16. 13
      project/plugins.sbt
  17. 5
      scripts/.env
  18. 46
      scripts/app.Dockerfile
  19. 7
      scripts/app.sh
  20. 24
      scripts/build.sh
  21. 7
      scripts/curl
  22. 4
      scripts/db.Dockerfile
  23. 6
      scripts/db.sh
  24. 41
      scripts/docker-compose.yml
  25. 11
      scripts/native
  26. 0
      scripts/native-image-readme.md
  27. 4
      scripts/test.Dockerfile
  28. 182
      scripts/wait-for-it.sh
  29. 10
      src/it/resources/logback-test.xml
  30. 112
      src/it/scala/wow/doge/http4sdemo/DatabaseIntegrationTestBase.scala
  31. 121
      src/it/scala/wow/doge/http4sdemo/LibraryServiceSpec.scala
  32. 22
      src/main/resources/META-INF/native-image/wow/doge/http4sdemo/jni-config.json
  33. 301
      src/main/resources/META-INF/native-image/wow/doge/http4sdemo/reflect-config.json
  34. 19
      src/main/resources/META-INF/native-image/wow/doge/http4sdemo/resource-config.json
  35. 2
      src/main/resources/META-INF/native-image/wow/doge/http4sdemo/serialization-config.json
  36. 40
      src/main/resources/application.conf
  37. 144
      src/main/scala/wow/doge/http4sdemo/Http4sdemoRoutes.scala
  38. 50
      src/main/scala/wow/doge/http4sdemo/Http4sdemoServer.scala
  39. 47
      src/main/scala/wow/doge/http4sdemo/Jokes.scala
  40. 31
      src/main/scala/wow/doge/http4sdemo/Main.scala
  41. 1
      src/main/scala/wow/doge/http4sdemo/Migrate.scala
  42. 43
      src/main/scala/wow/doge/http4sdemo/Server.scala
  43. 4
      src/main/scala/wow/doge/http4sdemo/SlickResource.scala
  44. 79
      src/main/scala/wow/doge/http4sdemo/dto/Library.scala
  45. 23
      src/main/scala/wow/doge/http4sdemo/implicits/package.scala
  46. 114
      src/main/scala/wow/doge/http4sdemo/routes/LibraryRoutes.scala
  47. 243
      src/main/scala/wow/doge/http4sdemo/services/LibraryService.scala
  48. 10
      src/test/resources/logback-test.xml
  49. 25
      src/test/scala/wow/doge/http4sdemo/HelloWorldSpec.scala
  50. 156
      src/test/scala/wow/doge/http4sdemo/LibraryControllerSpec.scala

178
.github/workflows/ci.yaml

@ -0,0 +1,178 @@
---
name: Continuous Integration
on:
pull_request:
branches: ["*", series/*]
paths-ignore:
- ".dockerignore"
- ".github/workflow/ci.yml"
- "Changelog.md"
- "Dockerfile"
- "doc/**"
- "docker/**"
- "LICENSE"
- "README.md"
# - "tests/e2e/**"
push:
branches: ["*", series/*]
tags: [v*]
paths-ignore:
- ".dockerignore"
- ".github/workflow/ci.yml"
- "Changelog.md"
- "Dockerfile"
- "doc/**"
- "docker/**"
- "LICENSE"
- "README.md"
# - "tests/e2e/**"
jobs:
build:
name: Build and Test
runs-on: ubuntu-latest
env:
HTTP4S_DEMO_CODEGEN_DB_HOST: localhost
HTTP4S_DEMO_CODEGEN_DB_PORT: 5432
HTTP4S_DEMO_CODEGEN_DB_USER: codegenuser
HTTP4S_DEMO_CODEGEN_DB_PASSWORD: postgres
HTTP4S_DEMO_CODEGEN_DB_NAME: codegendb
services:
postgres:
image: postgres:12-alpine
env:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: codegenuser
POSTGRES_DB: codegendb
# Set health checks to wait until postgres has started
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- name: Check out repository code
uses: actions/checkout@v2
- name: Coursier cache
uses: coursier/cache-action@v6
- name: Setup
uses: olafurpg/setup-scala@v10
with:
java-version: adopt@1.11
- name: Migrate
run: csbt flyway/flywayMigrate
- name: Lint
run: csbt lint-check
- name: Compile
run: |
csbt "compile; test:compile"
- name: Run Unit Tests
run: |
csbt test
- name: Run Integration Tests
run: |
csbt it:test
publish:
name: Publish Release Docker Image
needs: [build]
if: github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v'))
runs-on: ubuntu-latest
env:
HTTP4S_DEMO_CODEGEN_DB_HOST: localhost
HTTP4S_DEMO_CODEGEN_DB_PORT: 5432
HTTP4S_DEMO_CODEGEN_DB_USER: codegenuser
HTTP4S_DEMO_CODEGEN_DB_PASSWORD: postgres
HTTP4S_DEMO_CODEGEN_DB_NAME: codegendb
HTTP4S_DEMO_DOCKER_JAVA_IMAGE: azul/zulu-openjdk-alpine:11-jre-headless
services:
postgres:
image: postgres:12-alpine
env:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: codegenuser
POSTGRES_DB: codegendb
# Set health checks to wait until postgres has started
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- name: Check out repository code
uses: actions/checkout@v2
- name: Coursier cache
uses: coursier/cache-action@v6
- name: Setup
uses: olafurpg/setup-scala@v10
with:
java-version: adopt@1.11
- name: Login to Docker Hub
uses: docker/login-action@v1
with:
username: rohansircar
password: ${{ secrets.DOCKER_LOGIN_PASSWORD }}
- name: Migrate
run: csbt flyway/flywayMigrate
- name: Publish Tag
if: startsWith(github.ref, 'refs/tags/v')
run: |
csbt docker:publish
- name: Publish Latest
if: github.ref == 'refs/heads/main'
env:
DOCKER_PUBLISH_TAG: latest
run: |
csbt docker:publish
publish-devel:
name: Publish Devel Docker Image
needs: [build]
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/devel'
runs-on: ubuntu-latest
env:
HTTP4S_DEMO_CODEGEN_DB_HOST: localhost
HTTP4S_DEMO_CODEGEN_DB_PORT: 5432
HTTP4S_DEMO_CODEGEN_DB_USER: codegenuser
HTTP4S_DEMO_CODEGEN_DB_PASSWORD: postgres
HTTP4S_DEMO_CODEGEN_DB_NAME: codegendb
HTTP4S_DEMO_DOCKER_JAVA_IMAGE: azul/zulu-openjdk-alpine:11-jre-headless
HTTP4S_DEMO_DOCKER_PUBLISH_TAG: devel
services:
postgres:
image: postgres:12-alpine
env:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: codegenuser
POSTGRES_DB: codegendb
# Set health checks to wait until postgres has started
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- name: Check out repository code
uses: actions/checkout@v2
- name: Coursier cache
uses: coursier/cache-action@v6
- name: Setup
uses: olafurpg/setup-scala@v10
with:
java-version: adopt@1.11
- name: Login to Docker Hub
uses: docker/login-action@v1
with:
username: rohansircar
password: ${{ secrets.DOCKER_LOGIN_PASSWORD }}
- name: Migrate
run: csbt flyway/flywayMigrate
- name: Publish
run: |
csbt docker:publish

1
.gitignore

@ -27,3 +27,4 @@ assets/
.attach_pid*
hs_err_pid*
*.db
/app/

1
.scalafix.conf

@ -0,0 +1 @@
rules = [OrganizeImports]

1
README.MD

@ -0,0 +1 @@
Just a scala project for me to experiment with CI and (docker) publishing. Nothing much else to see here.

182
build.sbt

@ -6,21 +6,38 @@ val MunitCatsEffectVersion = "0.13.0"
val FlywayVersion = "7.5.3"
scalaVersion in ThisBuild := "2.13.4"
resolvers in ThisBuild += "jitpack" at "https://jitpack.io"
import com.github.tototoshi.sbt.slick.CodegenPlugin.autoImport.{
slickCodegenDatabasePassword,
slickCodegenDatabaseUrl,
slickCodegenJdbcDriver
}
import _root_.slick.codegen.SourceCodeGenerator
import _root_.slick.{model => m}
import slick.codegen.SourceCodeGenerator
import slick.{model => m}
lazy val codegenDbHost =
sys.env.getOrElse("HTTP4S_DEMO_CODEGEN_DB_HOST", "localhost")
lazy val codegenDbPort =
sys.env.getOrElse("HTTP4S_DEMO_CODEGEN_DB_PORT", "5432")
lazy val codegenDbName =
sys.env.getOrElse("HTTP4S_DEMO_CODEGEN_DB_NAME", "test_db")
lazy val databaseUrl =
s"jdbc:postgresql://$codegenDbHost:$codegenDbPort/$codegenDbName"
lazy val databaseUrl = sys.env.getOrElse(
"DB_DEFAULT_URL",
"jdbc:postgresql://localhost:5432/test_db"
lazy val databaseUser =
sys.env.getOrElse("HTTP4S_DEMO_CODEGEN_DB_USER", "test_user")
lazy val databasePassword =
sys.env.getOrElse("HTTP4S_DEMO_CODEGEN_DB_PASSWORD", "password")
// alpine java docker image for smaller size - "azul/zulu-openjdk-alpine:11-jre-headless"
lazy val dockerJavaImage =
sys.env.getOrElse(
"HTTP4S_DEMO_DOCKER_JAVA_IMAGE",
"openjdk:11-jre-slim-buster"
)
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)
@ -33,12 +50,39 @@ lazy val flyway = (project in file("modules/flyway"))
flywayBaselineOnMigrate := true
)
lazy val testCommon = (project in file("modules/test-common"))
.settings(
libraryDependencies ++= Seq(
"com.github.monix" % "monix-bio" % "0a2ad29275",
"com.github.valskalla" %% "odin-monix" % "0.9.1",
"de.lolhens" %% "munit-tagless-final" % "0.0.1"
)
)
lazy val root = (project in file("."))
.enablePlugins(CodegenPlugin)
.enablePlugins(
CodegenPlugin,
DockerPlugin,
JavaAppPackaging,
AshScriptPlugin,
BuildInfoPlugin,
GitBranchPrompt
)
.configs(IntegrationTest)
.settings(
organization := "wow.doge",
name := "http4s-demo",
version := "0.0.1-SNAPSHOT",
version in Docker := sys.env
.get("HTTP4S_DEMO_DOCKER_PUBLISH_TAG")
.map(s => if (s.startsWith("v")) s.tail else s)
.getOrElse(version.value),
dockerBaseImage := dockerJavaImage,
dockerExposedPorts := Seq(8081),
dockerUsername := Some("rohansircar"),
Defaults.itSettings,
inConfig(IntegrationTest)(scalafixConfigSettings(IntegrationTest)),
buildInfoOptions ++= Seq(BuildInfoOption.ToJson, BuildInfoOption.BuildTime),
buildInfoPackage := "wow.doge.http4sdemo",
scalacOptions ++= Seq(
"-encoding",
"UTF-8",
@ -56,8 +100,16 @@ lazy val root = (project in file("."))
"-Wconf:cat=lint-byname-implicit:s",
//give errors on non exhaustive matches
"-Wconf:msg=match may not be exhaustive:e",
// """-Wconf:site=wow\.doge\.http4sdemo\.slickcodegen\Tables\$:i""",
"-Wconf:msg=early initializers are deprecated:i",
"""-Wconf:site=wow\.doge\.http4sdemo\.slickcodegen\..*:i""",
// """-Wconf:src=target/src_managed/Tables.scala:s""",
"-explaintypes" // Explain type errors in more detail.
),
scalacOptions ++= {
if (insideCI.value) Seq("-Xfatal-warnings")
else Seq.empty
},
javacOptions ++= Seq("-source", "11", "-target", "11"),
//format: off
libraryDependencies ++= Seq(
@ -66,16 +118,17 @@ lazy val root = (project in file("."))
"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,
"org.scalameta" %% "munit" % MunitVersion % "it,test",
"org.typelevel" %% "munit-cats-effect-2" % MunitCatsEffectVersion % "it,test",
"ch.qos.logback" % "logback-classic" % LogbackVersion,
"org.scalameta" %% "svm-subs" % "20.2.0",
"co.fs2" %% "fs2-reactive-streams" % "2.5.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.monix" %% "monix-bio" % "1.1.0",
"com.github.monix" % "monix-bio" % "0a2ad29275",
"io.circe" %% "circe-core" % "0.13.0",
"io.circe" %% "circe-generic" % "0.13.0",
"com.softwaremill.sttp.client" %% "core" % "2.2.9",
@ -103,52 +156,27 @@ lazy val root = (project in file("."))
"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
)
"com.kubukoz" %% "slick-effect" % "0.3.0",
"io.circe" %% "circe-fs2" % "0.13.0",
// "org.scalameta" %% "munit" % "0.7.23" % "it,test",
"de.lolhens" %% "munit-tagless-final" % "0.0.1" % "it,test",
"org.scalameta" %% "munit-scalacheck" % "0.7.23" % "it,test",
"org.scalacheck" %% "scalacheck" % "1.15.3" % "it,test",
"com.dimafeng" %% "testcontainers-scala-munit" % "0.39.3" % IntegrationTest,
"com.dimafeng" %% "testcontainers-scala-postgresql" % "0.39.3" % IntegrationTest
),
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
}
buildInfoKeys := Seq[BuildInfoKey](
name,
version,
scalaVersion,
sbtVersion,
libraryDependencies,
javacOptions,
dockerBaseImage
)
)
.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,
@ -159,10 +187,13 @@ lazy val root = (project in file("."))
slickCodegenCodeGenerator := { (model: m.Model) =>
new SourceCodeGenerator(model) {
override def Table = new Table(_) {
// override def EntityType = new EntityType {
// override def caseClassFinal = true
// }
override def Column = new Column(_) {
override def rawType = model.tpe match {
case "java.sql.Timestamp" =>
"java.time.Instant" // kill j.s.Timestamp
"java.time.LocalDateTime" // kill j.s.Timestamp
case _ =>
super.rawType
}
@ -172,4 +203,41 @@ lazy val root = (project in file("."))
},
sourceGenerators in Compile += slickCodegen.taskValue
)
.dependsOn(flyway)
.dependsOn(flyway, testCommon)
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
addCompilerPlugin("org.typelevel" %% "kind-projector" % "0.10.3"),
addCompilerPlugin("com.olegpy" %% "better-monadic-for" % "0.3.1"),
dynverSeparator := "-"
)
)
addCommandAlias("lint-check", "scalafmtCheckAll; scalafixAll --check")
addCommandAlias("lint-run", "scalafmtAll; scalafixAll")
wartremoverErrors in (Compile, compile) ++=
Warts.allBut(
Wart.Any,
Wart.NonUnitStatements,
Wart.StringPlusAny,
Wart.Overloading,
Wart.PublicInference,
Wart.Nothing,
Wart.Var,
Wart.DefaultArguments,
Wart.OptionPartial,
// Wart.MutableDataStructures,
Wart.ImplicitConversion,
Wart.ImplicitParameter,
Wart.ToString,
Wart.Recursion,
Wart.While,
Wart.ExplicitImplicitTypes,
Wart.ListUnapply
)
wartremoverExcluded += (sourceManaged in Compile).value

BIN
lib/monix-bio_2.13.jar

6
modules/flyway/src/main/resources/db/migration/default/V1__create_users_table.sql

@ -1,6 +0,0 @@
create table "users" (
"id" VARCHAR(255) PRIMARY KEY NOT NULL,
"email" VARCHAR(1024) NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NULL
);

36
modules/flyway/src/main/resources/db/migration/default/V1__library_schema.sql

@ -0,0 +1,36 @@
create table authors (
author_id SERIAL PRIMARY KEY,
author_name VARCHAR(30) NOT NULL
);
CREATE TABLE books (
book_id SERIAL PRIMARY KEY,
isbn VARCHAR(50) UNIQUE NOT NULL,
book_title VARCHAR(30) NOT NULL,
author_id INTEGER REFERENCES authors(author_id) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
);
create table books_store (
books_store_id SERIAL PRIMARY KEY,
book_id INTEGER REFERENCES books(book_id) NOT NULL,
quantity INTEGER NOT NULL
);
create table book_expiry (
book_expiry_id SERIAL PRIMARY KEY,
book_id INTEGER REFERENCES books(book_id) NOT NULL,
discontinued BOOLEAN NOT NULL
);
create table users (
user_id SERIAL PRIMARY KEY NOT NULL,
user_name VARCHAR(30) NOT NULL
);
create table checkouts (
checkout_id SERIAL PRIMARY KEY,
book_id INTEGER REFERENCES books(book_id) NOT NULL,
taken_by INTEGER REFERENCES users(user_id) NOT NULL,
return_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
);

6
modules/flyway/src/main/resources/db/migration/default/V2__add_user.sql

@ -1,6 +0,0 @@
INSERT INTO "users" VALUES (
'd074bce8-a8ca-49ec-9225-a50ffe83dc2f',
'myuser@example.com',
(TIMESTAMP '2013-03-26T17:50:06Z'),
(TIMESTAMP '2013-03-26T17:50:06Z')
);

69
modules/flyway/src/main/resources/db/migration/default/V2__sample_data.sql

@ -0,0 +1,69 @@
insert into
authors (author_name)
values
('Author1');
insert into
authors (author_name)
values
('Author2');
insert into
authors (author_name)
values
('Author3');
insert into
books (isbn, book_title, author_id)
values
('aebwegbwe', 'book1', 3);
insert into
books (isbn, book_title, author_id)
values
('abeqegbqeg', 'book2', 2);
insert into
books (isbn, book_title, author_id)
values
('aebhqeqegq', 'book3', 1);
insert into
books_store (book_id, quantity)
values
(1, 5);
insert into
books_store (book_id, quantity)
values
(2, 3);
insert into
books_store (book_id, quantity)
values
(3, 8);
insert into
book_expiry (book_id, discontinued)
values
(1, false);
insert into
book_expiry (book_id, discontinued)
values
(2, false);
insert into
book_expiry (book_id, discontinued)
values
(3, false);
insert into
users (user_name)
values
('user1');
insert into
users (user_name)
values
('user2');

6
modules/flyway/src/main/resources/db/migration/default/V3__create_cars_table.sql

@ -1,6 +0,0 @@
create table "cars" (
"id" VARCHAR(255) PRIMARY KEY NOT NULL,
"model" VARCHAR(1024) NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NULL
);

6
modules/flyway/src/main/resources/db/migration/default/V4__add_car.sql

@ -1,6 +0,0 @@
INSERT INTO "cars" VALUES (
'd074bce8-a8ca-49ec-9225-a50ffe83dc2f',
'gxxer',
(TIMESTAMP '2013-03-26T17:50:06Z'),
(TIMESTAMP '2013-03-26T17:50:06Z')
);

12
modules/flyway/src/main/resources/db/migration/default/V5__authors_books_table.sql

@ -1,12 +0,0 @@
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
);

14
modules/flyway/src/main/resources/db/migration/default/V6__insert_books_and_authors.sql

@ -1,14 +0,0 @@
-- 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);

26
modules/test-common/src/main/scala/wow/doge/MonixBioSuite.scala

@ -0,0 +1,26 @@
package wow.doge.http4sdemo
import scala.concurrent.Future
import cats.syntax.all._
import io.odin.Logger
import io.odin.fileLogger
import io.odin.syntax._
import monix.bio.Task
import monix.execution.Scheduler
import munit.TestOptions
import java.time.LocalDateTime
trait MonixBioSuite extends munit.TaglessFinalSuite[Task] {
override protected def toFuture[A](f: Task[A]): Future[A] = {
implicit val s = Scheduler.global
f.runToFuture
}
val date = LocalDateTime.now()
val noopLogger = Logger.noop[Task]
val consoleLogger = io.odin.consoleLogger[Task]()
}

13
project/plugins.sbt

@ -1,12 +1,15 @@
// 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("io.spray" % "sbt-revolver" % "0.9.1")
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")
addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.8.0")
addSbtPlugin("com.dwijnand" % "sbt-dynver" % "4.1.1")
addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.4.2")
addSbtPlugin("com.typesafe.sbt" % "sbt-git" % "1.0.0")
addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.10.0")
addSbtPlugin("org.wartremover" % "sbt-wartremover" % "2.4.13")

5
scripts/.env

@ -0,0 +1,5 @@
export POSTGRES_DB=codegen_db
export CODEGEN_DB_HOST=localhost
export CODEGEN_DB_NAME=codegen_db
export CODEGEN_DB_USER=codegen_user
export CODEGEN_DB_PASSWORD=password

46
scripts/app.Dockerfile

@ -0,0 +1,46 @@
FROM scala/coursier-sbt:0.0.2
ARG DOCKER_TAG
# RUN apt-get update
# RUN apt-get -y install git
# RUN apt-get -y install curl
# RUN sh -c '(echo "#!/usr/bin/env sh" && curl -fLo cs https://git.io/coursier-cli-"$(uname | tr LD ld)") && chmod +x cs'
# RUN ./cs install cs
# ENV PATH=${PATH}:/root/.local/share/coursier/bin
# RUN export PATH="$PATH:/root/.local/share/coursier/bin"
# RUN rm ./cs
# ENV PATH=${PATH}:/root/.local/share/coursier/bin
# RUN export PATH="$PATH:/root/.local/share/coursier/bin"
# RUN mkdir -p /root/.local/share/coursier
# COPY coursier/bin /root/.local/share/coursier/bin
# RUN echo $PATH
# RUN cs install sbt
RUN mkdir -p /usr/src/app/bin
WORKDIR /usr/src/app
COPY ./ /usr/src/app
# RUN cat /etc/hosts
# COPY wait-for-it.sh wait-for-it.sh
# RUN chmod +x wait-for-it.sh
# ENTRYPOINT [ "/bin/bash", "-c" ]
# CMD ["./wait-for-it.sh" , "project_db:5432" , "--strict" , "--timeout=30000" , "--" , "echo 'db has started'"]
# RUN bash ./wait-for-it.sh project_db:5432 --timeout=3000 --strict -- echo "db is up"
# RUN cat /etc/hosts
# CMD [ "sbt" , "flyway/flywayMigrate" ]
# CMD ["sbtn","universal:packageBin"]
# CMD sh sbtn flyway/flywayMigrate; sbtn universal:packageBin
# RUN sbt flyway/flywayMigrate
# RUN sbt docker:stage
CMD sh Docker/app.sh
# CMD ["coursier", "--help"]
# RUN coursier install sbt
# RUN sbt docker:stage
# RUN

7
scripts/app.sh

@ -0,0 +1,7 @@
sbtn flyway/flywayMigrate
sbtn universal:packageZipTarball
tar -xf target/universal/http4s-demo-0.0.1-SNAPSHOT.tgz -C bin
# ./http4s-demo-0.0.1-SNAPSHOT/bin/http4s-demo
# sbtn docker:stage
# mv targer/docker/** bin
rm -r target

24
scripts/build.sh

@ -0,0 +1,24 @@
# export POSTGRES_DB=codegen_db
export CODEGEN_DB_HOST=localhost
export CODEGEN_DB_NAME=codegen_db
export CODEGEN_DB_USER=codegen_user
export CODEGEN_DB_PASSWORD=password
export CODEGEN_DB_PORT=5435
cid=$(docker run \
-e POSTGRES_DB=$CODEGEN_DB_NAME \
-e POSTGRES_USER=$CODEGEN_DB_USER \
-e POSTGRES_PASSWORD=$CODEGEN_DB_PASSWORD \
-p $CODEGEN_DB_PORT:5432 \
-d postgres:12)
echo "Container id is $cid"
sleep 5s
# ./wait-for-it.sh localhost:5434 -s -t 300 -- echo "db started"
sbtn flyway/flywayMigrate
# needs docker login
sbtn docker:publish
sbtn shutdown
docker stop $cid
docker rm $cid

7
scripts/curl

@ -0,0 +1,7 @@
curl -X POST -H "content-type: application/json" http://localhost:8081/api/post/book --data '{"aege":"aaegqE"}'
curl http://localhost:8081/api/get/books
curl http://localhost:8081/api/get/book/1
curl -X POST -H "content-type: application/json" http://localhost:8081/api/post/book --data '{"title":"aaegqE", "authorId": 1}'
curl -X PATCH -H "content-type: application/json" http://localhost:8081/api/update/book/2 --data '{"title":"abwbewe"}'

4
scripts/db.Dockerfile

@ -0,0 +1,4 @@
FROM postgres:12
ENV POSTGRES_USER test_user
ENV POSTGRES_PASSWORD password
ENV POSTGRES_DB test_db

6
scripts/db.sh

@ -0,0 +1,6 @@
docker run \
-e POSTGRES_DB=test_db \
-e POSTGRES_USER=test_user \
-e POSTGRES_PASSWORD=password \
-p 5433:5432 \
-d postgres:12

41
scripts/docker-compose.yml

@ -0,0 +1,41 @@
version: "3.3"
services:
db:
container_name: project_db
image: postgres:12
# build:
# context: ./Docker
# dockerfile: db.Dockerfile
environment:
POSTGRES_DB: 'codegen_db'
POSTGRES_USER: 'codegen_user'
POSTGRES_PASSWORD: 'password'
# volumes:
# - ./var/pgdata:/var/lib/postgresql/data
ports:
- "5432:5433"
# network_mode: host
backend:
container_name: project_backend
build:
context: .
dockerfile: app.Dockerfile
# ports:
# - "9000:9001"
environment:
POSTGRES_DB: 'codegen_db'
CODEGEN_DB_HOST: 'project_db'
CODEGEN_DB_NAME: 'codegen_db'
CODEGEN_DB_USER: 'codegen_user'
CODEGEN_DB_PASSWORD: 'password'
volumes:
- ./app:/usr/src/app/bin
# links:
# - db
# # command: ["./wait-for-it.sh", "project_db:5432", "--strict" , "--timeout=30000" , "--" , "echo 'db has started'"]
# depends_on:
# - db
# # condition: service_healthy

11
scripts/native

@ -0,0 +1,11 @@
native-image --trace-class-initialization --static -H:+ReportExceptionStackTraces -H:+AddAllCharsets --allow-incomplete-classpath --no-fallback --initialize-at-build-time --enable-http --enable-https --enable-all-security-services --initialize-at-run-time=org.flywaydb.core.internal.scanner.cloud.s3.AwsS3Scanner \
--initialize-at-run-time=org.flywaydb.core.internal.scanner.classpath.jboss.JBossVFSv3ClassPathLocationScanner \
--initialize-at-run-time=org.postgresql.sspi.SSPIClient \
--initialize-at-build-time=scala.runtime.Statics$VM \
--initialize-at-run-time=scala.tools.nsc.profile.ExtendedThreadMxBean \
--verbose -jar "./target/scala-2.13/http4s-demo-assembly-0.0.1-SNAPSHOT.jar" http4s-demoBinaryImage
--initialize-at-run-time=scala.tools.nsc.profile.ExtendedThreadMxBean \
--initialize-at-build-time=scala.tools.nsc.profile.SunThreadMxBean \

0
native-image-readme.md → scripts/native-image-readme.md

4
scripts/test.Dockerfile

@ -0,0 +1,4 @@
FROM scala/coursier/sbt:v0.0.1
# RUN apt search docker
RUN apt install -y docker.io
RUN docker --help

182
scripts/wait-for-it.sh

@ -0,0 +1,182 @@
#!/usr/bin/env bash
# Use this script to test if a given TCP host/port are available
WAITFORIT_cmdname=${0##*/}
echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi }
usage()
{
cat << USAGE >&2
Usage:
$WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args]
-h HOST | --host=HOST Host or IP under test
-p PORT | --port=PORT TCP port under test
Alternatively, you specify the host and port as host:port
-s | --strict Only execute subcommand if the test succeeds
-q | --quiet Don't output any status messages
-t TIMEOUT | --timeout=TIMEOUT
Timeout in seconds, zero for no timeout
-- COMMAND ARGS Execute command with args after the test finishes
USAGE
exit 1
}
wait_for()
{
if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then
echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT"
else
echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout"
fi
WAITFORIT_start_ts=$(date +%s)
while :
do
if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then
nc -z $WAITFORIT_HOST $WAITFORIT_PORT
WAITFORIT_result=$?
else
(echo -n > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1
WAITFORIT_result=$?
fi
if [[ $WAITFORIT_result -eq 0 ]]; then
WAITFORIT_end_ts=$(date +%s)
echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds"
break
fi
sleep 1
done
return $WAITFORIT_result
}
wait_for_wrapper()
{
# In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692
if [[ $WAITFORIT_QUIET -eq 1 ]]; then
timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT &
else
timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT &
fi
WAITFORIT_PID=$!
trap "kill -INT -$WAITFORIT_PID" INT
wait $WAITFORIT_PID
WAITFORIT_RESULT=$?
if [[ $WAITFORIT_RESULT -ne 0 ]]; then
echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT"
fi
return $WAITFORIT_RESULT
}
# process arguments
while [[ $# -gt 0 ]]
do
case "$1" in
*:* )
WAITFORIT_hostport=(${1//:/ })
WAITFORIT_HOST=${WAITFORIT_hostport[0]}
WAITFORIT_PORT=${WAITFORIT_hostport[1]}
shift 1
;;
--child)
WAITFORIT_CHILD=1
shift 1
;;
-q | --quiet)
WAITFORIT_QUIET=1
shift 1
;;
-s | --strict)
WAITFORIT_STRICT=1
shift 1
;;
-h)
WAITFORIT_HOST="$2"
if [[ $WAITFORIT_HOST == "" ]]; then break; fi
shift 2
;;
--host=*)
WAITFORIT_HOST="${1#*=}"
shift 1
;;
-p)
WAITFORIT_PORT="$2"
if [[ $WAITFORIT_PORT == "" ]]; then break; fi
shift 2
;;
--port=*)
WAITFORIT_PORT="${1#*=}"
shift 1
;;
-t)
WAITFORIT_TIMEOUT="$2"
if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi
shift 2
;;
--timeout=*)
WAITFORIT_TIMEOUT="${1#*=}"
shift 1
;;
--)
shift
WAITFORIT_CLI=("$@")
break
;;
--help)
usage
;;
*)
echoerr "Unknown argument: $1"
usage
;;
esac
done
if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then
echoerr "Error: you need to provide a host and port to test."
usage
fi
WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15}
WAITFORIT_STRICT=${WAITFORIT_STRICT:-0}
WAITFORIT_CHILD=${WAITFORIT_CHILD:-0}
WAITFORIT_QUIET=${WAITFORIT_QUIET:-0}
# Check to see if timeout is from busybox?
WAITFORIT_TIMEOUT_PATH=$(type -p timeout)
WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH)
WAITFORIT_BUSYTIMEFLAG=""
if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then
WAITFORIT_ISBUSY=1
# Check if busybox timeout uses -t flag
# (recent Alpine versions don't support -t anymore)
if timeout &>/dev/stdout | grep -q -e '-t '; then
WAITFORIT_BUSYTIMEFLAG="-t"
fi
else
WAITFORIT_ISBUSY=0
fi
if [[ $WAITFORIT_CHILD -gt 0 ]]; then
wait_for
WAITFORIT_RESULT=$?
exit $WAITFORIT_RESULT
else
if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then
wait_for_wrapper
WAITFORIT_RESULT=$?
else
wait_for
WAITFORIT_RESULT=$?
fi
fi
if [[ $WAITFORIT_CLI != "" ]]; then
if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then
echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess"
exit $WAITFORIT_RESULT
fi
exec "${WAITFORIT_CLI[@]}"
else
exit $WAITFORIT_RESULT
fi

10
src/it/resources/logback-test.xml

@ -0,0 +1,10 @@
<configuration>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<pattern>%msg%n</pattern>
</encoder>
</appender>
<root level="error">
<appender-ref ref="CONSOLE" />
</root>
</configuration>

112
src/it/scala/wow/doge/http4sdemo/DatabaseIntegrationTestBase.scala

@ -0,0 +1,112 @@
package wow.doge.http4sdemo
import com.dimafeng.testcontainers.ContainerDef
import com.dimafeng.testcontainers.PostgreSQLContainer
import com.dimafeng.testcontainers.munit.TestContainerForAll
import com.typesafe.config.ConfigFactory
import monix.bio.IO
import monix.bio.Task
import monix.bio.UIO
import monix.execution.Scheduler
import org.testcontainers.utility.DockerImageName
import slick.jdbc.JdbcBackend
import slick.jdbc.PostgresProfile
import wow.doge.http4sdemo.MonixBioSuite
trait DatabaseIntegrationTestBase
extends MonixBioSuite
with TestContainerForAll {
def databaseName = "testcontainer-scala"
def username = "scala"
def password = "scala"
override val containerDef: ContainerDef = PostgreSQLContainer.Def(
dockerImageName = DockerImageName.parse("postgres:12-alpine"),
databaseName = databaseName,
username = username,
password = password
)
lazy val profile = PostgresProfile
def config(url: String) = ConfigFactory.parseString(s"""|
|testDatabase = {
| url = "$url"
| driver = org.postgresql.Driver
| user = $username
| password = $password
|
| numThreads = 2
|
| queueSize = 10
|
| maxThreads = 2
|
| maxConnections = 2
|
}""".stripMargin)
def withDb[T](url: String)(f: JdbcBackend.DatabaseDef => Task[T]) = Task(
// JdbcBackend.Database.forURL(
// url,
// // user = username,
// // password = password,
// // driver = "org.postgresql.Driver",
// prop = Map(
// "driver" -> "org.postgresql.Driver",
// "user" -> username,
// "password" -> password,
// "numThreads" -> "16",
// "maxThreads" -> "36",
// "queueSize" -> "10",
// "maxConnections" -> "36"
// )
// )
JdbcBackend.Database.forConfig("testDatabase", config(url))
).bracket(f)(db => UIO(db.close()))
def createSchema(containers: Containers) = {
implicit val s = Scheduler.global
containers match {
case container: PostgreSQLContainer =>
val config = JdbcDatabaseConfig(
container.jdbcUrl,
"org.postgresql.Driver",
Some(username),
Some(password),
"flyway_schema_history",
List("classpath:db/migration/default")
)
// (UIO(println("creating db")) >> dbBracket(container.jdbcUrl)(
// // _.runL(Tables.schema.create)
// _ => DBMigrations.migrate[Task](config)
// ))
DBMigrations.migrate[Task](config).runSyncUnsafe(munitTimeout)
case _ => ()
}
}
// val fixture = ResourceFixture(
// Resource.make(
// Task(
// JdbcBackend.Database.forURL(
// "jdbc:postgresql://localhost:49162/testcontainer-scala?",
// user = username,
// password = password,
// driver = "org.postgresql.Driver"
// )
// )
// )(db => Task(db.close()))
// )
def withContainersIO[A](pf: PartialFunction[Containers, Task[A]]): Task[A] = {
withContainers { containers =>
pf.applyOrElse(
containers,
(c: Containers) =>
IO.terminate(new Exception(s"Unknown container: ${c.toString}"))
)
}
}
}

121
src/it/scala/wow/doge/http4sdemo/LibraryServiceSpec.scala

@ -0,0 +1,121 @@
package wow.doge.http4sdemo
import com.dimafeng.testcontainers.PostgreSQLContainer
import monix.bio.UIO
import wow.doge.http4sdemo.dto.BookSearchMode
import wow.doge.http4sdemo.dto.NewAuthor
import wow.doge.http4sdemo.dto.NewBook
import wow.doge.http4sdemo.implicits._
import wow.doge.http4sdemo.services.LibraryDbio
import wow.doge.http4sdemo.services.LibraryService
import wow.doge.http4sdemo.services.LibraryServiceImpl
class LibraryServiceSpec extends DatabaseIntegrationTestBase {
override def afterContainersStart(containers: Containers): Unit = {
super.afterContainersStart(containers)
createSchema(containers)
}
test("insert and retrieve book") {
withContainersIO { case container: PostgreSQLContainer =>
val io =
withDb(container.jdbcUrl)(db =>
for {
_ <- UIO.unit
service: LibraryService = new LibraryServiceImpl(
profile,
new LibraryDbio(profile),
db
)
id <- service.insertAuthor(NewAuthor("author1"))
book <- service.insertBook(NewBook("blah", "Segehwe", id))
_ <- service
.getBookById(book.bookId)
.assertEquals(Some(book))
} yield ()
)
io
}
}
test("author does not exist error on book insertion") {
withContainersIO { case container: PostgreSQLContainer =>
val io =
withDb(container.jdbcUrl)(db =>
for {
_ <- UIO.unit
service: LibraryService = new LibraryServiceImpl(
profile,
new LibraryDbio(profile),
db
)
_ <- service
.insertBook(NewBook("blah2", "agege", 23))
.attempt
.assertEquals(
Left(
LibraryService
.EntityDoesNotExist("Author with id=23 does not exist")
)
)
} yield ()
)
io
}
}
test("books with isbn already exists error on book insertion") {
withContainersIO { case container: PostgreSQLContainer =>
val io =
withDb(container.jdbcUrl)(db =>
for {
_ <- UIO.unit
service: LibraryService = new LibraryServiceImpl(
profile,
new LibraryDbio(profile),
db
)
_ <- service.insertBook(NewBook("blah2", "agege", 1))
_ <- service
.insertBook(NewBook("blah3", "agege", 1))
.attempt
.assertEquals(
Left(
LibraryService
.EntityAlreadyExists("Book with isbn=agege already exists")
)
)
} yield ()
)
io
}
}
test("search books by author id") {
withContainersIO { case container: PostgreSQLContainer =>
val io =
withDb(container.jdbcUrl)(db =>
for {
_ <- UIO.unit
service: LibraryService = new LibraryServiceImpl(
profile,
new LibraryDbio(profile),
db
)
id <- service.insertAuthor(NewAuthor("bar"))
book1 <- service.insertBook(NewBook("blah3", "aeaega", id))
book2 <- service.insertBook(NewBook("blah4", "afgegg", id))
_ <- service
.searchBook(BookSearchMode.AuthorName, "bar")
.toListL
.toIO
.attempt
.assertEquals(Right(List(book1, book2)))
} yield ()
)
io
}
}
}

22
src/main/resources/META-INF/native-image/wow/doge/http4sdemo/jni-config.json

@ -0,0 +1,22 @@
[
{
"name":"java.lang.ClassLoader",
"methods":[{"name":"getPlatformClassLoader","parameterTypes":[] }]
},
{
"name":"java.lang.NoSuchMethodError"
},
{
"name":"sun.management.VMManagementImpl",
"fields":[
{"name":"compTimeMonitoringSupport"},
{"name":"currentThreadCpuTimeSupport"},
{"name":"objectMonitorUsageSupport"},
{"name":"otherThreadCpuTimeSupport"},
{"name":"remoteDiagnosticCommandsSupport"},
{"name":"synchronizerUsageSupport"},
{"name":"threadAllocatedMemorySupport"},
{"name":"threadContentionMonitoringSupport"}
]
}
]

301
src/main/resources/META-INF/native-image/wow/doge/http4sdemo/reflect-config.json

@ -1,5 +1,4 @@
[
{
[{
"name": "org.slf4j.impl.StaticLoggerBinder",
"allDeclaredConstructors": true
},
@ -176,5 +175,303 @@
{
"name": "com.sun.org.apache.xerces.internal.jaxp.SAXParserFactoryImpl",
"allDeclaredConstructors": true
},
{
"name": "com.zaxxer.hikari.HikariConfig",
"allDeclaredFields": true
},
{
"name": "com.zaxxer.hikari.util.ConcurrentBag$IConcurrentBagEntry[]"
},
{
"name": "java.io.FilePermission"
},
{
"name": "java.lang.RuntimePermission"
},
{
"name": "java.lang.String[]"
},
{
"name": "java.lang.invoke.VarHandle",
"methods": [{ "name": "releaseFence", "parameterTypes": [] }]
},
{
"name": "java.lang.reflect.Method[]"
},
{
"name": "java.net.NetPermission"
},
{
"name": "java.net.SocketPermission"
},
{
"name": "java.net.URLPermission",
"methods": [{ "name": "<init>", "parameterTypes": ["java.lang.String", "java.lang.String"] }]
},
{
"name": "java.nio.ByteBuffer[]"
},
{
"name": "java.security.AlgorithmParametersSpi"
},
{
"name": "java.security.AllPermission"
},
{
"name": "java.security.KeyStoreSpi"
},
{
"name": "java.security.MessageDigestSpi"
},
{
"name": "java.security.SecureRandomParameters"
},
{
"name": "java.security.SecurityPermission"
},
{
"name": "java.sql.Statement[]"
},
{
"name": "java.util.PropertyPermission"
},
{
"name": "javax.management.ObjectName"
},
{
"name": "javax.security.auth.x500.X500Principal",
"fields": [{ "name": "thisX500Name" }],
"methods": [{ "name": "<init>", "parameterTypes": ["sun.security.x509.X500Name"] }]
},
{
"name": "monix.execution.internal.atomic.LeftRight128Java8BoxedObjectImpl",
"fields": [{ "name": "value", "allowUnsafeAccess": true }]
},
{
"name": "monix.execution.internal.atomic.NormalJava8BoxedInt",
"fields": [{ "name": "value", "allowUnsafeAccess": true }]
},
{
"name": "monix.execution.internal.atomic.NormalJava8BoxedObject",
"fields": [{ "name": "value", "allowUnsafeAccess": true }]
},
{
"name": "org.flywaydb.core.api.Location[]"
},
{
"name": "org.flywaydb.core.internal.logging.slf4j.Slf4jLogCreator",
"methods": [{ "name": "<init>", "parameterTypes": [] }]
},
{
"name": "org.h2.Driver"
},
{
"name": "org.http4s.blaze.channel.nio1.SelectorLoop[]"
},
{
"name": "org.http4s.blaze.util.TickWheelExecutor$Bucket[]"
},
{
"name": "org.postgresql.Driver",
"methods": [{ "name": "<init>", "parameterTypes": [] }]
},
{
"name": "org.postgresql.PGProperty",
"fields": [
{ "name": "ALLOW_ENCODING_CHANGES" },
{ "name": "APPLICATION_NAME" },
{ "name": "ASSUME_MIN_SERVER_VERSION" },
{ "name": "AUTOSAVE" },
{ "name": "BINARY_TRANSFER" },
{ "name": "BINARY_TRANSFER_DISABLE" },
{ "name": "BINARY_TRANSFER_ENABLE" },
{ "name": "CANCEL_SIGNAL_TIMEOUT" },
{ "name": "CLEANUP_SAVEPOINTS" },
{ "name": "CONNECT_TIMEOUT" },
{ "name": "CURRENT_SCHEMA" },
{ "name": "DATABASE_METADATA_CACHE_FIELDS" },
{ "name": "DATABASE_METADATA_CACHE_FIELDS_MIB" },
{ "name": "DEFAULT_ROW_FETCH_SIZE" },
{ "name": "DISABLE_COLUMN_SANITISER" },
{ "name": "ESCAPE_SYNTAX_CALL_MODE" },
{ "name": "GSS_ENC_MODE" },
{ "name": "GSS_LIB" },
{ "name": "HIDE_UNPRIVILEGED_OBJECTS" },
{ "name": "HOST_RECHECK_SECONDS" },
{ "name": "JAAS_APPLICATION_NAME" },
{ "name": "JAAS_LOGIN" },
{ "name": "KERBEROS_SERVER_NAME" },
{ "name": "LOAD_BALANCE_HOSTS" },
{ "name": "LOGGER_FILE" },
{ "name": "LOGGER_LEVEL" },
{ "name": "LOGIN_TIMEOUT" },
{ "name": "LOG_SERVER_ERROR_DETAIL" },
{ "name": "LOG_UNCLOSED_CONNECTIONS" },
{ "name": "MAX_RESULT_BUFFER" },
{ "name": "OPTIONS" },
{ "name": "PASSWORD" },
{ "name": "PG_DBNAME" },
{ "name": "PG_HOST" },
{ "name": "PG_PORT" },
{ "name": "PREFER_QUERY_MODE" },
{ "name": "PREPARED_STATEMENT_CACHE_QUERIES" },
{ "name": "PREPARED_STATEMENT_CACHE_SIZE_MIB" },
{ "name": "PREPARE_THRESHOLD" },
{ "name": "PROTOCOL_VERSION" },
{ "name": "READ_ONLY" },
{ "name": "READ_ONLY_MODE" },
{ "name": "RECEIVE_BUFFER_SIZE" },
{ "name": "REPLICATION" },
{ "name": "REWRITE_BATCHED_INSERTS" },
{ "name": "SEND_BUFFER_SIZE" },
{ "name": "SOCKET_FACTORY" },
{ "name": "SOCKET_FACTORY_ARG" },
{ "name": "SOCKET_TIMEOUT" },
{ "name": "SSL" },
{ "name": "SSL_CERT" },
{ "name": "SSL_FACTORY" },
{ "name": "SSL_FACTORY_ARG" },
{ "name": "SSL_HOSTNAME_VERIFIER" },
{ "name": "SSL_KEY" },
{ "name": "SSL_MODE" },
{ "name": "SSL_PASSWORD" },
{ "name": "SSL_PASSWORD_CALLBACK" },
{ "name": "SSL_ROOT_CERT" },
{ "name": "SSPI_SERVICE_CLASS" },
{ "name": "STRING_TYPE" },
{ "name": "TARGET_SERVER_TYPE" },
{ "name": "TCP_KEEP_ALIVE" },
{ "name": "UNKNOWN_LENGTH" },
{ "name": "USER" },
{ "name": "USE_SPNEGO" },
{ "name": "XML_FACTORY_FACTORY" }
]
},
{
"name": "org.slf4j.Logger"
},
{
"name": "org.slf4j.impl.StaticLoggerBinder"
},
{
"name": "scala.Symbol",
"methods": [{ "name": "apply", "parameterTypes": ["java.lang.String"] }]
},
{
"name": "scala.concurrent.BlockContext$",
"allDeclaredMethods": true
},
{
"name": "scala.util.Either[]"
},
{
"name": "slick.jdbc.hikaricp.HikariCPJdbcDataSource$",
"fields": [{ "name": "MODULE$" }]
},
{
"name": "slick.relational.ResultConverter[]"
},
{
"name": "sun.misc.Unsafe",
"fields": [{ "name": "theUnsafe" }],
"methods": [
{ "name": "fullFence", "parameterTypes": [] },
{ "name": "getAndAddInt", "parameterTypes": ["java.lang.Object", "long", "int"] },
{ "name": "getAndAddLong", "parameterTypes": ["java.lang.Object", "long", "long"] },
{ "name": "getAndSetObject", "parameterTypes": ["java.lang.Object", "long", "java.lang.Object"] }
]
},
{
"name": "sun.security.pkcs12.PKCS12KeyStore",
"methods": [{ "name": "<init>", "parameterTypes": [] }]
},
{
"name": "sun.security.pkcs12.PKCS12KeyStore$DualFormatPKCS12",
"methods": [{ "name": "<init>", "parameterTypes": [] }]
},
{
"name": "sun.security.provider.JavaKeyStore$JKS",
"methods": [{ "name": "<init>", "parameterTypes": [] }]
},
{
"name": "sun.security.provider.MD5",
"methods": [{ "name": "<init>", "parameterTypes": [] }]
},
{
"name": "sun.security.provider.NativePRNG",
"methods": [{ "name": "<init>", "parameterTypes": [] }]
},
{
"name": "sun.security.provider.SHA",
"methods": [{ "name": "<init>", "parameterTypes": [] }]
},
{
"name": "sun.security.provider.X509Factory",
"methods": [{ "name": "<init>", "parameterTypes": [] }]
},
{
"name": "sun.security.rsa.RSAKeyFactory$Legacy",
"methods": [{ "name": "<init>", "parameterTypes": [] }]
},
{
"name": "sun.security.ssl.KeyManagerFactoryImpl$SunX509",
"methods": [{ "name": "<init>", "parameterTypes": [] }]
},
{
"name": "sun.security.ssl.SSLContextImpl$DefaultSSLContext",
"methods": [{ "name": "<init>", "parameterTypes": [] }]
},
{
"name": "sun.security.ssl.TrustManagerFactoryImpl$PKIXFactory",
"methods": [{ "name": "<init>", "parameterTypes": [] }]
},
{
"name": "sun.security.x509.AuthorityInfoAccessExtension",
"methods": [{ "name": "<init>", "parameterTypes": ["java.lang.Boolean", "java.lang.Object"] }]
},
{
"name": "sun.security.x509.AuthorityKeyIdentifierExtension",
"methods": [{ "name": "<init>", "parameterTypes": ["java.lang.Boolean", "java.lang.Object"] }]
},
{
"name": "sun.security.x509.BasicConstraintsExtension",
"methods": [{ "name": "<init>", "parameterTypes": ["java.lang.Boolean", "java.lang.Object"] }]
},
{
"name": "sun.security.x509.CRLDistributionPointsExtension",
"methods": [{ "name": "<init>", "parameterTypes": ["java.lang.Boolean", "java.lang.Object"] }]
},
{
"name": "sun.security.x509.CertificatePoliciesExtension",
"methods": [{ "name": "<init>", "parameterTypes": ["java.lang.Boolean", "java.lang.Object"] }]
},
{
"name": "sun.security.x509.ExtendedKeyUsageExtension",
"methods": [{ "name": "<init>", "parameterTypes": ["java.lang.Boolean", "java.lang.Object"] }]
},
{
"name": "sun.security.x509.IssuerAlternativeNameExtension",
"methods": [{ "name": "<init>", "parameterTypes": ["java.lang.Boolean", "java.lang.Object"] }]
},
{
"name": "sun.security.x509.KeyUsageExtension",
"methods": [{ "name": "<init>", "parameterTypes": ["java.lang.Boolean", "java.lang.Object"] }]
},
{
"name": "sun.security.x509.NetscapeCertTypeExtension",
"methods": [{ "name": "<init>", "parameterTypes": ["java.lang.Boolean", "java.lang.Object"] }]
},
{
"name": "sun.security.x509.PrivateKeyUsageExtension",
"methods": [{ "name": "<init>", "parameterTypes": ["java.lang.Boolean", "java.lang.Object"] }]
},
{
"name": "sun.security.x509.SubjectAlternativeNameExtension",
"methods": [{ "name": "<init>", "parameterTypes": ["java.lang.Boolean", "java.lang.Object"] }]
},
{
"name": "sun.security.x509.SubjectKeyIdentifierExtension",
"methods": [{ "name": "<init>", "parameterTypes": ["java.lang.Boolean", "java.lang.Object"] }]
}
]

19
src/main/resources/META-INF/native-image/wow/doge/http4sdemo/resource-config.json

@ -0,0 +1,19 @@
{
"resources":{
"includes":[
{"pattern":"\\QMETA-INF/services/java.sql.Driver\\E"},
{"pattern":"\\Qapplication.conf\\E"},
{"pattern":"\\Qdb/migration/default/V1__create_users_table.sql\\E"},
{"pattern":"\\Qdb/migration/default/V2__add_user.sql\\E"},
{"pattern":"\\Qdb/migration/default/V3__create_cars_table.sql\\E"},
{"pattern":"\\Qdb/migration/default/V4__add_car.sql\\E"},
{"pattern":"\\Qdb/migration/default/V5__authors_books_table.sql\\E"},
{"pattern":"\\Qdb/migration/default/V6__insert_books_and_authors.sql\\E"},
{"pattern":"\\Qdb/migration/default\\E"},
{"pattern":"\\Qlogback.xml\\E"},
{"pattern":"\\Qorg/flywaydb/core/internal/version.txt\\E"},
{"pattern":"\\Qorg/slf4j/impl/StaticLoggerBinder.class\\E"},
{"pattern":"\\Qreference.conf\\E"}
]},
"bundles":[]
}

2
src/main/resources/META-INF/native-image/wow/doge/http4sdemo/serialization-config.json

@ -0,0 +1,2 @@
[
]

40
src/main/resources/application.conf

@ -2,36 +2,36 @@
myapp = {
database = {
driver = org.postgresql.Driver
url = "jdbc:postgresql://localhost:5432/test_db"
dbHost = localhost
dbHost = ${?HTTP4S_DEMO_DB_HOST}
dbPort = 5432
dbPort = ${?HTTP4S_DEMO_DB_PORT}
dbName = test_db
dbName = ${?HTTP4S_DEMO_DB_NAME}
url = "jdbc:postgresql://"${myapp.database.dbHost}":"${myapp.database.dbPort}"/"${myapp.database.dbName}
user = "test_user"
user = ${?HTTP4S_DEMO_DB_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
password = ${?HTTP4S_DEMO_DB_PASSWORD}
numThreads = 16
queueSize = 1000
maxConnections = 16
connectionTimeout = 5000
validationTimeout = 5000
# connectionPool = disabled
keepAlive = true
migrations-table = "flyway_schema_history"
migrations-locations = [
# "classpath:example/jdbc"
"classpath:db/migration/default"
]
},
testDatabase = {
driver = org.postgresql.Driver
user = "scala"
password = "scala"
numThreads = 16
queueSize = 10
maxConnections = 36
}
}

144
src/main/scala/wow/doge/http4sdemo/Http4sdemoRoutes.scala

@ -1,144 +0,0 @@
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)
)
}

50
src/main/scala/wow/doge/http4sdemo/Http4sdemoServer.scala

@ -1,50 +0,0 @@
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
}

47
src/main/scala/wow/doge/http4sdemo/Jokes.scala

@ -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
}
}
}

31
src/main/scala/wow/doge/http4sdemo/Main.scala

@ -1,16 +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
import wow.doge.http4sdemo.SlickResource
object Main extends BIOApp {
val profile: JdbcProfile = _root_.slick.jdbc.H2Profile
def app = for {
val profile: JdbcProfile = slick.jdbc.PostgresProfile
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")
@ -18,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

1
src/main/scala/wow/doge/http4sdemo/Migrate.scala

@ -55,6 +55,7 @@ object DBMigrations extends LazyLogging {
count
}
@SuppressWarnings(Array("org.wartremover.warts.Null"))
private def unsafeMigrate(config: JdbcDatabaseConfig): Int = {
val m: FluentConfiguration = Flyway.configure
.dataSource(

43
src/main/scala/wow/doge/http4sdemo/Server.scala

@ -0,0 +1,43 @@
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
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.routes.LibraryRoutes
import wow.doge.http4sdemo.services.LibraryDbio
import wow.doge.http4sdemo.services.LibraryServiceImpl
final class Server(db: DatabaseDef, p: JdbcProfile, logger: odin.Logger[Task]) {
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
libraryDbio = new LibraryDbio(p)
libraryService = new LibraryServiceImpl(p, libraryDbio, db)
httpApp = (
new LibraryRoutes(libraryService, logger).routes
).orNotFound
finalHttpApp = Logger.httpApp(
true,
true,
logAction = log.pure[Option]
)(httpApp)
exitCode <- BlazeServerBuilder[Task](s)
.bindHttp(8081, "0.0.0.0")
.withHttpApp(finalHttpApp)
.serve
} yield exitCode
}.drain
}

4
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()))
}

79
src/main/scala/wow/doge/http4sdemo/dto/Library.scala

@ -1,45 +1,47 @@
package wow.doge.http4sdemo.dto
import java.time.Instant
import java.time.LocalDateTime
import io.circe.Printer
import cats.syntax.either._
import enumeratum.EnumEntry
import enumeratum._
import io.circe.generic.semiauto._
import io.scalaland.chimney.dsl._
import org.http4s.EntityEncoder
import org.http4s.circe.streamJsonArrayEncoderWithPrinterOf
import org.http4s.ParseFailure
import org.http4s.QueryParamDecoder
import org.http4s.dsl.impl.QueryParamDecoderMatcher
import slick.jdbc.JdbcProfile
import wow.doge.http4sdemo.slickcodegen.Tables
final case class Book(
id: Int,
title: String,
bookId: Int,
bookTitle: String,
isbn: String,
authorId: Int,
createdAt: Instant
createdAt: LocalDateTime
)
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 tupled = (apply _).tupled
implicit val codec = deriveCodec[Book]
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]
(b: Tables.Books) =>
(b.bookId, b.bookTitle, b.isbn, b.authorId, b.createdAt).mapTo[Book]
}
def fromBooksTable(implicit profile: JdbcProfile) =
Tables.Books.map(fromBooksTableFn)
}
final case class NewBook(title: String, authorId: Int)
final case class NewBook(bookTitle: String, isbn: String, authorId: Int)
object NewBook {
def tupled = (NewBook.apply _).tupled
def tupled = (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])
Tables.Books.map(b => (b.bookTitle, b.isbn, b.authorId).mapTo[NewBook])
}
}
@ -47,36 +49,57 @@ final case class BookUpdate(title: Option[String], authorId: Option[Int]) {
import com.softwaremill.quicklens._
def update(row: Tables.BooksRow): Tables.BooksRow =
row
.modify(_.title)
.modify(_.bookTitle)
.setToIfDefined(title)
.modify(_.authorId)
.setToIfDefined(authorId)
}
object BookUpdate {
implicit val decoder = deriveDecoder[BookUpdate]
implicit val codec = deriveCodec[BookUpdate]
}
final case class Author(id: Int, name: String)
final case class Author(authorId: Int, authorName: String)
object Author {
def tupled = (Author.apply _).tupled
def tupled = (apply _).tupled
implicit val codec = deriveCodec[Author]
implicit def streamEntityEncoder[F[_]]
: EntityEncoder[F, fs2.Stream[F, Author]] =
streamJsonArrayEncoderWithPrinterOf(Printer.noSpaces)
def fromAuthorsRow(row: Tables.AuthorsRow) = row.transformInto[Author]
def fromAuthorsTableFn(implicit profile: JdbcProfile) = {
import profile.api._
(a: Tables.Authors) => (a.authorId, a.authorName).mapTo[Author]
}
}
final case class NewAuthor(name: String)
object NewAuthor {
// def fromAuthorsTable(implicit profile: JdbcProfile) = {
// import profile.api._
// Tables.Authors.map(a => (a.authorName).mapTo[NewAuthor])
// }
}
final case class BookWithAuthor(
id: Int,
title: String,
isbn: String,
author: Author,
createdAt: Instant
createdAt: LocalDateTime
)
object BookWithAuthor {
def tupled = (BookWithAuthor.apply _).tupled
def tupled = (apply _).tupled
implicit val codec = deriveCodec[BookWithAuthor]
implicit def streamEntityEncoder[F[_]]
: EntityEncoder[F, fs2.Stream[F, BookWithAuthor]] =
streamJsonArrayEncoderWithPrinterOf(Printer.noSpaces)
}
sealed trait BookSearchMode extends EnumEntry
object BookSearchMode extends Enum[BookSearchMode] {
val values = findValues
case object BookTitle extends BookSearchMode
case object AuthorName extends BookSearchMode
implicit val yearQueryParamDecoder: QueryParamDecoder[BookSearchMode] =
QueryParamDecoder[String].emap(s =>
withNameEither(s).leftMap(e => ParseFailure(e.getMessage, e.getMessage))
)
object Matcher extends QueryParamDecoderMatcher[BookSearchMode]("mode")
}

23
src/main/scala/wow/doge/http4sdemo/implicits/package.scala

@ -1,5 +1,9 @@
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
@ -13,6 +17,11 @@ package object implicits {
def runL[R](a: DBIOAction[R, NoStream, Nothing]) =
Task.deferFuture(db.run(a))
def runTryL[R, A](a: DBIOAction[R, NoStream, Nothing])(implicit
ev: R <:< Try[A]
) =
Task.deferFuture(db.run(a)).flatMap(r => IO.fromTry(ev(r)))
def streamO[T](a: DBIOAction[_, Streaming[T], Nothing]) =
Observable.fromReactivePublisher(db.stream(a))
}
@ -28,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
}
}

114
src/main/scala/wow/doge/http4sdemo/routes/LibraryRoutes.scala

@ -0,0 +1,114 @@
package wow.doge.http4sdemo.routes
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 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
class LibraryRoutes(libraryService: LibraryService, logger: Logger[Task]) {
val routes: HttpRoutes[Task] = {
val dsl = Http4sDsl[Task]
import dsl._
object Value extends QueryParamDecoderMatcher[String]("value")
HttpRoutes.of[Task] {
case GET -> Root / "api" / "books" :?
BookSearchMode.Matcher(mode) +& Value(value) =>
import org.http4s.circe.streamJsonArrayEncoder
import io.circe.syntax._
IO.deferAction(implicit s =>
for {
books <- IO.pure(
libraryService
.searchBook(mode, value)
.toReactivePublisher
.toStream[Task]
)
res <- Ok(books.map(_.asJson))
} yield res
)
case GET -> Root / "api" / "books" =>
import org.http4s.circe.streamJsonArrayEncoder
import io.circe.syntax._
Task.deferAction(implicit s =>
for {
books <- IO.pure(
libraryService.getBooks.toReactivePublisher
.toStream[Task]
)
res <- Ok(books.map(_.asJson))
} yield res
)
case GET -> Root / "api" / "books" / IntVar(id) =>
import org.http4s.circe.CirceEntityCodec._
for {
bookJson <- libraryService.getBookById(id)
res <- Ok(bookJson)
} yield res
case req @ PUT -> Root / "api" / "books" =>
import org.http4s.circe.CirceEntityCodec._
for {
newBook <- req.as[NewBook]
res <- libraryService
.insertBook(newBook)
.tapError(err => logger.errorU(err.toString))
.flatMap(book => Created(book).hideErrors)
.onErrorHandleWith(_.toResponse)
} yield res
case req @ PATCH -> Root / "api" / "books" / IntVar(id) =>
import org.http4s.circe.CirceEntityCodec._
for {
updateData <- req.as[BookUpdate]
res <- libraryService
.updateBook(id, updateData)
.flatMap(_ => NoContent().hideErrors)
.tapError(err => logger.errorU(err.toString))
.onErrorHandleWith(_.toResponse)
} yield res
case req @ DELETE -> Root / "api" / "books" / IntVar(id) =>
for {
_ <- libraryService.deleteBook(id)
res <- Ok()
} yield res
//TODO: use convenience method for decoding json stream
case req @ POST -> Root / "api" / "books" =>
import org.http4s.circe.CirceEntityCodec.circeEntityDecoder
for {
newBooks <- req.as[List[Book]]
// obs = Observable.fromIterable(newBooks)
// book <- libraryService.insertBook(newBook)
res <- Ok("blah")
} yield res
}
}
}
final 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
}

243
src/main/scala/wow/doge/http4sdemo/services/LibraryService.scala

@ -1,70 +1,194 @@
package wow.doge.http4sdemo.services
import io.circe.generic.semiauto._
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
import wow.doge.http4sdemo.dto.Book
import wow.doge.http4sdemo.dto.BookSearchMode
import wow.doge.http4sdemo.dto.BookSearchMode.AuthorName
import wow.doge.http4sdemo.dto.BookSearchMode.BookTitle
import wow.doge.http4sdemo.dto.BookUpdate
import wow.doge.http4sdemo.dto.NewAuthor
import wow.doge.http4sdemo.dto.NewBook
import wow.doge.http4sdemo.implicits._
import wow.doge.http4sdemo.slickcodegen.Tables
class LibraryService(
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) => ()
// }
}
}
trait LibraryService {
import LibraryService._
def getBooks: Observable[Book]
def getBookById(id: Int): Task[Option[Book]]
def searchBook(mode: BookSearchMode, value: String): Observable[Book]
def updateBook(id: Int, updateData: BookUpdate): IO[Error, Unit]
def deleteBook(id: Int): Task[Int]
def insertBook(newBook: NewBook): IO[Error, Book]
def insertAuthor(a: NewAuthor): Task[Int]
def booksForAuthor(authorId: Int): Observable[Book]
}
class LibraryServiceImpl(
profile: JdbcProfile,
dbio: LibraryDbio,
db: JdbcBackend.DatabaseDef
) {
) extends LibraryService {
import profile.api._
def getBooks = db.streamO(dbio.getBooks)
import LibraryService._
def getBooks = db.streamO(dbio.getBooks.transactionally)
def getBookById(id: Int) = db.runL(dbio.getBook(id))
def getBookById(id: Int) = db.runL(dbio.getBookById(id))
// .map(b =>
// (b.title, b.authorId, b.createdAt).mapTo[BookUpdateEntity]
// )
def searchBook(mode: BookSearchMode, value: String): Observable[Book] =
mode match {
case BookTitle =>
db.streamO(dbio.getBooksByTitle(value))
def updateBook(id: Int, updateData: BookUpdate) =
case AuthorName =>
Observable
.fromTask((for {
author <- db.runL(dbio.getAuthorByName(value)).flatMap {
case None =>
IO.raiseError(
new EntityDoesNotExist(
s"Author with name=$value does not exist"
)
)
case Some(value) => IO.pure(value)
}
books = db
.streamO(dbio.getBooksForAuthor(author.authorId))
.map(Book.fromBooksRow)
} yield books).toTask)
.flatten
}
def insertAuthor(a: NewAuthor): Task[Int] = db.runL(dbio.insertAuthor(a))
def updateBook(id: Int, updateData: BookUpdate): IO[Error, Unit] =
for {
action <- IO.deferAction { implicit s =>
Task(for {
mbRow <- dbio.selectBook(id).result.headOption
action <- UIO.deferAction(implicit s =>
UIO(for {
mbRow <- Tables.Books.filter(_.bookId === 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"))
DBIO.failed(
EntityDoesNotExist(s"Book with id=$id does not exist")
)
}
updateAction = dbio.selectBook(id).update(updatedRow)
updateAction = Tables.Books.filter(_.bookId === id).update(updatedRow)
_ = println(s"SQL = ${updateAction.statements}")
_ <- updateAction
} yield ())
)
_ <- db
.runTryL(action.transactionally.asTry)
.mapErrorPartial { case e: Error =>
e
}
_ <- 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 insertBook(newBook: NewBook): IO[Error, Book] =
IO.deferAction { implicit s =>
for {
action <- UIO(for {
_ <- Tables.Books
.filter(_.isbn === newBook.isbn)
.map(Book.fromBooksTableFn)
.result
.headOption
.flatMap {
case None => DBIO.successful(())
case Some(_) =>
DBIO.failed(
EntityAlreadyExists(
s"Book with isbn=${newBook.isbn} already exists"
)
)
}
_ <- dbio.getAuthor(newBook.authorId).flatMap {
case None =>
DBIO.failed(
EntityDoesNotExist(
s"Author with id=${newBook.authorId} does not exist"
)
)
case Some(_) => DBIO.successful(())
}
book <- dbio.insertBookAndGetBook(newBook)
} yield book)
book <- db
.runTryL(action.transactionally.asTry)
.mapErrorPartial { case e: Error =>
e
}
} yield book
}
def booksForAuthor(authorId: Int) =
db.streamO(dbio.booksForAuthor(authorId)).map(Book.fromBooksRow)
db.streamO(dbio.getBooksForAuthor(authorId).transactionally)
.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] =
@ -73,31 +197,92 @@ class LibraryDbio(val profile: JdbcProfile) {
def insertBookAndGetBook(newBook: NewBook): DBIO[Book] =
Query.insertBookGetBook += newBook
def selectBook(id: Int) = Tables.Books.filter(_.id === id)
def insertAuthor(newAuthor: NewAuthor): DBIO[Int] =
Query.insertAuthorGetId += newAuthor
def deleteBook(id: Int) = selectBook(id).delete
def getAuthor(id: Int): DBIO[Option[Author]] =
Query.selectAuthor(id).map(Author.fromAuthorsTableFn).result.headOption
def getBook(id: Int) = selectBook(id)
def getAuthorByName(name: String): DBIO[Option[Author]] =
Tables.Authors
.filter(_.authorName === name)
.map(Author.fromAuthorsTableFn)
.result
.headOption
def deleteBook(id: Int): DBIO[Int] = Query.selectBookById(id).delete
def getBookById(id: Int): DBIO[Option[Book]] = Query
.selectBookById(id)
.map(Book.fromBooksTableFn)
.result
.headOption
def booksForAuthor(authorId: Int) = Query.booksForAuthorInner(authorId).result
def getBooksByTitle(title: String): StreamingDBIO[Seq[Book], Book] =
Tables.Books.filter(_.bookTitle === title).map(Book.fromBooksTableFn).result
def getBooksForAuthor(
authorId: Int
): StreamingDBIO[Seq[Tables.BooksRow], Tables.BooksRow] =
Query.booksForAuthorInner(authorId).result
private object Query {
val getBooksInner = Book.fromBooksTable
val insertBookGetId =
NewBook.fromBooksTable.returning(Tables.Books.map(_.id))
NewBook.fromBooksTable.returning(Tables.Books.map(_.bookId))
val insertBookGetBook = NewBook.fromBooksTable.returning(getBooksInner)
val insertAuthorGetId =
Tables.Authors
.map(a => (a.authorName).mapTo[NewAuthor])
.returning(Tables.Authors.map(_.authorId))
// val insertAuthor = NewAuthor.fromAuthorsTable
def booksForAuthorInner(authorId: Int) = for {
b <- Tables.Books
a <- selectAuthor(authorId) if b.authorId === a.id
a <- Tables.Authors
if b.authorId === a.authorId && b.authorId === authorId
} yield b
def selectAuthor(authorId: Int) = Tables.Authors.filter(_.id === authorId)
def selectAuthor(authorId: Int) =
Tables.Authors.filter(_.authorId === authorId)
def selectBookById(id: Int) = Tables.Books.filter(_.bookId === id)
def selectBookByIsbn(isbn: String) = Tables.Books.filter(_.isbn === isbn)
}
}
trait NoopLibraryService extends LibraryService {
def getBooks: Observable[Book] =
Observable.raiseError(new NotImplementedError)
def getBookById(id: Int): Task[Option[Book]] =
IO.terminate(new NotImplementedError)
def searchBook(
mode: BookSearchMode,
value: String
): Observable[Book] = Observable.raiseError(new NotImplementedError)
def updateBook(
id: Int,
updateData: BookUpdate
): IO[LibraryService.Error, Unit] = IO.terminate(new NotImplementedError)
def deleteBook(id: Int): Task[Int] = IO.terminate(new NotImplementedError)
def insertBook(newBook: NewBook): IO[LibraryService.Error, Book] =
IO.terminate(new NotImplementedError)
def insertAuthor(a: NewAuthor): Task[Int] =
IO.terminate(new NotImplementedError)
def booksForAuthor(authorId: Int): Observable[Book] =
Observable.raiseError(new NotImplementedError)
}

10
src/test/resources/logback-test.xml

@ -0,0 +1,10 @@
<configuration>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<pattern>%msg%n</pattern>
</encoder>
</appender>
<root level="OFF">
<appender-ref ref="CONSOLE" />
</root>
</configuration>

25
src/test/scala/wow/doge/http4sdemo/HelloWorldSpec.scala

@ -1,25 +0,0 @@
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)
// }
}

156
src/test/scala/wow/doge/http4sdemo/LibraryControllerSpec.scala

@ -0,0 +1,156 @@
package wow.doge.http4sdemo
import cats.syntax.all._
import monix.bio.IO
import monix.bio.Task
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
class LibraryControllerSpec extends MonixBioSuite {
val Root = Uri(path = "")
test("get books success") {
import org.http4s.circe.CirceEntityCodec._
val book = Book(1, "book1", "adsgq342dsdc", 1, date)
val service = new NoopLibraryService {
override def getBooks: Observable[Book] =
Observable.fromIterable(book :: Nil)
override def getBookById(id: Int): Task[Option[Book]] =
Task.some(book)
}
for {
_ <- UIO.unit
routes = new LibraryRoutes(service, noopLogger).routes
res <- routes
.run(Request[Task](Method.GET, uri"/api/books"))
.value
.hideErrors
body <- res.traverse(_.as[List[Book]])
_ <- UIO(assertEquals(body, Some(List(book))))
// _ <- logger2.debug(body.toString).hideErrors
} yield ()
}
test("update book error") {
import org.http4s.circe.CirceEntityCodec._
val service = new NoopLibraryService {
override def updateBook(id: Int, updateData: BookUpdate) =
IO.raiseError(
LibraryService.EntityDoesNotExist(s"Book with id=$id does not exist")
)
}
for {
_ <- UIO.unit
reqBody = BookUpdate(Some("blah"), None)
routes = new LibraryRoutes(service, noopLogger).routes
res <- routes
.run(
Request[Task](Method.PATCH, Root / "api" / "books" / "1")
.withEntity(reqBody)
)
.value
.hideErrors
_ <- UIO(assertEquals(res.map(_.status), Some(Status.NotFound)))
body <- res.traverse(_.as[LibraryService.Error])
_ <- UIO(
assertEquals(
body,
Some(
LibraryService.EntityDoesNotExist("Book with id=1 does not exist")
)
)
)
// _ <- logger.debug(res.toString).hideErrors
// _ <- logger.debug(body.toString).hideErrors
} yield ()
}
test("get books by author name") {
import org.http4s.circe.CirceEntityCodec._
val value = "blah"
val books =
List(Book(1, "book1", value, 1, date), Book(2, "book1", value, 1, date))
val service = new NoopLibraryService {
override def searchBook(mode: BookSearchMode, value: String) =
mode match {
case BookSearchMode.BookTitle =>
Observable.fromIterable(books)
case BookSearchMode.AuthorName =>
Observable.fromIterable(books)
}
}
for {
_ <- UIO.unit
// logger2 = logger.withConstContext(
// Map("Test" -> "get books by author name")
// )
routes = new LibraryRoutes(service, noopLogger).routes
request = Request[Task](
Method.GET,
Root / "api" / "books"
withQueryParams Map(
"mode" -> BookSearchMode.AuthorName.entryName,
"value" -> "blah"
)
)
// _ <- logger2.info(s"Request -> $request")
res <- routes.run(request).value.hideErrors
body <- res.traverse(_.as[List[Book]])
_ <- UIO.pure(body).assertEquals(Some(books))
// _ <- logger2.debug(s"Response body -> $body").hideErrors
} yield ()
}
test("get books by book title") {
import org.http4s.circe.CirceEntityCodec._
val value = "blah"
val books =
List(Book(1, "book1", value, 1, date), Book(2, "book1", value, 1, date))
val service = new NoopLibraryService {
override def searchBook(mode: BookSearchMode, value: String) =
mode match {
case BookSearchMode.BookTitle =>
Observable.fromIterable(books)
case BookSearchMode.AuthorName =>
Observable.fromIterable(books)
}
}
for {
_ <- UIO.unit
// logger2 = logger.withConstContext(
// Map("Test" -> "get books by book title")
// )
routes = new LibraryRoutes(service, noopLogger).routes
request = Request[Task](
Method.GET,
Root / "api" / "books"
withQueryParams Map(
"mode" -> BookSearchMode.BookTitle.entryName,
"value" -> "blah"
)
)
// _ <- logger2.info(s"Request -> $request")
res <- routes.run(request).value.hideErrors
body <- res.traverse(_.as[List[Book]])
_ <- UIO.pure(body).assertEquals(Some(books))
// _ <- logger2.debug(s"Response body -> $body").hideErrors
} yield ()
}
}
Loading…
Cancel
Save