├── project
├── build.properties
├── project
│ └── plugins.sbt
├── plugins.sbt
├── BuildHelper.scala
└── Librairies.scala
├── .gitattributes
├── .git-blame-ignore-revs
├── .github
├── dependabot.yml
├── workflows
│ ├── draft.yml
│ ├── scala-steward.yml
│ ├── ci.yml
│ ├── cd-staging.yml
│ └── cd-prod.yml
└── release-drafter.yml
├── modules
├── api
│ └── src
│ │ ├── main
│ │ ├── resources
│ │ │ ├── db
│ │ │ │ └── migration
│ │ │ │ │ ├── V2__20210322154344_lowercase_created_at.sql
│ │ │ │ │ ├── V1__20210322153653_add_published_to_post.sql
│ │ │ │ │ └── V3__20210322154908_type_uuid.sql
│ │ │ └── logback.xml
│ │ └── scala
│ │ │ └── io
│ │ │ └── conduktor
│ │ │ └── api
│ │ │ ├── http
│ │ │ ├── health
│ │ │ │ └── HealthRoutes.scala
│ │ │ ├── Errors.scala
│ │ │ ├── RoutesInterpreter.scala
│ │ │ ├── Server.scala
│ │ │ ├── posts
│ │ │ │ └── v1
│ │ │ │ │ ├── Domain.scala
│ │ │ │ │ └── PostRoutes.scala
│ │ │ └── endpoints.scala
│ │ │ ├── config
│ │ │ └── AppConfig.scala
│ │ │ └── ApiTemplateApp.scala
│ │ └── test
│ │ └── scala
│ │ └── io
│ │ └── conduktor
│ │ └── api
│ │ ├── ServerTestLayers.scala
│ │ └── http
│ │ └── posts
│ │ └── v1
│ │ ├── CodecSpec.scala
│ │ └── PostRoutesSpec.scala
├── auth
│ └── src
│ │ └── main
│ │ └── scala
│ │ └── io
│ │ └── conduktor
│ │ └── api
│ │ ├── config
│ │ └── Auth0Config.scala
│ │ ├── model
│ │ └── User.scala
│ │ └── auth
│ │ └── AuthService.scala
├── postgres
│ └── src
│ │ ├── main
│ │ └── scala
│ │ │ └── io
│ │ │ └── conduktor
│ │ │ └── api
│ │ │ ├── config
│ │ │ └── DBConfig.scala
│ │ │ └── db
│ │ │ ├── DatabaseMigration.scala
│ │ │ └── DbSessionPool.scala
│ │ └── test
│ │ └── scala
│ │ └── io
│ │ └── conduktor
│ │ └── api
│ │ └── db
│ │ ├── EmbeddedPostgres.scala
│ │ └── DbSpec.scala
├── posts
│ └── src
│ │ ├── main
│ │ └── scala
│ │ │ └── io
│ │ │ └── conduktor
│ │ │ └── api
│ │ │ ├── repository
│ │ │ ├── db
│ │ │ │ ├── db.scala
│ │ │ │ ├── Fragments.scala
│ │ │ │ ├── SkunkExtensions.scala
│ │ │ │ └── DbPostRepository.scala
│ │ │ └── PostRepository.scala
│ │ │ ├── model
│ │ │ └── Post.scala
│ │ │ └── service
│ │ │ └── PostService.scala
│ │ └── test
│ │ └── scala
│ │ └── io
│ │ └── conduktor
│ │ └── api
│ │ ├── db
│ │ ├── DbRepositorySpec.scala
│ │ ├── RepositorySpec.scala
│ │ └── MemoryRepositorySpec.scala
│ │ └── service
│ │ └── PostServiceSpec.scala
└── common
│ └── src
│ └── main
│ └── scala
│ └── io
│ └── conduktor
│ └── primitives
│ └── types
│ └── package.scala
├── .gitignore
├── .scalafix.conf
├── .scalafmt.conf
├── README.md
├── blog-post.adoc
└── LICENSE
/project/build.properties:
--------------------------------------------------------------------------------
1 | sbt.version = 1.8.2
2 |
--------------------------------------------------------------------------------
/project/project/plugins.sbt:
--------------------------------------------------------------------------------
1 | addSbtPlugin("com.timushev.sbt" % "sbt-updates" % "0.5.3")
2 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | sbt linguist-vendored
2 | website/* linguist-vendored
3 | docs/* linguist-vendored
4 |
5 |
--------------------------------------------------------------------------------
/.git-blame-ignore-revs:
--------------------------------------------------------------------------------
1 | # Scala Steward: Reformat with scalafmt 3.7.2
2 | 4e1885394a8551056519b80f8e2eb950243542a4
3 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: "github-actions"
4 | directory: "/"
5 | schedule:
6 | interval: "weekly"
--------------------------------------------------------------------------------
/modules/api/src/main/resources/db/migration/V2__20210322154344_lowercase_created_at.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - You are about to drop the column `createdAt` on the `post` table. All the data in the column will be lost.
5 |
6 | */
7 | -- AlterTable
8 | ALTER TABLE "post" DROP COLUMN "createdAt",
9 | ADD COLUMN "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
10 |
--------------------------------------------------------------------------------
/modules/auth/src/main/scala/io/conduktor/api/config/Auth0Config.scala:
--------------------------------------------------------------------------------
1 | package io.conduktor.api.config
2 |
3 | import eu.timepit.refined.types.string.NonEmptyString
4 |
5 | import zio.duration.{Duration, durationInt}
6 |
7 | final case class Auth0Config(domain: NonEmptyString, audience: Option[NonEmptyString]) {
8 | val cacheSize: Int = 100
9 | val ttl: Duration = 10.hours
10 | }
11 |
--------------------------------------------------------------------------------
/.github/workflows/draft.yml:
--------------------------------------------------------------------------------
1 | name: Release Drafter
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 |
8 | pull_request:
9 | types: [opened, reopened, synchronize]
10 |
11 | jobs:
12 | update_release_draft:
13 | runs-on: ubuntu-latest
14 | steps:
15 | - uses: release-drafter/release-drafter@v5
16 | env:
17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
18 |
--------------------------------------------------------------------------------
/modules/api/src/main/resources/db/migration/V1__20210322153653_add_published_to_post.sql:
--------------------------------------------------------------------------------
1 | -- CreateTable
2 | CREATE TABLE "post" (
3 | "id" TEXT NOT NULL,
4 | "title" TEXT NOT NULL,
5 | "published" BOOLEAN NOT NULL DEFAULT false,
6 | "author" TEXT NOT NULL,
7 | "content" TEXT NOT NULL,
8 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
9 |
10 | PRIMARY KEY ("id")
11 | );
12 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 |
2 | # Generated files
3 | bin/
4 | gen/
5 | out/
6 |
7 | # IntelliJ
8 | *.iml
9 | .idea
10 |
11 | # mpeltonen/sbt-idea plugin
12 | .idea_modules/
13 |
14 | dist/*
15 | target/
16 | lib_managed/
17 | src_managed/
18 | project/boot/
19 | project/plugins/project/
20 | .history
21 | .cache
22 | .lib/
23 | *.class
24 | *.log
25 |
26 | .metals/
27 | metals.sbt
28 | .bloop/
29 | project/secret
30 | .bsp/
31 | .vscode/
32 |
33 | .env
34 | .env.*
--------------------------------------------------------------------------------
/.github/workflows/scala-steward.yml:
--------------------------------------------------------------------------------
1 | # This workflow will launch everyday at 00:00
2 | on:
3 | schedule:
4 | - cron: '0 0 * * *'
5 |
6 | name: Launch Scala Steward
7 |
8 | jobs:
9 | scala-steward:
10 | runs-on: ubuntu-latest
11 | name: Launch Scala Steward
12 | steps:
13 | - name: Launch Scala Steward
14 | uses: scala-steward-org/scala-steward-action@v2
15 | with:
16 | github-token: ${{ secrets.SCALA_STEWARD }}
--------------------------------------------------------------------------------
/modules/api/src/main/resources/logback.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | %d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n
6 |
7 |
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/modules/postgres/src/main/scala/io/conduktor/api/config/DBConfig.scala:
--------------------------------------------------------------------------------
1 | package io.conduktor.api.config
2 |
3 | import io.conduktor.primitives.types.Secret
4 |
5 | final case class DBConfig(
6 | user: String,
7 | password: Option[Secret],
8 | host: String,
9 | port: Int,
10 | database: String,
11 | maxPoolSize: Int,
12 | gcpInstance: Option[String],
13 | // flyway baseline migration. If 1, only migrations > V1 will apply. Should be None on new database
14 | baselineVersion: Option[String],
15 | migrate: Boolean,
16 | ssl: Boolean
17 | )
18 |
--------------------------------------------------------------------------------
/modules/api/src/main/resources/db/migration/V3__20210322154908_type_uuid.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - The migration will change the primary key for the `post` table. If it partially fails, the table could be left without primary key constraint.
5 | - Changed the type of `id` on the `post` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required.
6 |
7 | */
8 | -- AlterTable
9 | ALTER TABLE "post" DROP CONSTRAINT "post_pkey",
10 | DROP COLUMN "id",
11 | ADD COLUMN "id" UUID NOT NULL,
12 | ADD PRIMARY KEY ("id");
13 |
--------------------------------------------------------------------------------
/modules/auth/src/main/scala/io/conduktor/api/model/User.scala:
--------------------------------------------------------------------------------
1 | package io.conduktor.api.model
2 |
3 | import eu.timepit.refined.types.all.NonEmptyString
4 | import io.circe.Decoder
5 | import io.circe.generic.semiauto.deriveDecoder
6 | import io.conduktor.primitives.types.Email
7 |
8 | final case class User(email: Email)
9 | object User {
10 | import io.circe.refined._
11 | import io.estatico.newtype.ops._
12 |
13 | implicit val emailDecoder: Decoder[Email] = Decoder[NonEmptyString].coerce[Decoder[Email]]
14 |
15 | // Depending on the requirements, use a custom decoder here to extract data from the claims
16 | implicit val userDecoder: Decoder[User] = deriveDecoder
17 | }
18 |
--------------------------------------------------------------------------------
/modules/posts/src/main/scala/io/conduktor/api/repository/db/db.scala:
--------------------------------------------------------------------------------
1 | package io.conduktor.api.repository
2 |
3 | import eu.timepit.refined.types.all.NonEmptyString
4 | import io.conduktor.primitives.types.UserName
5 | import io.estatico.newtype.ops.toCoercibleIdOps
6 | import skunk.Codec
7 | import skunk.codec.all.{text, timestamp}
8 |
9 | import java.time.LocalDateTime
10 |
11 | package object db {
12 |
13 | final val createdAt: Codec[LocalDateTime] = timestamp(3)
14 |
15 | final val nonEmptyText: Codec[NonEmptyString] =
16 | text.imap[NonEmptyString](NonEmptyString.unsafeFrom)(_.value)
17 |
18 | implicit final val usernameCodec: skunk.Codec[UserName] = db.nonEmptyText.coerce[skunk.Codec[UserName]]
19 |
20 | }
21 |
--------------------------------------------------------------------------------
/modules/common/src/main/scala/io/conduktor/primitives/types/package.scala:
--------------------------------------------------------------------------------
1 | package io.conduktor.primitives
2 |
3 | import eu.timepit.refined.types.all.NonEmptyString
4 | import io.estatico.newtype.macros.newtype
5 |
6 | package object types {
7 | @newtype final case class Email(value: NonEmptyString) {
8 | def getUserName: Either[String, UserName] = value.value match {
9 | case s"$name@$_" => NonEmptyString.from(name).map(UserName.apply)
10 | case _ => Left("No username")
11 | }
12 | }
13 | @newtype final case class UserName(value: NonEmptyString)
14 |
15 | @newtype final case class Secret(_secret: NonEmptyString) {
16 | def unwrapValue: String = _secret.value
17 | override def toString: String = "*****"
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/modules/posts/src/main/scala/io/conduktor/api/model/Post.scala:
--------------------------------------------------------------------------------
1 | package io.conduktor.api.model
2 |
3 | import cats.Show
4 | import eu.timepit.refined.types.string.NonEmptyString
5 | import io.conduktor.api.model.Post.{Content, Id, Title}
6 | import io.conduktor.primitives.types.UserName
7 | import io.estatico.newtype.macros.newtype
8 |
9 | import java.util.UUID
10 |
11 | final case class Post(
12 | id: Id,
13 | title: Title,
14 | author: UserName,
15 | published: Boolean,
16 | content: Content
17 | )
18 | object Post {
19 | @newtype case class Id(value: UUID)
20 |
21 | @newtype case class Title(value: NonEmptyString)
22 | object Title {
23 | import eu.timepit.refined.cats._
24 | implicit final val show: Show[Title] = deriving
25 | }
26 | @newtype case class Content(value: String)
27 |
28 | }
29 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on: [pull_request]
4 |
5 | jobs:
6 | check:
7 | runs-on: ubuntu-latest
8 | steps:
9 | - uses: actions/checkout@v3
10 | - uses: coursier/cache-action@v6
11 | - uses: actions/setup-java@v3
12 | with:
13 | distribution: temurin
14 | java-version: 11
15 | check-latest: true
16 | - name: Run scalafix and scalafmt
17 | run: sbt ';scalafixAll --check ;scalafmtCheckAll '
18 |
19 | ci:
20 | runs-on: ubuntu-latest
21 | steps:
22 | - uses: actions/checkout@v3
23 | - uses: coursier/cache-action@v6
24 | - uses: actions/setup-java@v3
25 | with:
26 | distribution: temurin
27 | java-version: 11
28 | check-latest: true
29 | - name: Run tests
30 | run: sbt clean test
--------------------------------------------------------------------------------
/.scalafix.conf:
--------------------------------------------------------------------------------
1 | rules = [
2 | Disable
3 | DisableSyntax
4 | ExplicitResultTypes
5 | LeakingImplicitClassVal
6 | NoAutoTupling
7 | NoValInForComprehension
8 | OrganizeImports
9 | ProcedureSyntax
10 | RemoveUnused
11 | MissingFinal
12 | ]
13 |
14 | Disable {
15 | ifSynthetic = [
16 | "scala/Option.option2Iterable"
17 | "scala/Predef.any2stringadd"
18 | ]
19 | }
20 |
21 | OrganizeImports {
22 | removeUnused = true
23 |
24 | expandRelative = true
25 | groupedImports = Merge
26 | groups = [
27 | "re:javax?\\.",
28 | "scala.",
29 | "*",
30 | "zio."
31 | ]
32 | }
33 |
34 | RemoveUnused {
35 | imports = false # handled by OrganizeImports
36 | }
37 |
38 | DisableSyntax.noReturns = true
39 | DisableSyntax.noXml = true
40 | DisableSyntax.noFinalize = true
41 | DisableSyntax.noValPatterns = true
42 |
--------------------------------------------------------------------------------
/modules/postgres/src/test/scala/io/conduktor/api/db/EmbeddedPostgres.scala:
--------------------------------------------------------------------------------
1 | package io.conduktor.api.db
2 |
3 | import com.opentable.db.postgres.embedded.{EmbeddedPostgres => Postgres}
4 | import io.conduktor.api.config.DBConfig
5 |
6 | import zio.{Has, Task, ZLayer, ZManaged}
7 |
8 | object EmbeddedPostgres {
9 |
10 | val pgLayer: ZLayer[Any, Throwable, Has[DBConfig]] =
11 | ZLayer.fromManaged(ZManaged.make(Task(Postgres.start()))(pg => Task(pg.close()).orDie).map { pg =>
12 | DBConfig(
13 | user = "postgres",
14 | password = None,
15 | host = "localhost",
16 | port = pg.getPort,
17 | database = "postgres",
18 | maxPoolSize = 5,
19 | gcpInstance = None,
20 | ssl = false,
21 | migrate = false,
22 | baselineVersion = None
23 | )
24 | })
25 | }
26 |
--------------------------------------------------------------------------------
/.scalafmt.conf:
--------------------------------------------------------------------------------
1 | version = "3.7.3"
2 | runner.dialect = Scala213Source3 # https://scalameta.org/scalafmt/docs/configuration.html#scala-2-with--xsource3
3 | maxColumn = 140
4 | align.preset = most
5 | continuationIndent.defnSite = 2
6 | assumeStandardLibraryStripMargin = true
7 | docstrings.style = Asterisk
8 | lineEndings = preserve
9 | includeCurlyBraceInSelectChains = false
10 | danglingParentheses.preset = true
11 | optIn.annotationNewlines = true
12 | newlines.alwaysBeforeMultilineDef = false
13 | trailingCommas = preserve
14 |
15 | rewrite.rules = [RedundantBraces, SortModifiers]
16 |
17 | rewrite.sortModifiers.order = [
18 | "implicit", "override", "private", "protected", "final", "sealed", "abstract", "lazy"
19 | ]
20 | rewrite.redundantBraces.generalExpressions = false
21 | rewriteTokens = {
22 | "⇒": "=>"
23 | "→": "->"
24 | "←": "<-"
25 | }
26 |
--------------------------------------------------------------------------------
/modules/api/src/main/scala/io/conduktor/api/http/health/HealthRoutes.scala:
--------------------------------------------------------------------------------
1 | package io.conduktor.api.http.health
2 |
3 | import io.conduktor.api.http.endpoints.baseEndpoint
4 | import sttp.capabilities.zio.ZioStreams
5 | import sttp.tapir.ztapir._
6 |
7 | import zio.ZIO
8 |
9 | object HealthRoutes {
10 |
11 | object Endpoints {
12 | def healthEndpoint: ZServerEndpoint[Any, ZioStreams] = baseEndpoint.get
13 | .in("health")
14 | .out(emptyOutput)
15 | .zServerLogic(_ => ZIO.unit)
16 |
17 | def helloWorldEndpoint: ZServerEndpoint[Any, ZioStreams] = baseEndpoint.get
18 | .in("hello-world")
19 | .out(stringBody)
20 | .zServerLogic(_ => ZIO.effectTotal("hello world !"))
21 |
22 | val all: List[ZServerEndpoint[Any, ZioStreams]] = List(
23 | healthEndpoint,
24 | helloWorldEndpoint
25 | )
26 | }
27 |
28 | }
29 |
--------------------------------------------------------------------------------
/project/plugins.sbt:
--------------------------------------------------------------------------------
1 | addSbtPlugin("com.github.sbt" % "sbt-native-packager" % "1.9.16")
2 | addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.10.4")
3 | addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.0")
4 | addSbtPlugin("pl.project13.scala" % "sbt-jmh" % "0.4.4")
5 | addSbtPlugin("com.timushev.sbt" % "sbt-updates" % "0.6.4")
6 | addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.11.0")
7 | addSbtPlugin("ch.epfl.scala" % "sbt-bloop" % "1.5.6")
8 | //addSbtPlugin("com.github.cb372" % "sbt-explicit-dependencies" % "0.2.16")
9 | addSbtPlugin("nl.gn0s1s" % "sbt-dotenv" % "3.0.0")
10 | addSbtPlugin("io.spray" % "sbt-revolver" % "0.10.0")
11 | addSbtPlugin("io.github.davidgregory084" % "sbt-tpolecat" % "0.4.2")
12 |
--------------------------------------------------------------------------------
/modules/api/src/test/scala/io/conduktor/api/ServerTestLayers.scala:
--------------------------------------------------------------------------------
1 | package io.conduktor.api
2 |
3 | import eu.timepit.refined.auto._
4 | import io.conduktor.api.auth.AuthService
5 | import io.conduktor.api.auth.AuthService.AuthToken
6 | import io.conduktor.api.config._
7 | import io.conduktor.api.db.{EmbeddedPostgres, FlywayDatabaseMigrationService}
8 | import io.conduktor.api.model.User
9 | import io.conduktor.primitives.types
10 |
11 | import zio.{Has, Task, TaskLayer, ULayer, ZLayer}
12 |
13 | object ServerTestLayers {
14 |
15 | val localDB: TaskLayer[Has[DBConfig]] = EmbeddedPostgres.pgLayer.tap { conf =>
16 | FlywayDatabaseMigrationService.layer.build.useNow
17 | .flatMap(_.get.migrate())
18 | .provide(conf)
19 | }
20 |
21 | val dummyAuth: ULayer[Has[AuthService]] = ZLayer.succeed((_: AuthToken) => Task.succeed(User(types.Email("john.doe@conduktor.io"))))
22 |
23 | val randomPortHttpConfig: ULayer[Has[HttpConfig]] = ZLayer.succeed(HttpConfig(0))
24 |
25 | }
26 |
--------------------------------------------------------------------------------
/modules/postgres/src/test/scala/io/conduktor/api/db/DbSpec.scala:
--------------------------------------------------------------------------------
1 | package io.conduktor.api.db
2 |
3 | import io.conduktor.api.db.DbSessionPool.SessionTask
4 | import skunk.codec.all.int4
5 | import skunk.implicits.toStringOps
6 | import skunk.{Query, Void}
7 |
8 | import zio.magic._
9 | import zio.test.Assertion._
10 | import zio.test._
11 | import zio.{TaskManaged, ZIO}
12 |
13 | object DbSpec extends DefaultRunnableSpec {
14 |
15 | override def spec: ZSpec[environment.TestEnvironment, Any] = suite("test technical db details")(
16 | testM("execute a simple query using a session from DbSessionPool") {
17 |
18 | val query: Query[Void, Int] = sql"SELECT 1".query(int4)
19 |
20 | (for {
21 | pool <- ZIO.service[TaskManaged[SessionTask]]
22 | res <- pool.use { session =>
23 | session.unique(query)
24 | }
25 | } yield assert(res)(equalTo(1))).inject(DbSessionPool.layer, EmbeddedPostgres.pgLayer)
26 | }
27 | )
28 |
29 | }
30 |
--------------------------------------------------------------------------------
/modules/api/src/main/scala/io/conduktor/api/http/Errors.scala:
--------------------------------------------------------------------------------
1 | package io.conduktor.api.http
2 |
3 | import io.circe.Codec
4 | import io.circe.generic.semiauto.deriveCodec
5 |
6 | sealed trait ErrorInfo
7 | case object Unauthorized extends ErrorInfo
8 | case object Forbidden extends ErrorInfo
9 | case object NoContent extends ErrorInfo
10 | final case class NotFound(what: String) extends ErrorInfo
11 | object NotFound {
12 | implicit val notFoundCodec: Codec[NotFound] = deriveCodec
13 | }
14 | final case class Conflict(msg: String) extends ErrorInfo
15 | object Conflict {
16 | implicit val conflictCodec: Codec[Conflict] = deriveCodec
17 | }
18 | final case class BadRequest(msg: String) extends ErrorInfo
19 | object BadRequest {
20 | implicit val badRequestCodec: Codec[BadRequest] = deriveCodec
21 | }
22 | final case class ServerError(msg: String) extends ErrorInfo
23 | object ServerError {
24 | implicit val serverErrorCodec: Codec[ServerError] = deriveCodec
25 | }
26 |
--------------------------------------------------------------------------------
/modules/posts/src/test/scala/io/conduktor/api/db/DbRepositorySpec.scala:
--------------------------------------------------------------------------------
1 | package io.conduktor.api.db
2 |
3 | import io.conduktor.api.db.DbSessionPool.SessionTask
4 | import io.conduktor.api.repository.PostRepository
5 | import skunk.implicits.toStringOps
6 | import zio.test.environment.TestEnvironment
7 | import zio.test.{DefaultRunnableSpec, ZSpec}
8 | import zio.{Has, TaskManaged}
9 |
10 | object DbRepositorySpec extends DefaultRunnableSpec {
11 |
12 | private def initTables(x: Has[TaskManaged[SessionTask]]) =
13 | x.get.use { session =>
14 | session.execute(sql"""
15 | CREATE TABLE "post" (
16 | "id" UUID NOT NULL,
17 | "title" TEXT NOT NULL,
18 | "published" BOOLEAN NOT NULL DEFAULT false,
19 | "author" TEXT NOT NULL,
20 | "content" TEXT NOT NULL,
21 | "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
22 |
23 | PRIMARY KEY ("id")
24 | )""".command)
25 | }
26 |
27 | val repoLayer = (EmbeddedPostgres.pgLayer >>> DbSessionPool.layer.tap(initTables) >>> PostRepository.Pool.live).orDie
28 |
29 | override def spec: ZSpec[TestEnvironment, Any] = RepositorySpec.spec(repositoryType = "database").provideCustomLayer(repoLayer)
30 |
31 | }
32 |
--------------------------------------------------------------------------------
/.github/release-drafter.yml:
--------------------------------------------------------------------------------
1 | name-template: '$RESOLVED_VERSION'
2 | tag-template: '$RESOLVED_VERSION'
3 |
4 | version-resolver:
5 | major:
6 | labels:
7 | - 'major'
8 | minor:
9 | labels:
10 | - 'minor'
11 | patch:
12 | labels:
13 | - 'patch'
14 | default: minor
15 |
16 | categories:
17 | - title: 'Features'
18 | label: 'enhancement'
19 | - title: 'Bug Fixes'
20 | label: 'bug'
21 |
22 | exclude-labels:
23 | - 'skip'
24 |
25 | autolabeler:
26 | - label: 'bug'
27 | title:
28 | - '/.*\[fix\].*/'
29 | - label: 'patch'
30 | title:
31 | - '/.*\[fix\].*/'
32 | - label: 'enhancement'
33 | title:
34 | - '/.*\[feat\].*/'
35 | - label: 'minor'
36 | title:
37 | - '/.*\[feat\].*/'
38 | - label: 'skip'
39 | title:
40 | - '/.*\[skip\].*/'
41 | - label: 'major'
42 | title:
43 | - '/.*\[breaking\].*/'
44 |
45 | replacers:
46 | - search: '/\[feat\]/g'
47 | replace: ''
48 | - search: '/\[fix\]/g'
49 | replace: ''
50 | - search: '/\[skip\]/g'
51 | replace: ''
52 | - search: '/\[breaking\]/g'
53 | replace: ''
54 |
55 | template: |
56 | # What's Changed
57 |
58 | $CHANGES
59 |
60 |
--------------------------------------------------------------------------------
/modules/api/src/main/scala/io/conduktor/api/http/RoutesInterpreter.scala:
--------------------------------------------------------------------------------
1 | package io.conduktor.api.http
2 |
3 | import io.conduktor.api.http.health.HealthRoutes
4 | import org.http4s.HttpRoutes
5 | import sttp.capabilities.zio.ZioStreams
6 | import sttp.tapir.server.http4s.ztapir.ZHttp4sServerInterpreter
7 | import sttp.tapir.swagger.bundle.SwaggerInterpreter
8 | import sttp.tapir.ztapir._
9 |
10 | import zio.RIO
11 | import zio.blocking.Blocking
12 | import zio.clock.Clock
13 |
14 | /*
15 | Interpret Tapir endpoints as Http4s routes
16 | */
17 | object RoutesInterpreter {
18 |
19 | type Env = posts.v1.PostRoutes.Env
20 |
21 | val endpoints: List[ZServerEndpoint[Env, ZioStreams]] =
22 | HealthRoutes.Endpoints.all.map(_.widen[Env]) ++
23 | posts.v1.PostRoutes.Endpoints.all
24 |
25 | val swaggerRoute: HttpRoutes[RIO[Env with Clock with Blocking, *]] =
26 | ZHttp4sServerInterpreter[Env]()
27 | .from(
28 | SwaggerInterpreter()
29 | .fromServerEndpoints[RIO[Env, *]](endpoints, "Template API", "1.0")
30 | )
31 | .toRoutes
32 |
33 | val appRoutes: HttpRoutes[RIO[Env with Clock with Blocking, *]] =
34 | ZHttp4sServerInterpreter[Env]()
35 | .from(endpoints)
36 | .toRoutes
37 | }
38 |
--------------------------------------------------------------------------------
/modules/postgres/src/main/scala/io/conduktor/api/db/DatabaseMigration.scala:
--------------------------------------------------------------------------------
1 | package io.conduktor.api.db
2 |
3 | import scala.util.chaining.scalaUtilChainingOps
4 |
5 | import io.conduktor.api.config.DBConfig
6 | import org.flywaydb.core.Flyway
7 |
8 | import zio._
9 |
10 | trait DatabaseMigrationService {
11 | def migrate(): Task[Unit]
12 | }
13 |
14 | object DatabaseMigrationService {
15 | def migrate(): ZIO[Has[DatabaseMigrationService], Throwable, Unit] = ZIO.serviceWith(_.migrate())
16 | }
17 |
18 | final class FlywayDatabaseMigrationService(config: DBConfig) extends DatabaseMigrationService {
19 |
20 | def migrate(): Task[Unit] = Task {
21 | Flyway
22 | .configure()
23 | .dataSource(
24 | s"jdbc:postgresql://${config.host}:${config.port}/${config.database}",
25 | config.user,
26 | config.password.map(_.unwrapValue).orNull
27 | )
28 | .pipe(fw =>
29 | config.baselineVersion.fold(fw)(
30 | fw.baselineOnMigrate(true)
31 | .baselineVersion(_)
32 | )
33 | )
34 | .load()
35 | .migrate()
36 | }.unit
37 |
38 | }
39 |
40 | object FlywayDatabaseMigrationService {
41 | val layer: URLayer[Has[DBConfig], Has[DatabaseMigrationService]] = (new FlywayDatabaseMigrationService(_)).toLayer
42 | }
43 |
--------------------------------------------------------------------------------
/modules/postgres/src/main/scala/io/conduktor/api/db/DbSessionPool.scala:
--------------------------------------------------------------------------------
1 | package io.conduktor.api.db
2 |
3 | import cats.effect.std.{Console => CatsConsole}
4 | import io.conduktor.api.config.DBConfig
5 | import natchez.Trace.Implicits.noop
6 | import skunk.{SSL, Session, Strategy}
7 |
8 | import zio.interop.catz._
9 | import zio.interop.catz.implicits._
10 | import zio.{Has, Task, TaskManaged, ZLayer, ZManaged}
11 |
12 | object DbSessionPool {
13 |
14 | type SessionTask = Session[Task]
15 |
16 | val layer: ZLayer[Has[DBConfig], Throwable, Has[TaskManaged[SessionTask]]] = {
17 | implicit val console: CatsConsole[Task] = CatsConsole.make[Task]
18 |
19 | (for {
20 | conf <- ZManaged.service[DBConfig]
21 | pool <- Session
22 | .pooled[Task](
23 | host = conf.host,
24 | port = conf.port,
25 | user = conf.user,
26 | database = conf.database,
27 | password = conf.password.map(_.unwrapValue),
28 | max = conf.maxPoolSize,
29 | strategy = Strategy.SearchPath,
30 | ssl = if (conf.ssl) SSL.Trusted else SSL.None
31 | )
32 | .toManagedZIO
33 | } yield pool.toManagedZIO).toLayer
34 | }
35 |
36 | }
37 |
--------------------------------------------------------------------------------
/modules/posts/src/main/scala/io/conduktor/api/repository/db/Fragments.scala:
--------------------------------------------------------------------------------
1 | package io.conduktor.api.repository.db
2 |
3 | import eu.timepit.refined.types.string.NonEmptyString
4 | import io.conduktor.primitives.types.UserName
5 | import skunk.{Command, Fragment, Query}
6 | import skunk.codec.all.{text, uuid}
7 | import skunk.implicits.toStringOps
8 |
9 | import java.util.UUID
10 |
11 | private[db] object Fragments {
12 | val fullPostFields: Fragment[skunk.Void] = sql"id, title, author, content, published, created_at"
13 | val byId: Fragment[UUID] = sql"where id = $uuid"
14 | val byTitle: Fragment[NonEmptyString] = sql"where title = $nonEmptyText"
15 |
16 | def postQuery[A](where: Fragment[A]): Query[A, PostDb] =
17 | sql"SELECT $fullPostFields FROM post $where".query(PostDb.codec)
18 |
19 | def postDelete[A](where: Fragment[A]): Command[A] =
20 | sql"DELETE FROM post $where".command
21 |
22 | // using a Query to retrieve user
23 | def postCreate: Query[(UUID, NonEmptyString, UserName, String), PostDb] =
24 | sql"""
25 | INSERT INTO post (id, title, author, content)
26 | VALUES ($uuid, $nonEmptyText, $usernameCodec, $text)
27 | RETURNING $fullPostFields
28 | """
29 | .query(PostDb.codec)
30 | .gcontramap[(UUID, NonEmptyString, UserName, String)]
31 | }
32 |
--------------------------------------------------------------------------------
/modules/posts/src/test/scala/io/conduktor/api/service/PostServiceSpec.scala:
--------------------------------------------------------------------------------
1 | package io.conduktor.api.service
2 |
3 | import eu.timepit.refined.auto._
4 | import io.conduktor.api.db.MemoryRepositorySpec
5 | import io.conduktor.api.model.Post
6 | import io.conduktor.api.service.PostService.DuplicatePostError
7 | import io.conduktor.primitives.types.UserName
8 | import zio.ZIO
9 | import zio.logging.Logging
10 | import zio.magic._
11 | import zio.test.Assertion.{equalTo, isLeft, isRight}
12 | import zio.test.environment.TestEnvironment
13 | import zio.test.{DefaultRunnableSpec, ZSpec, assert}
14 |
15 | object PostServiceSpec extends DefaultRunnableSpec {
16 | override def spec: ZSpec[TestEnvironment, Any] = suite("PostService")(
17 | testM("should fail to create two posts with the same title") {
18 | for {
19 | postService <- ZIO.service[PostService]
20 | r1 <- postService.createPost(author = UserName("ray"), title = Post.Title("title"), content = Post.Content("content1")).either
21 | r2 <- postService.createPost(author = UserName("ray"), title = Post.Title("title"), content = Post.Content("content2")).either
22 |
23 | } yield assert(r1)(isRight) && assert(r2)(isLeft(equalTo(DuplicatePostError(Post.Title("title")))))
24 | }
25 | ).injectCustom(MemoryRepositorySpec.testLayer, Logging.ignore, PostServiceLive.layer)
26 | }
27 |
--------------------------------------------------------------------------------
/modules/posts/src/main/scala/io/conduktor/api/repository/PostRepository.scala:
--------------------------------------------------------------------------------
1 | package io.conduktor.api.repository
2 |
3 | import io.conduktor.api.db.DbSessionPool.SessionTask
4 | import io.conduktor.api.model.Post
5 | import io.conduktor.api.repository.PostRepository.Error
6 | import io.conduktor.api.repository.db.DbPostRepository
7 | import io.conduktor.primitives.types.UserName
8 | import zio.{Has, IO, Managed, TaskManaged, ZLayer}
9 |
10 | trait PostRepository {
11 | def createPost(id: Post.Id, title: Post.Title, author: UserName, content: Post.Content): IO[Error, Post]
12 |
13 | def findPostByTitle(title: Post.Title): IO[Error, Option[Post]]
14 |
15 | def deletePost(id: Post.Id): IO[Error, Unit]
16 |
17 | def findPostById(id: Post.Id): IO[Error, Post]
18 |
19 | // TODO paginated, zio stream
20 | def allPosts: IO[Error, List[
21 | Post
22 | ]]
23 | }
24 |
25 | object PostRepository extends zio.Accessible[PostRepository] {
26 |
27 | type Pool = Managed[Error.Unexpected, PostRepository]
28 | object Pool {
29 | def live: ZLayer[Has[TaskManaged[SessionTask]], Throwable, Has[Pool]] =
30 | (DbPostRepository.managed _).toLayer
31 | }
32 |
33 | sealed trait Error
34 | object Error {
35 | final case class PostNotFound(id: Post.Id) extends Error
36 | final case class Unexpected(throwable: Throwable) extends Error
37 | }
38 |
39 | }
40 |
--------------------------------------------------------------------------------
/modules/api/src/main/scala/io/conduktor/api/http/Server.scala:
--------------------------------------------------------------------------------
1 | package io.conduktor.api.http
2 |
3 | import cats.syntax.all._
4 | import io.conduktor.api.config.HttpConfig
5 | import io.conduktor.api.http.posts.v1.PostRoutes
6 | import org.http4s.blaze.server.BlazeServerBuilder
7 | import org.http4s.server.middleware._
8 | import org.http4s.server.{Router, Server}
9 |
10 | import zio.blocking.Blocking
11 | import zio.clock.Clock
12 | import zio.interop.catz._
13 | import zio.{Has, RIO, RLayer, ZManaged}
14 |
15 | object Server {
16 |
17 | type ServerEnv = Clock with Blocking with PostRoutes.Env with Has[HttpConfig]
18 |
19 | // In production you will want to restrict CORS config.
20 | val corsPolicy: CORSPolicy = CORS.policy
21 |
22 | // Starting the server (as a layer to simplify testing)
23 | val serve: ZManaged[ServerEnv, Throwable, Server] =
24 | ZManaged.runtime[ServerEnv].flatMap { implicit runtime =>
25 | for {
26 | conf <- ZManaged.service[HttpConfig]
27 | server <-
28 | BlazeServerBuilder[RIO[PostRoutes.Env with Clock with Blocking, *]]
29 | .withExecutionContext(runtime.platform.executor.asEC)
30 | .bindHttp(conf.port, "0.0.0.0")
31 | .withHttpApp(corsPolicy(Router("/" -> (RoutesInterpreter.appRoutes <+> RoutesInterpreter.swaggerRoute)).orNotFound))
32 | .resource
33 | .toManagedZIO
34 | } yield server
35 | }
36 |
37 | val layer: RLayer[ServerEnv, Has[Server]] = serve.toLayer
38 |
39 | }
40 |
--------------------------------------------------------------------------------
/modules/posts/src/test/scala/io/conduktor/api/db/RepositorySpec.scala:
--------------------------------------------------------------------------------
1 | package io.conduktor.api.db
2 |
3 | import eu.timepit.refined.auto._
4 | import io.conduktor.api.model.Post
5 | import io.conduktor.api.repository.PostRepository
6 | import io.conduktor.primitives.types.UserName
7 | import zio.random.Random
8 | import zio.test.Assertion.equalTo
9 | import zio.test.environment.TestEnvironment
10 | import zio.test.{ZSpec, assert, suite, testM}
11 | import zio.{Has, ZIO}
12 |
13 | object RepositorySpec {
14 |
15 | def spec(repositoryType: String): ZSpec[TestEnvironment with Has[PostRepository.Pool] with Random, Any] =
16 | suite(s"test the behavior of the repository $repositoryType")(
17 | testM(s"a created post can be retrieved by id") {
18 | // FIXME: inject database schema properly
19 | for {
20 | random <- ZIO.service[Random.Service]
21 | postId <- random.nextUUID.map(Post.Id.apply)
22 | pool <- ZIO.service[PostRepository.Pool]
23 | actual <- pool.use(repo =>
24 | repo.createPost(
25 | id = postId,
26 | title = Post.Title("hello"),
27 | author = UserName("bob"),
28 | content = Post.Content("testing")
29 | ) *> repo.findPostById(postId)
30 | )
31 | } yield assert(actual)(
32 | equalTo(
33 | Post(id = postId, title = Post.Title("hello"), author = UserName("bob"), published = false, content = Post.Content("testing"))
34 | )
35 | )
36 | }
37 | )
38 | }
39 |
--------------------------------------------------------------------------------
/modules/posts/src/main/scala/io/conduktor/api/repository/db/SkunkExtensions.scala:
--------------------------------------------------------------------------------
1 | package io.conduktor.api.repository.db
2 |
3 | import io.conduktor.api.repository.PostRepository.Error
4 | import skunk.{Command, Query, Session}
5 | import skunk.data.Completion
6 | import zio.{IO, Managed, Task, TaskManaged}
7 | import zio.interop.catz._
8 |
9 | private[db] object SkunkExtensions {
10 |
11 | implicit final class ManagedOps[A](private val self: TaskManaged[A]) extends AnyVal {
12 | def wrapException: Managed[Error.Unexpected, A] = self.mapError(Error.Unexpected)
13 | }
14 | implicit final class ZioOps[A](private val self: Task[A]) extends AnyVal {
15 | def wrapException: IO[Error.Unexpected, A] = self.mapError(Error.Unexpected)
16 | }
17 |
18 | implicit final class CommandOps[A](private val self: Command[A]) extends AnyVal {
19 | def execute(a: A)(implicit session: Session[Task]): IO[Error.Unexpected, Completion] =
20 | session.prepare(self).use(_.execute(a)).wrapException
21 | }
22 | implicit final class QueryOps[A, B](private val self: Query[A, B]) extends AnyVal {
23 | def option(a: A)(implicit session: Session[Task]): IO[Error.Unexpected, Option[B]] =
24 | session.prepare(self).use(_.option(a)).wrapException
25 | def unique(a: A)(implicit session: Session[Task]): IO[Error.Unexpected, B] = session.prepare(self).use(_.unique(a)).wrapException
26 | def list(a: A, chunkSize: Int)(implicit session: Session[Task]): IO[Error.Unexpected, List[B]] =
27 | session.prepare(self).use(_.stream(a, chunkSize).compile.toList).wrapException
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/project/BuildHelper.scala:
--------------------------------------------------------------------------------
1 | import com.typesafe.sbt.packager.Keys.{daemonUser, maintainer, packageName}
2 | import com.typesafe.sbt.packager.docker.DockerPlugin.autoImport._
3 | import sbt.Keys._
4 | import sbt._
5 |
6 | object BuildHelper {
7 |
8 | val commonSettings = Seq(
9 | libraryDependencies += compilerPlugin("org.typelevel" %% "kind-projector" % "0.13.2" cross CrossVersion.full),
10 | addCompilerPlugin("com.olegpy" %% "better-monadic-for" % "0.3.1"),
11 | javacOptions ++= Seq("-source", "11", "-target", "11"),
12 | scalacOptions ++= Seq("-Ymacro-annotations", "-Xsource:3", "-target:11"),
13 | testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework"),
14 | (Test / parallelExecution) := true,
15 | (Test / fork) := true
16 | ) ++ noDoc
17 |
18 | lazy val dockerSettings = Seq(
19 | Docker / maintainer := "Conduktor Inc ",
20 | Docker / daemonUser := "conduktor",
21 | Docker / dockerRepository := Some("eu.gcr.io"),
22 | Docker / packageName := sys.env.getOrElse("DOCKER_PACKAGE", ""),
23 | dockerUpdateLatest := true,
24 | dockerExposedPorts := Seq(8080),
25 | dockerBaseImage := "adoptopenjdk/openjdk11:alpine-jre"
26 | ) ++ sys.env.get("RELEASE_TAG").map(v => Seq(Docker / version := v)).getOrElse(Seq.empty)
27 |
28 | lazy val noDoc = Seq(
29 | (Compile / doc / sources) := Seq.empty,
30 | (Compile / packageDoc / publishArtifact) := false
31 | )
32 |
33 | /**
34 | * Copied from Cats
35 | */
36 | lazy val noPublishSettings = Seq(
37 | publish := {},
38 | publishLocal := {},
39 | publishM2 := {},
40 | publishArtifact := false
41 | )
42 | }
43 |
--------------------------------------------------------------------------------
/modules/api/src/main/scala/io/conduktor/api/http/posts/v1/Domain.scala:
--------------------------------------------------------------------------------
1 | package io.conduktor.api.http.posts.v1
2 |
3 | import java.util.UUID
4 |
5 | import eu.timepit.refined.types.string.NonEmptyString
6 | import io.circe.generic.semiauto.deriveCodec
7 | import io.circe.{Codec, Decoder, Encoder}
8 | import io.conduktor.api.model.Post
9 | import io.conduktor.primitives.types.UserName
10 | import sttp.tapir.Schema
11 | import sttp.tapir.codec.newtype.TapirCodecNewType
12 | import sttp.tapir.codec.refined.TapirCodecRefined
13 |
14 | private[v1] object Domain extends TapirCodecRefined with TapirCodecNewType {
15 |
16 | import io.circe.refined._
17 | import io.estatico.newtype.ops._
18 |
19 | implicit val usernameEncoder: Encoder[UserName] = Encoder[NonEmptyString].coerce[Encoder[UserName]]
20 | implicit val usernameDecoder: Decoder[UserName] = Decoder[NonEmptyString].coerce[Decoder[UserName]]
21 |
22 | final case class PostDTO(
23 | id: UUID,
24 | title: NonEmptyString,
25 | author: UserName,
26 | published: Boolean,
27 | content: String
28 | )
29 | object PostDTO {
30 | implicit final val encoder: Codec[PostDTO] = deriveCodec
31 | implicit val schema: Schema[PostDTO] = Schema.derived
32 |
33 | def from(p: Post): PostDTO =
34 | PostDTO(
35 | id = p.id.value,
36 | title = p.title.value,
37 | author = p.author,
38 | published = p.published,
39 | content = p.content.value
40 | )
41 | }
42 |
43 | final case class CreatePostInput(title: NonEmptyString, content: String)
44 | object CreatePostInput {
45 | implicit final val codec: Codec[CreatePostInput] = deriveCodec
46 | implicit val schema: Schema[CreatePostInput] = Schema.derived
47 |
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/modules/posts/src/test/scala/io/conduktor/api/db/MemoryRepositorySpec.scala:
--------------------------------------------------------------------------------
1 | package io.conduktor.api.db
2 |
3 | import io.conduktor.api.model.Post
4 | import io.conduktor.api.repository.PostRepository
5 | import io.conduktor.api.repository.PostRepository.Error
6 | import io.conduktor.primitives.types.UserName
7 | import zio.test.environment.TestEnvironment
8 | import zio.test.{DefaultRunnableSpec, ZSpec}
9 | import zio.{Has, IO, UIO, ULayer, ZManaged}
10 |
11 | import java.util.UUID
12 |
13 | object MemoryRepositorySpec extends DefaultRunnableSpec {
14 |
15 | class InMemoryPostRepository extends PostRepository {
16 |
17 | private val db = collection.mutable.Map[UUID, Post]()
18 |
19 | override def createPost(id: Post.Id, title: Post.Title, author: UserName, content: Post.Content): IO[Error, Post] = UIO {
20 | db(id.value) = Post(
21 | id = id,
22 | title = title,
23 | author = author,
24 | published = false,
25 | content = content
26 | )
27 | db(id.value)
28 | }
29 |
30 | override def findPostByTitle(title: Post.Title): IO[Error, Option[Post]] =
31 | UIO {
32 | db.values.find(_.title == title)
33 | }
34 |
35 | override def deletePost(id: Post.Id): IO[Error, Unit] = UIO {
36 | db.remove(id.value)
37 | ()
38 | }
39 |
40 | override def findPostById(id: Post.Id): IO[Error, Post] = UIO {
41 | db(id.value)
42 | }
43 |
44 | override def allPosts: IO[Error, List[Post]] = UIO {
45 | db.values.toList
46 | }
47 | }
48 |
49 | private val inMemoryRepo = new InMemoryPostRepository
50 | val testLayer: ULayer[Has[PostRepository.Pool]] =
51 | (() => ZManaged.succeed(inMemoryRepo)).toLayer
52 |
53 | override def spec: ZSpec[TestEnvironment, Any] = RepositorySpec.spec(repositoryType = "memory").provideCustomLayer(testLayer)
54 |
55 | }
56 |
--------------------------------------------------------------------------------
/modules/api/src/test/scala/io/conduktor/api/http/posts/v1/CodecSpec.scala:
--------------------------------------------------------------------------------
1 | package io.conduktor.api.http.posts.v1
2 |
3 | import cats.Eq
4 | import eu.timepit.refined.types.string.NonEmptyString
5 | import io.circe.testing.CodecTests
6 | import io.circe.testing.instances.arbitraryJson
7 | import io.conduktor.api.http.posts.v1.Domain.CreatePostInput
8 | import org.scalacheck.Test.{Failed, Result}
9 | import org.scalacheck.{Arbitrary, Gen, Test}
10 | import org.typelevel.discipline.Laws
11 |
12 | import zio.Task
13 | import zio.test.Assertion.{anything, isSubtype, not}
14 | import zio.test.{DefaultRunnableSpec, Spec, TestFailure, TestSuccess, assert}
15 |
16 | object CodecSpec extends DefaultRunnableSpec {
17 |
18 | val createPostInputCodec: CodecTests[CreatePostInput] = CodecTests[CreatePostInput]
19 |
20 | object Implicits {
21 | implicit val eqCreatePostInput: Eq[CreatePostInput] = Eq.fromUniversalEquals
22 |
23 | val genNonEmptyString: Gen[NonEmptyString] = Gen.alphaStr.filter(_.nonEmpty).map(NonEmptyString.unsafeFrom)
24 | implicit val genCreatePostInput: Arbitrary[CreatePostInput] = Arbitrary {
25 | for {
26 | title <- genNonEmptyString
27 | content <- Gen.alphaStr
28 | } yield CreatePostInput(title, content)
29 | }
30 | }
31 |
32 | import Implicits._
33 |
34 | val spec: Spec[Any, TestFailure[Throwable], TestSuccess] = suite("codecs should respect codec law")(
35 | checkLaw("CreatePostInput codec laws", createPostInputCodec.codec)
36 | )
37 |
38 | def checkLaw(testName: String, ruleSet: Laws#RuleSet): Spec[Any, TestFailure[Throwable], TestSuccess] =
39 | suite(testName)(
40 | ruleSet.all.properties.toList.map { case (id, prop) =>
41 | testM(s"$id $prop") {
42 | Task {
43 | Test.check(prop)(identity)
44 | }
45 | .map((result: Result) => assert(result.status)(not(isSubtype[Failed](anything))))
46 | }
47 | }: _*
48 | )
49 |
50 | }
51 |
--------------------------------------------------------------------------------
/.github/workflows/cd-staging.yml:
--------------------------------------------------------------------------------
1 | name: CD Staging
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 |
8 | jobs:
9 | cd:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - uses: actions/checkout@v3
13 | - uses: coursier/cache-action@v6
14 | - uses: actions/setup-java@v3
15 | with:
16 | distribution: temurin
17 | java-version: 11
18 | check-latest: true
19 |
20 | - name: Run tests
21 | run: sbt clean test
22 |
23 | - name: Setup GCP Service Account
24 | uses: google-github-actions/setup-gcloud@master
25 | with:
26 | version: 'latest'
27 | service_account_key: ${{ secrets.GCP_KEY_SECRET }}
28 | export_default_credentials: true
29 |
30 | - name: Configure Docker Registry
31 | run: |
32 | gcloud auth configure-docker
33 |
34 | - name: Build Docker Image
35 | run: |
36 | sbt "api / Docker / publish"
37 | env:
38 | DOCKER_PACKAGE: ${{ secrets.GCP_PROJECT_ID }}/api-template
39 |
40 | - name: Deploy Cloud Run
41 | run: |
42 | gcloud run deploy api-template \
43 | --region europe-west1 \
44 | --image eu.gcr.io/${{ secrets.GCP_PROJECT_ID }}/api-template:latest \
45 | --platform managed \
46 | --allow-unauthenticated \
47 | --cpu 1000m --memory 512Mi --max-instances 1 \
48 | --project ${{ secrets.GCP_PROJECT_ID }} \
49 | --update-env-vars DB_USER=${{ secrets.DB_USER }} \
50 | --update-env-vars DB_PASSWORD=${{ secrets.DB_PASSWORD }} \
51 | --update-env-vars DB_HOST=${{ secrets.DB_HOST }} \
52 | --update-env-vars DB_PORT=${{ secrets.DB_PORT }} \
53 | --update-env-vars DB_USE_SSL=false \
54 | --update-env-vars DB_DATABASE=api_template \
55 | --update-env-vars DB_MAX_POOL_SIZE=16 \
56 | --update-env-vars AUTH0_DOMAIN=${{ secrets.AUTH0_DOMAIN }} \
57 | --add-cloudsql-instances conduktor:europe-west1:conduktor-dev \
58 | --vpc-connector vpc-connector-to-cloudsql \
59 | --update-env-vars INSTANCE_CONNECTION_NAME="conduktor:europe-west1:conduktor-dev"
60 |
61 |
62 |
--------------------------------------------------------------------------------
/modules/api/src/main/scala/io/conduktor/api/config/AppConfig.scala:
--------------------------------------------------------------------------------
1 | package io.conduktor.api.config
2 |
3 | import eu.timepit.refined.predicates.all.NonEmpty
4 | import io.conduktor.primitives.types.Secret
5 | import io.estatico.newtype.ops.toCoercibleIdOps
6 |
7 | import zio.config.ConfigDescriptor._
8 | import zio.config._
9 | import zio.config.refined._
10 | import zio.{Has, ZLayer, system}
11 |
12 | final case class AppConfig(
13 | db: DBConfig,
14 | http: HttpConfig,
15 | auth0: Auth0Config
16 | )
17 | final case class HttpConfig(port: Int)
18 |
19 | private object DB {
20 | val config: ConfigDescriptor[DBConfig] =
21 | (string("DB_USER") zip
22 | refine[String, NonEmpty]("DB_PASSWORD").coerce[ConfigDescriptor[Secret]].optional zip
23 | string("DB_HOST") zip
24 | int("DB_PORT") zip
25 | string("DB_DATABASE") zip
26 | int("DB_MAX_POOL_SIZE") zip
27 | string("INSTANCE_CONNECTION_NAME").optional zip
28 | string("DB_BASELINE_VERSION").optional zip
29 | boolean("DB_MIGRATION").default(false) zip
30 | boolean("DB_USE_SSL").default(false)).to[DBConfig]
31 |
32 | val layer: ZLayer[system.System, ReadError[String], Has[DBConfig]] = ZConfig.fromSystemEnv(config)
33 | }
34 |
35 | private object Auth0 {
36 | val config: ConfigDescriptor[Auth0Config] =
37 | (
38 | refine[String, NonEmpty]("AUTH0_DOMAIN") zip
39 | refine[String, NonEmpty]("AUTH0_AUDIENCE").optional
40 | ).to[Auth0Config]
41 | }
42 | private object Http {
43 | val config: ConfigDescriptor[HttpConfig] = int("PORT").default(8080).to[HttpConfig]
44 | }
45 |
46 | object AppConfig {
47 | type HasAllConfigs = Has[Auth0Config] with Has[DBConfig] with Has[HttpConfig]
48 |
49 | private val configDesc: ConfigDescriptor[AppConfig] = (
50 | (DB.config zip
51 | Http.config zip
52 | Auth0.config).to[AppConfig]
53 | )
54 |
55 | val dbOnlyLayer: ZLayer[system.System, ReadError[String], Has[DBConfig]] = DB.layer
56 | val layer: ZLayer[system.System, ReadError[String], Has[AppConfig]] =
57 | ZConfig.fromSystemEnv(configDesc)
58 |
59 | // for layer config granularity
60 | val allLayers: ZLayer[system.System, ReadError[String], HasAllConfigs] =
61 | layer.to(
62 | ZLayer.fromServiceMany[AppConfig, HasAllConfigs] { case AppConfig(db, http, auth0) =>
63 | Has(db) ++ Has(http) ++ Has(auth0)
64 | }
65 | )
66 | }
67 |
--------------------------------------------------------------------------------
/modules/api/src/main/scala/io/conduktor/api/http/endpoints.scala:
--------------------------------------------------------------------------------
1 | package io.conduktor.api.http
2 |
3 | import io.conduktor.api.auth.AuthService
4 | import io.conduktor.api.auth.AuthService.AuthToken
5 | import io.conduktor.api.model.User
6 | import sttp.model.StatusCode
7 | import sttp.tapir.PublicEndpoint
8 | import sttp.tapir.codec.newtype._
9 | import sttp.tapir.codec.refined._
10 | import sttp.tapir.generic.auto._
11 | import sttp.tapir.json.circe.jsonBody
12 | import sttp.tapir.ztapir._
13 |
14 | import zio.{Has, ZIO}
15 |
16 | object endpoints {
17 |
18 | /**
19 | * Public endpoint
20 | */
21 | val baseEndpoint: PublicEndpoint[Unit, ErrorInfo, Unit, Any] = endpoint.errorOut(
22 | oneOf[ErrorInfo](
23 | oneOfVariant(StatusCode.NotFound, jsonBody[NotFound].description("not found")),
24 | oneOfVariant(StatusCode.BadRequest, jsonBody[BadRequest].description("bad request")),
25 | oneOfVariant(StatusCode.Unauthorized, emptyOutputAs(Unauthorized)),
26 | oneOfVariant(StatusCode.NoContent, emptyOutputAs(NoContent)),
27 | oneOfVariant(StatusCode.Forbidden, emptyOutputAs(Forbidden)),
28 | oneOfVariant(StatusCode.Conflict, jsonBody[Conflict].description("conflict")),
29 | oneOfVariant(StatusCode.InternalServerError, jsonBody[ServerError].description("server error"))
30 | // default is somehow broken since tapir 0.18, this leads to a ClassCastException on error
31 | // oneOfDefaultMapping(jsonBody[ServerError].description("unknown"))
32 | )
33 | )
34 |
35 | /**
36 | * User need a valid JWT and to pass the validation
37 | *
38 | * ex: assert that user is a member of your admin domain
39 | */
40 | def restrictedEndpoint(
41 | userFilter: User => Boolean
42 | ): ZPartialServerEndpoint[Has[AuthService], Option[AuthToken], User, Unit, ErrorInfo, Unit, Any] =
43 | baseEndpoint.securityIn(auth.bearer[Option[AuthToken]]()).zServerSecurityLogic { tokenOpt =>
44 | tokenOpt
45 | .map(token =>
46 | AuthService
47 | .auth(token)
48 | .orElseFail[ErrorInfo](Unauthorized)
49 | .filterOrFail(userFilter)(Forbidden)
50 | )
51 | .getOrElse(ZIO.fail(Unauthorized))
52 | }
53 |
54 | /**
55 | * Any user with a valid JWT can access this endpoint
56 | */
57 | val secureEndpoint: ZPartialServerEndpoint[Has[AuthService], Option[AuthToken], User, Unit, ErrorInfo, Unit, Any] =
58 | restrictedEndpoint(_ => true)
59 |
60 | }
61 |
--------------------------------------------------------------------------------
/.github/workflows/cd-prod.yml:
--------------------------------------------------------------------------------
1 | name: CD Prod
2 |
3 | on:
4 | push:
5 | tags:
6 | - '*'
7 |
8 | jobs:
9 | cd:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - uses: actions/checkout@v3
13 | - uses: coursier/cache-action@v6
14 | - uses: actions/setup-java@v3
15 | with:
16 | distribution: temurin
17 | java-version: 11
18 | check-latest: true
19 |
20 | - name: Get the version
21 | id: get_version
22 | run: echo ::set-output name=VERSION::${GITHUB_REF##*/}
23 |
24 | - name: Run tests
25 | run: sbt clean test
26 |
27 | - name: Setup GCP Service Account
28 | uses: google-github-actions/setup-gcloud@master
29 | with:
30 | version: 'latest'
31 | service_account_key: ${{ secrets.GCP_KEY_SECRET }}
32 | export_default_credentials: true
33 |
34 | - name: Configure Docker Registry
35 | run: |
36 | gcloud auth configure-docker
37 |
38 | - name: Build Docker Image
39 | run: |
40 | sbt "api / Docker / publish"
41 | env:
42 | DOCKER_PACKAGE: ${{ secrets.GCP_PROJECT_ID }}/api-template-prod
43 | RELEASE_TAG: ${{ steps.get_version.outputs.VERSION }}
44 | - name: Deploy Cloud Run
45 | run: |
46 | gcloud run deploy api-template-prod \
47 | --region europe-west1 \
48 | --image eu.gcr.io/${{ secrets.GCP_PROJECT_ID }}/api-template-prod:${{ steps.get_version.outputs.VERSION }} \
49 | --platform managed \
50 | --allow-unauthenticated \
51 | --cpu 1000m --memory 512Mi --max-instances 1 \
52 | --project ${{ secrets.GCP_PROJECT_ID }} \
53 | --update-env-vars DB_USER=${{ secrets.DB_USER_PROD }} \
54 | --update-env-vars DB_PASSWORD=${{ secrets.DB_PASSWORD_PROD }} \
55 | --update-env-vars DB_HOST=${{ secrets.DB_HOST_PROD }} \
56 | --update-env-vars DB_PORT=${{ secrets.DB_PORT_PROD }} \
57 | --update-env-vars DB_USE_SSL=false \
58 | --update-env-vars DB_DATABASE=api_template_prod \
59 | --update-env-vars DB_MAX_POOL_SIZE=16 \
60 | --update-env-vars AUTH0_DOMAIN=${{ secrets.AUTH0_DOMAIN_PROD }} \
61 | --add-cloudsql-instances conduktor:europe-west1:conduktor-prod \
62 | --vpc-connector vpc-connector-to-cloudsql \
63 | --update-env-vars INSTANCE_CONNECTION_NAME="conduktor:europe-west1:conduktor-prod"
64 |
--------------------------------------------------------------------------------
/modules/api/src/main/scala/io/conduktor/api/ApiTemplateApp.scala:
--------------------------------------------------------------------------------
1 | package io.conduktor.api
2 |
3 | import io.conduktor.api.auth.{AuthService, JwtAuthService}
4 | import io.conduktor.api.config.{AppConfig, Auth0Config, DBConfig}
5 | import io.conduktor.api.db.{DatabaseMigrationService, DbSessionPool, FlywayDatabaseMigrationService}
6 | import io.conduktor.api.http.Server
7 | import io.conduktor.api.repository.PostRepository
8 | import io.conduktor.api.service.{PostService, PostServiceLive}
9 |
10 | import zio.clock.Clock
11 | import zio.logging._
12 | import zio.logging.slf4j.Slf4jLogger
13 | import zio.random.Random
14 | import zio.{App, ExitCode, Has, RIO, RLayer, ULayer, URIO, ZIO, ZLayer}
15 |
16 | object ApiTemplateApp extends App {
17 |
18 | type AppEnv = zio.ZEnv with Server.ServerEnv with Has[DBConfig] with Has[DatabaseMigrationService]
19 |
20 | val correlationId: LogAnnotation[String] = LogAnnotation[String](
21 | name = "correlationId",
22 | initialValue = "noop",
23 | combine = (_, newValue) => newValue,
24 | render = identity
25 | )
26 | val logLayerLive: ULayer[Logging] =
27 | Slf4jLogger.make((context, message) => "[correlation-id = %s] %s".format(context(correlationId), message))
28 |
29 | val authLayer: RLayer[Has[Auth0Config] with Clock with Logging, Has[AuthService]] = JwtAuthService.layer
30 |
31 | val dbLayer: RLayer[Has[DBConfig], Has[PostRepository.Pool]] = DbSessionPool.layer >>> PostRepository.Pool.live
32 |
33 | val serviceLayer: RLayer[Has[PostRepository.Pool] with Random with Logging, Has[PostService]] = PostServiceLive.layer
34 |
35 | import zio.magic._
36 | private val env: RLayer[zio.ZEnv, AppEnv] =
37 | ZLayer.fromSomeMagic[zio.ZEnv, AppEnv](
38 | AppConfig.allLayers,
39 | dbLayer,
40 | authLayer,
41 | serviceLayer,
42 | logLayerLive,
43 | FlywayDatabaseMigrationService.layer
44 | )
45 |
46 | val migrateDatabase: RIO[Has[DBConfig] with Has[DatabaseMigrationService], Unit] = for {
47 | conf <- ZIO.service[DBConfig]
48 | service <- ZIO.service[DatabaseMigrationService]
49 | _ <- service.migrate().when(conf.migrate)
50 | } yield ()
51 |
52 | val program: RIO[AppEnv, ExitCode] = for {
53 | _ <- migrateDatabase
54 | // injecting separately, as repository layer verify the db schema on init
55 | .injectCustom(AppConfig.dbOnlyLayer, FlywayDatabaseMigrationService.layer)
56 | .orDie
57 | exitCode <- Server.serve.useForever
58 | .tapError(err => ZIO.effect(Option(err.getMessage).fold(err.printStackTrace())(println(_))))
59 | .exitCode
60 | } yield exitCode
61 |
62 | override def run(args: List[String]): URIO[zio.ZEnv, ExitCode] = program
63 | .provideCustomLayer(env)
64 | .orDie
65 |
66 | }
67 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | # Conduktor's Scala API template
4 |
5 | A template for writing Restful APIs we use at Conduktor.
6 |
7 | ## Requirements
8 |
9 | The requirements are:
10 |
11 | - store data into Postgres
12 | - handle Postgres schema and schema migration programmatically
13 | - expose domain logic via a RESTful API
14 | - describe the RESTful API using OpenAPI standard
15 | - secure the RESTful API with JWT using auth0 service (https://auth0.com) but avoid vendor lock-in
16 | - use only non-blocking technologies and implement only stateless services to handle high-scale workload
17 | - leverage Scala type-system and hexagonal architecture to minimize the testing requirements
18 | - enable testing at every layer: domain logic, end-to-end, data-access, RESTful API, integration of various layer combinations
19 | - enforce green tests and style conformance using Github actions
20 | - generate a docker image using Github actions and push it to a repository
21 | - allows developing proprietary software
22 |
23 | ## Tech
24 |
25 | This is the list of technologies we chose to implement our requirements:
26 |
27 | - http4s (https://http4s.org/) for HTTP implementation, APL v2 license
28 | - circe (https://circe.github.io/circe/) for JSON serialization, APL v2 license
29 | - tapir (https://tapir.softwaremill.com) for RESTful API description, APL v2 license
30 | - skunk (https://tpolecat.github.io/skunk/) for async (non-jdbc) Postgres access, MIT license
31 | - flyway (https://flywaydb.org/) for database schema handling, APL v2 license
32 | - zio (https://zio.dev/) for effect, streaming, logging, config and tests, APL v2 license
33 | - JWT validation using auth0-provided Java library, MIT license
34 | - refined (https://github.com/fthomas/refined) for defining value objects constraints, MIT license
35 | - newtype (https://github.com/estatico/scala-newtype) for generating value objects with no runtime cost, APL v2 license
36 | - sbt-native-packager (https://sbt-native-packager.readthedocs.io) for docker image generation, BSD-2-Clause License
37 |
38 | ## Development flow
39 |
40 | - create branches from main
41 | - merge into main to release Staging via Github action
42 | - tag main to release Prod via Github action
43 |
44 | The stack is deployed on Google Cloud (Cloud Run + Cloud SQL)
45 |
46 |
47 | ## Migration
48 | Database provisioning / migration is done via [flyway](https://flywaydb.org/)
49 |
50 | The migrations are applied at application start to ensure the database is up-to-date with current running code.
51 |
52 |
53 | ## Auth
54 |
55 | Auth is a JWT validation + data extraction, against an auth0 tenant.
56 |
57 | We retrieve the exposed public key from auth0, and use it to validate and decode the bearer token provided in the authorization header
58 |
59 |
60 | ## ISSUES
61 |
62 | - Intellij can't type properly skunk's "sql" StringOps macro,
63 | using Metals is therefore recommended when dealing with repositories
64 |
65 | ## TODOS
66 | - use domain-specific errors
67 | - add streaming endpoints examples (paginated, websocket)
68 |
--------------------------------------------------------------------------------
/modules/posts/src/main/scala/io/conduktor/api/repository/db/DbPostRepository.scala:
--------------------------------------------------------------------------------
1 | package io.conduktor.api.repository.db
2 |
3 | import eu.timepit.refined.types.all.NonEmptyString
4 | import io.conduktor.api.db.DbSessionPool.SessionTask
5 | import io.conduktor.api.model.Post
6 | import io.conduktor.api.repository.PostRepository
7 | import io.conduktor.api.repository.PostRepository.Error
8 | import io.conduktor.api.repository.db.DbPostRepository.PreparedQueries
9 | import io.conduktor.api.repository.db.SkunkExtensions._
10 | import io.conduktor.primitives.types.UserName
11 | import skunk.codec.all.{bool, uuid, text}
12 | import skunk.{Codec, Fragment, PreparedQuery}
13 | import zio.interop.catz._
14 | import zio.{IO, Managed, Task, TaskManaged}
15 |
16 | import java.time.LocalDateTime
17 | import java.util.UUID
18 |
19 | private[db] final case class PostDb(
20 | id: UUID,
21 | title: NonEmptyString,
22 | author: UserName,
23 | content: String,
24 | published: Boolean,
25 | createdAt: LocalDateTime
26 | )
27 | private[db] object PostDb {
28 | val codec: Codec[PostDb] =
29 | (uuid ~ nonEmptyText ~ usernameCodec ~ text ~ bool ~ createdAt).gimap[PostDb]
30 |
31 | def toDomain(p: PostDb): Post =
32 | Post(
33 | id = Post.Id(p.id),
34 | title = Post.Title(p.title),
35 | author = p.author,
36 | published = p.published,
37 | content = Post.Content(p.content)
38 | )
39 | }
40 |
41 | final class DbPostRepository(preparedQueries: PreparedQueries)(implicit
42 | private[db] val session: SessionTask
43 | ) extends PostRepository {
44 |
45 | override def createPost(id: Post.Id, title: Post.Title, author: UserName, content: Post.Content): IO[PostRepository.Error, Post] =
46 | Fragments.postCreate
47 | .unique((id.value, title.value, author, content.value))
48 | .map(PostDb.toDomain)
49 |
50 | override def deletePost(id: Post.Id): IO[PostRepository.Error, Unit] =
51 | Fragments.postDelete(Fragments.byId).execute(id.value).unit
52 |
53 | override def findPostById(id: Post.Id): IO[PostRepository.Error, Post] =
54 | preparedQueries.findById.unique(id.value).map(PostDb.toDomain).wrapException
55 |
56 | // Skunk allows streaming pagination, but it requires keeping the connection opens
57 | override def allPosts: IO[PostRepository.Error, List[Post]] =
58 | Fragments.postQuery(Fragment.empty).list(skunk.Void, 64).map(_.map(PostDb.toDomain))
59 |
60 | override def findPostByTitle(title: Post.Title): IO[PostRepository.Error, Option[Post]] =
61 | Fragments.postQuery(Fragments.byTitle).option(title.value).map(_.map(PostDb.toDomain))
62 | }
63 |
64 | object DbPostRepository {
65 |
66 | private case class PreparedQueries(
67 | findById: PreparedQuery[Task, UUID, PostDb]
68 | )
69 |
70 | /**
71 | * Used to create a "pool of repository" mapped from a pool of DB session That ensure that one and only one session is used per user
72 | * request, preventing eventual "portal_xx not found" issues and simplifying the repository implementation
73 | *
74 | * Here we demo preparing a statement in advance, only do it here if it's going to be used by most of your requests
75 | */
76 | def managed(sessionPool: TaskManaged[SessionTask]): Managed[Error.Unexpected, DbPostRepository] = for {
77 | // we retrieve a session from the pool
78 | session <- sessionPool.wrapException
79 | findById <- session.prepare(Fragments.postQuery(Fragments.byId)).toManagedZIO.wrapException
80 |
81 | } yield new DbPostRepository(PreparedQueries(findById = findById))(session)
82 |
83 | }
84 |
--------------------------------------------------------------------------------
/project/Librairies.scala:
--------------------------------------------------------------------------------
1 | import sbt._
2 |
3 | object Librairies {
4 |
5 | val zioVersion = "2.0.13"
6 | val zioConfigVersion = "2.0.9"
7 | val tapirVersion = "1.0.4"
8 | val http4sVersion = "0.23.13"
9 | val circeVersion = "0.14.4"
10 | val refinedVersion = "0.10.1"
11 | val sttpVersion = "3.8.13"
12 | val slf4jVersion = "2.0.6"
13 |
14 | val newtype = "io.estatico" %% "newtype" % "0.4.4"
15 | val refinedScalacheck = "eu.timepit" %% "refined-scalacheck" % refinedVersion
16 | val flyway = Seq(
17 | "org.flywaydb" % "flyway-core" % "9.16.0",
18 | "org.postgresql" % "postgresql" % "42.5.4"
19 | )
20 |
21 | val refined: Seq[ModuleID] = Seq(
22 | "eu.timepit" %% "refined" % refinedVersion,
23 | "eu.timepit" %% "refined-cats" % refinedVersion
24 | )
25 |
26 | val effect = Seq(
27 | "dev.zio" %% "zio" % zioVersion,
28 | "dev.zio" %% "zio-test" % zioVersion % Test,
29 | "dev.zio" %% "zio-test-sbt" % zioVersion % Test,
30 | "io.github.kitlangton" %% "zio-magic" % "0.3.12"
31 | )
32 |
33 | val db = Seq(
34 | "org.tpolecat" %% "skunk-core" % "0.3.1",
35 | "dev.zio" %% "zio-interop-cats" % "3.2.9.1"
36 | )
37 |
38 | val http = Seq(
39 | "org.http4s" %% "http4s-dsl" % http4sVersion,
40 | "org.http4s" %% "http4s-blaze-server" % http4sVersion,
41 | "org.http4s" %% "http4s-circe" % http4sVersion,
42 | "com.softwaremill.sttp.tapir" %% "tapir-zio1" % tapirVersion,
43 | "com.softwaremill.sttp.tapir" %% "tapir-http4s-server-zio1" % tapirVersion,
44 | "com.softwaremill.sttp.tapir" %% "tapir-refined" % tapirVersion,
45 | "com.softwaremill.sttp.tapir" %% "tapir-newtype" % tapirVersion,
46 | "com.softwaremill.sttp.tapir" %% "tapir-json-circe" % tapirVersion
47 | )
48 |
49 | val client = Seq(
50 | "com.softwaremill.sttp.client3" %% "core" % sttpVersion % Test,
51 | "com.softwaremill.sttp.client3" %% "zio1" % sttpVersion % Test
52 | )
53 |
54 | val jwt = Seq(
55 | "com.github.jwt-scala" %% "jwt-circe" % "9.2.0",
56 | "com.auth0" % "jwks-rsa" % "0.22.0"
57 | )
58 |
59 | val json = Seq(
60 | "io.circe" %% "circe-core" % circeVersion,
61 | "io.circe" %% "circe-generic" % circeVersion,
62 | "io.circe" %% "circe-parser" % circeVersion,
63 | "io.circe" %% "circe-refined" % circeVersion,
64 | "io.circe" %% "circe-testing" % circeVersion % Test
65 | )
66 |
67 | val logging = Seq(
68 | "dev.zio" %% "zio-logging-slf4j" % "2.1.12",
69 | "ch.qos.logback" % "logback-classic" % "1.4.7",
70 | "org.slf4j" % "jul-to-slf4j" % slf4jVersion,
71 | "org.slf4j" % "log4j-over-slf4j" % slf4jVersion,
72 | "org.slf4j" % "jcl-over-slf4j" % slf4jVersion,
73 | )
74 |
75 | val configurations = Seq(
76 | "dev.zio" %% "zio-config" % zioConfigVersion,
77 | "dev.zio" %% "zio-config-refined" % zioConfigVersion,
78 | "dev.zio" %% "zio-config-magnolia" % zioConfigVersion,
79 | "dev.zio" %% "zio-config-typesafe" % zioConfigVersion
80 | )
81 |
82 | val apiDocs = Seq(
83 | "com.softwaremill.sttp.tapir" %% "tapir-swagger-ui-bundle" % tapirVersion
84 | )
85 |
86 | val embeddedPostgres = "com.opentable.components" % "otj-pg-embedded" % "1.0.1" % Test
87 | val dbTestingStack = Seq(embeddedPostgres)
88 |
89 | }
90 |
--------------------------------------------------------------------------------
/modules/posts/src/main/scala/io/conduktor/api/service/PostService.scala:
--------------------------------------------------------------------------------
1 | package io.conduktor.api.service
2 |
3 | import io.conduktor.api.model.Post
4 | import io.conduktor.api.model.Post.{Content, Title}
5 | import io.conduktor.api.repository
6 | import io.conduktor.api.repository.PostRepository
7 | import io.conduktor.api.service.PostService.{DuplicatePostError, PostServiceError}
8 | import io.conduktor.primitives.types.{Email, UserName}
9 | import zio._
10 | import zio.logging.{Logger, Logging}
11 | import zio.random.Random
12 |
13 | trait PostService {
14 | def createPost(author: UserName, title: Title, content: Content): IO[PostServiceError, Post]
15 |
16 | def deletePost(id: Post.Id): IO[PostServiceError, Unit]
17 |
18 | def findById(id: Post.Id): IO[PostServiceError, Post]
19 |
20 | def all: IO[PostServiceError, List[Post]]
21 | }
22 |
23 | object PostService {
24 | sealed trait PostServiceError
25 |
26 | final case class RepositoryError(err: repository.PostRepository.Error) extends PostServiceError
27 |
28 | final case class InvalidEmail(email: Email) extends PostServiceError
29 |
30 | final case class DuplicatePostError(title: Title) extends PostServiceError
31 |
32 | final case class Unexpected(throwable: Throwable) extends PostServiceError
33 | }
34 |
35 | final class PostServiceLive(random: Random.Service, postRepositoryPool: PostRepository.Pool)(implicit logger: Logger[String])
36 | extends PostService {
37 |
38 | import PostServiceLive._
39 |
40 | override def createPost(author: UserName, title: Title, content: Content): IO[PostServiceError, Post] =
41 | for {
42 | id <- random.nextUUID.map(Post.Id.apply)
43 | maybeCreated <- postRepositoryPool
44 | .use(repo =>
45 | repo
46 | .findPostByTitle(title)
47 | .flatMap(existing =>
48 | existing
49 | .map(_ => ZIO.none)
50 | .getOrElse(repo.createPost(id, title, author, content).asSome)
51 | )
52 | )
53 | .domainError
54 | created <- maybeCreated match {
55 | case Some(created) => ZIO.succeed(created)
56 | case None => ZIO.fail(DuplicatePostError(title))
57 | }
58 | } yield created
59 |
60 | override def deletePost(id: Post.Id): IO[PostServiceError, Unit] = postRepositoryPool.use(_.deletePost(id)).domainError
61 |
62 | override def findById(id: Post.Id): IO[PostServiceError, Post] = postRepositoryPool.use(_.findPostById(id)).domainError
63 |
64 | override def all: IO[PostServiceError, List[Post]] = postRepositoryPool.use(_.allPosts).domainError
65 | }
66 |
67 | object PostServiceLive {
68 |
69 | implicit private[PostServiceLive] final class PostRepoErrorOps[A](val io: IO[PostRepository.Error, A]) {
70 | def domainError(implicit logger: Logger[String]): IO[PostService.RepositoryError, A] = io.tapError {
71 | case PostRepository.Error.Unexpected(throwable) => logger.throwable("Unexpected repository error", throwable)
72 | case _ => ZIO.unit
73 | }
74 | .mapError(PostService.RepositoryError.apply)
75 | }
76 |
77 | val layer: URLayer[Has[PostRepository.Pool] with Logging with Random, Has[PostService]] =
78 | ZLayer.fromServices[PostRepository.Pool, Logger[String], Random.Service, PostService] { (pool, log, rand) =>
79 | new PostServiceLive(rand, pool)(log)
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/modules/auth/src/main/scala/io/conduktor/api/auth/AuthService.scala:
--------------------------------------------------------------------------------
1 | package io.conduktor.api.auth
2 |
3 | import java.time.{Instant, OffsetDateTime, ZoneId}
4 | import java.util.concurrent.TimeUnit
5 |
6 | import com.auth0.jwk.{Jwk, JwkProviderBuilder}
7 | import eu.timepit.refined.types.all.NonEmptyString
8 | import io.circe.Decoder
9 | import io.circe.parser.decode
10 | import io.conduktor.api.auth.AuthService.AuthToken
11 | import io.conduktor.api.auth.JwtAuthService.{AuthException, Header}
12 | import io.conduktor.api.config.Auth0Config
13 | import io.conduktor.api.model.User
14 | import io.estatico.newtype.macros.newtype
15 | import pdi.jwt.{JwtAlgorithm, JwtBase64, JwtCirce, JwtClaim}
16 |
17 | import zio._
18 | import zio.clock.Clock
19 | import zio.logging.{Logger, Logging}
20 |
21 | trait AuthService {
22 | def auth(token: AuthToken): Task[User]
23 | }
24 |
25 | object AuthService {
26 | @newtype final case class AuthToken(value: NonEmptyString) {
27 | def show: String = value.value
28 | }
29 | object AuthToken {
30 | import io.circe.refined._
31 | import io.estatico.newtype.ops._
32 | implicit val authTokenDecoder: Decoder[AuthToken] = Decoder[NonEmptyString].coerce[Decoder[AuthToken]]
33 | }
34 |
35 | def auth(token: AuthToken): RIO[Has[AuthService], User] = ZIO.serviceWith(_.auth(token))
36 | }
37 |
38 | object JwtAuthService {
39 | @newtype private final case class Header(value: String)
40 |
41 | class AuthException(message: String) extends RuntimeException(message)
42 |
43 | val layer: URLayer[Has[Auth0Config] with Clock with Logging, Has[AuthService]] = (new JwtAuthService(_, _, _)).toLayer
44 | }
45 |
46 | final class JwtAuthService(auth0Conf: Auth0Config, clock: Clock.Service, log: Logger[String]) extends AuthService {
47 |
48 | private val supportedAlgorithms = Seq(JwtAlgorithm.RS256)
49 | private val issuer = s"https://${auth0Conf.domain}/"
50 |
51 | private val cachedJwkProvider =
52 | new JwkProviderBuilder(issuer)
53 | .cached(auth0Conf.cacheSize.toLong, auth0Conf.ttl.getSeconds, TimeUnit.SECONDS)
54 | .build()
55 |
56 | private def clockFromOffset(now: OffsetDateTime): java.time.Clock = new java.time.Clock {
57 | override def getZone: ZoneId = now.getOffset
58 |
59 | override def withZone(zone: ZoneId): java.time.Clock = clockFromOffset(now.atZoneSameInstant(zone).toOffsetDateTime)
60 |
61 | override def instant(): Instant = now.toInstant
62 | }
63 |
64 | private val withJavaClock: ZIO[Clock, Throwable, java.time.Clock] = zio.clock.currentDateTime.map {
65 | clockFromOffset
66 | }
67 |
68 | override def auth(token: AuthToken): Task[User] = {
69 | for {
70 | claims <- validateJwt(token)
71 | user <- ZIO.fromEither(decode[User](claims.content))
72 | } yield user
73 | }
74 | .tapError(log.throwable("Failed to parse auth token", _))
75 | .provide(Has(clock))
76 |
77 | private def validateJwt(token: AuthToken): RIO[Clock, JwtClaim] = for {
78 | jwk <- getJwk(token) // Get the secret key for this token
79 | claims <-
80 | ZIO.fromTry(JwtCirce.decode(token.show, jwk.getPublicKey, supportedAlgorithms)) // Decode the token using the secret key
81 | _ <- validateClaims(claims) // validate the data stored inside the token
82 | } yield claims
83 |
84 | private def getJwk(token: AuthToken): Task[Jwk] =
85 | for {
86 | header <- extractHeader(token)
87 | jwtHeader <- Task(JwtCirce.parseHeader(header.value))
88 | kid <- IO.fromOption(jwtHeader.keyId).orElseFail(new AuthException("Unable to retrieve kid"))
89 | jwk <- Task(cachedJwkProvider.get(kid))
90 | } yield jwk
91 |
92 | private def extractHeader(jwt: AuthToken): Task[Header] =
93 | jwt.show match {
94 | case s"$header.$_.$_" => ZIO.succeed(Header(JwtBase64.decodeString(header)))
95 | case _ => ZIO.fail(new AuthException("Token does not match the correct pattern"))
96 | }
97 |
98 | private def validateClaims(claims: JwtClaim) =
99 | withJavaClock.flatMap { implicit clock =>
100 | ZIO
101 | .fail(new RuntimeException(s"The JWT did not pass validation for issuer $issuer"))
102 | .unless(claims.isValid(issuer)(clock))
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/modules/api/src/main/scala/io/conduktor/api/http/posts/v1/PostRoutes.scala:
--------------------------------------------------------------------------------
1 | package io.conduktor.api.http.posts.v1
2 |
3 | import java.util.UUID
4 |
5 | import io.conduktor.api.auth.AuthService
6 | import io.conduktor.api.http.endpoints.secureEndpoint
7 | import io.conduktor.api.http.{BadRequest, Conflict, ErrorInfo, NotFound, ServerError}
8 | import io.conduktor.api.model.{Post, User}
9 | import io.conduktor.api.repository.PostRepository.Error
10 | import io.conduktor.api.service.PostService
11 | import io.conduktor.api.service.PostService.{InvalidEmail, PostServiceError}
12 | import sttp.capabilities.zio.ZioStreams
13 | import sttp.tapir.EndpointInput
14 | import sttp.tapir.json.circe._
15 | import sttp.tapir.ztapir._
16 |
17 | import zio._
18 | import zio.random.Random
19 |
20 | object PostRoutes {
21 | import Domain._
22 |
23 | type Env = Has[AuthService] with Has[PostService] with Random
24 |
25 | private def serverError(defaultMessage: => String)(error: Throwable): ServerError =
26 | ServerError(Option(error.getMessage).getOrElse(defaultMessage))
27 |
28 | private def createPostServerLogic(user: User, post: CreatePostInput): ZIO[Has[PostService] with Random, ErrorInfo, PostDTO] =
29 | (for {
30 | service <- ZIO.service[PostService]
31 | userName <- ZIO.fromEither(user.email.getUserName).orElseFail(InvalidEmail(user.email))
32 | created <- service.createPost(userName, Post.Title(post.title), Post.Content(post.content))
33 | } yield created)
34 | .mapBoth(handlePostServiceError(s"Error creating post with title ${post.title}"), PostDTO.from)
35 |
36 | private def deletePostServerLogic(id: UUID): ZIO[Has[PostService], ErrorInfo, Unit] =
37 | ZIO
38 | .serviceWith[PostService](_.deletePost(Post.Id(id)))
39 | .mapError(handlePostServiceError(s"Error deleting post with id $id"))
40 |
41 | private def getPostByIdServerLogic(id: UUID): ZIO[Has[PostService], ErrorInfo, PostDTO] =
42 | ZIO
43 | .serviceWith[PostService](_.findById(Post.Id(id)))
44 | .mapBoth(handlePostServiceError(s"Error finding post $id"), PostDTO.from)
45 |
46 | private def allPostsServerLogic: ZIO[Has[PostService], ErrorInfo, List[PostDTO]] =
47 | ZIO
48 | .serviceWith[PostService](_.all)
49 | .mapBoth(handlePostServiceError("Error listing posts"), _.map(PostDTO.from))
50 |
51 | private def handlePostServiceError(context: String)(error: PostServiceError): ErrorInfo = {
52 | import cats.syntax.show._
53 | error match {
54 | case PostService.DuplicatePostError(title) =>
55 | Conflict(show"$context. A post already exists with the same title : ${title.value.value}.")
56 | case PostService.RepositoryError(err) =>
57 | err match {
58 | case Error.PostNotFound(id) => NotFound(show"$context. No post found with id ${id.value}")
59 | case Error.Unexpected(_) => ServerError(show"$context. Unexpected repository error. Check the logs for more")
60 | }
61 | case PostService.Unexpected(err) => serverError(show"$context. Unexpected service error.")(err)
62 | case PostService.InvalidEmail(email) => BadRequest(show"$context. Invalid email ${email.value.value}")
63 | }
64 | }
65 |
66 | object Endpoints {
67 |
68 | val BASE_PATH: EndpointInput[Unit] = "posts" / "v1"
69 |
70 | private val createPostEndpoint =
71 | secureEndpoint.post
72 | .in(BASE_PATH)
73 | .in(jsonBody[CreatePostInput])
74 | .out(jsonBody[PostDTO])
75 | .serverLogic(user => post => createPostServerLogic(user, post))
76 |
77 | private val deletePostEndpoint =
78 | secureEndpoint.delete
79 | .in(BASE_PATH / path[UUID]("id"))
80 | .out(emptyOutput)
81 | .serverLogic(_ => id => deletePostServerLogic(id))
82 |
83 | private val getPostByIdEndpoint =
84 | secureEndpoint.get
85 | .in(BASE_PATH / path[UUID]("id"))
86 | .out(jsonBody[PostDTO])
87 | .serverLogic(_ => id => getPostByIdServerLogic(id))
88 |
89 | private val allPostsEndpoint =
90 | secureEndpoint.get
91 | .in(BASE_PATH)
92 | .out(jsonBody[List[PostDTO]])
93 | .serverLogic(_ => _ => allPostsServerLogic)
94 |
95 | val all: List[ZServerEndpoint[Env, ZioStreams]] = List(
96 | createPostEndpoint.widen[Env],
97 | deletePostEndpoint.widen[Env],
98 | getPostByIdEndpoint.widen[Env],
99 | allPostsEndpoint.widen[Env]
100 | )
101 |
102 | }
103 |
104 | }
105 |
--------------------------------------------------------------------------------
/modules/api/src/test/scala/io/conduktor/api/http/posts/v1/PostRoutesSpec.scala:
--------------------------------------------------------------------------------
1 | package io.conduktor.api.http.posts.v1
2 |
3 | import scala.collection.mutable
4 |
5 | import io.conduktor.api.ApiTemplateApp
6 | import io.conduktor.api.ServerTestLayers.{dummyAuth, localDB, randomPortHttpConfig}
7 | import io.conduktor.api.db.MemoryRepositorySpec
8 | import io.conduktor.api.http.Server
9 | import io.conduktor.api.model.Post
10 | import io.conduktor.api.service.PostService
11 | import io.conduktor.primitives.types.UserName
12 | import org.http4s.server.Server
13 | import sttp.capabilities
14 | import sttp.capabilities.zio.ZioStreams
15 | import sttp.client3._
16 | import sttp.client3.httpclient.zio.HttpClientZioBackend
17 | import sttp.model.StatusCode
18 |
19 | import zio.blocking.Blocking
20 | import zio.clock.Clock
21 | import zio.logging.Logging
22 | import zio.magic._
23 | import zio.random.Random
24 | import zio.test.Assertion.{containsString, equalTo, isRight}
25 | import zio.test.TestAspect.sequential
26 | import zio.test._
27 | import zio.test.environment.TestEnvironment
28 | import zio.{Has, IO, RLayer, Task, TaskLayer, UIO, URLayer, ZIO, ZLayer}
29 |
30 | private class Stub(random: Random.Service) extends PostService {
31 | private val posts = mutable.Map.empty[Post.Id, Post]
32 |
33 | override def createPost(author: UserName, title: Post.Title, content: Post.Content): IO[PostService.PostServiceError, Post] =
34 | for {
35 | uuid <- random.nextUUID
36 | } yield {
37 | val id = Post.Id(uuid)
38 | posts(id) = Post(
39 | id = id,
40 | title = title,
41 | author = author,
42 | published = false,
43 | content = content
44 | )
45 | posts(id)
46 | }
47 |
48 | override def deletePost(uuid: Post.Id): IO[PostService.PostServiceError, Unit] = UIO(posts.remove(uuid)).unit
49 |
50 | override def findById(uuid: Post.Id): IO[PostService.PostServiceError, Post] = UIO(posts(uuid))
51 |
52 | override def all: IO[PostService.PostServiceError, List[Post]] = UIO(posts.values.toList)
53 | }
54 |
55 | private object Stub {
56 | val layer: URLayer[Random, Has[PostService]] = (new Stub(_)).toLayer
57 | }
58 |
59 | object PostRoutesSpec extends DefaultRunnableSpec {
60 |
61 | type HttpClient = SttpBackend[Task, ZioStreams with capabilities.WebSockets]
62 | type Env = Has[HttpClient] with Has[Server] with Logging
63 | type Layer = RLayer[zio.ZEnv, Env]
64 |
65 | val httpClient: TaskLayer[Has[HttpClient]] = HttpClientZioBackend().toLayer
66 |
67 | val commonLayers: ZLayer[
68 | Has[Clock.Service]
69 | with Has[zio.console.Console.Service]
70 | with Has[zio.system.System.Service]
71 | with Has[Random.Service]
72 | with Has[
73 | Blocking.Service
74 | ]
75 | with Has[PostService],
76 | Throwable,
77 | Has[HttpClient] with Has[Server]
78 | ] =
79 | ZLayer.fromSomeMagic[zio.ZEnv with Has[PostService], Env](
80 | httpClient,
81 | dummyAuth,
82 | randomPortHttpConfig,
83 | Server.layer,
84 | Logging.ignore
85 | )
86 |
87 | val dbLayers: Layer = ZLayer.fromSomeMagic[zio.ZEnv, Env](
88 | localDB,
89 | ApiTemplateApp.dbLayer,
90 | ApiTemplateApp.serviceLayer,
91 | commonLayers,
92 | Logging.ignore
93 | )
94 |
95 | val memoryLayer: Layer = ZLayer.fromSomeMagic[zio.ZEnv, Env](
96 | MemoryRepositorySpec.testLayer,
97 | ApiTemplateApp.serviceLayer,
98 | commonLayers,
99 | Logging.ignore
100 | )
101 |
102 | val stubServicesLayer: Layer = ZLayer.fromSomeMagic[zio.ZEnv, Env](
103 | Stub.layer,
104 | commonLayers,
105 | Logging.ignore
106 | )
107 |
108 | private final case class TestEnv(name: String, layer: Layer)
109 |
110 | private val envs: Seq[TestEnv] = Seq(
111 | TestEnv("with db", dbLayers),
112 | TestEnv("with memory repository", memoryLayer),
113 | TestEnv("with stub services", stubServicesLayer)
114 | )
115 |
116 | private val suites: Seq[String => ZSpec[Env, Throwable]] = Seq(`/posts/v1`)
117 |
118 | private def run(envs: Seq[TestEnv], suites: Seq[String => ZSpec[Env, Throwable]]): ZSpec[zio.ZEnv, Throwable] =
119 | suite("")(envs.flatMap(env => suites.map(suite => suite(env.name).provideSomeLayerShared(env.layer.orDie))): _*)
120 |
121 | override def spec: ZSpec[TestEnvironment, Throwable] = run(envs, suites)
122 |
123 | private def `/posts/v1`(name: String) = {
124 | suite(s"/posts/v1 $name")(
125 | testM("POST / should return 200") {
126 | val payload =
127 | """{
128 | | "title": "my test",
129 | | "content": "blabla"
130 | | }""".stripMargin
131 | for {
132 | server <- ZIO.service[Server]
133 | client <- ZIO.service[HttpClient]
134 | response <- basicRequest.body(payload).auth.bearer("Foo").post(uri"${server.baseUri}/posts/v1").send(client)
135 | } yield assert(response.code)(equalTo(StatusCode.Ok))
136 | },
137 | testM("GET / should return a post") {
138 | val payload =
139 | """{
140 | | "title": "my test",
141 | | "content": "blabla"
142 | | }""".stripMargin
143 | for {
144 | server <- ZIO.service[Server]
145 | client <- ZIO.service[HttpClient]
146 | _ <- basicRequest.body(payload).auth.bearer("Foo").post(uri"${server.baseUri}/posts/v1").send(client)
147 | response <- basicRequest.auth.bearer("Foo").get(uri"${server.baseUri}/posts/v1").send(client)
148 | } yield assert(response.code)(equalTo(StatusCode.Ok)) && assert(response.body)(isRight(containsString("blabla")))
149 | }
150 | )
151 | } @@ sequential
152 |
153 | }
154 |
--------------------------------------------------------------------------------
/blog-post.adoc:
--------------------------------------------------------------------------------
1 | = Scala Api Template @ Conduktor
2 |
3 |
4 | == The Conduktor developer team
5 |
6 | The Conduktor developer team is composed of a group of experienced and curious professionals. Scala features/enables a lot of
7 | characteristics we value, so that's the language we chose. Our DNA is our Kafka expertise and we believe that modern
8 | software architectures have to be built around streaming.
9 |
10 | === Functional programming
11 |
12 | We believe that FP is the best way to write robust software without surprises. Our team has the required knowledge to
13 | use this paradigm, and we of course want our applications to leverage FP.
14 |
15 | === Type Safety
16 |
17 | We love Scala for its very good type system: It allows us to write very expressive code that is ready for changes
18 | and only needs limited amounts of testing to ensure it behaves as expected. Not convinced? Watch
19 | https://www.youtube.com/watch?v=apu-J0msaiY["Types vs Tests" by Julien Truffaut]
20 |
21 | We utilize the full potential of the Scala type system by defining value objects and banning primitive types as much as possible.
22 |
23 | === Domain Driven Design
24 |
25 | We like what DDD and its ecosystem brought to our field: Ubiquitous Language, Entity/Value Object dichotomy,
26 | Hexagonal Architecture, etc.
27 |
28 | We try to use these concepts whenever it makes sense for us, with support from our tools.
29 |
30 | === Testing
31 |
32 | We are serious about software. We never want to rush to fix production bugs or give our customers buggy products so
33 | we do write extensive test suites.
34 |
35 | Our applications goes through tests at various layers:
36 | * domain logic tests
37 | * repository tests
38 | * APIs tests
39 | * various combination of integration tests
40 | * end 2 end tests, from API calls to database
41 |
42 | We get a strong and fast testsuite by writing tests respecting the famous pyramid of test principle.
43 |
44 | === Documentation
45 |
46 | As libs or services consumers, we like well-written documentation. Since we provide libs and services ourselves
47 | we always want to ensure the right level of information for our users.
48 |
49 | == Technologies we like
50 |
51 | Now that we have gone through our principles, let's talk about how we reach these goals using technologies.
52 |
53 | === Effect System, FP and type safety: ZIO to the rescue
54 |
55 | We believe that https://zio.dev[ZIO] is the best solution to write pure FP in Scala nowadays. At least it's what we are the most
56 | comfortable with.
57 |
58 | To fulfill other requirements, we usually consider only libraries that fit well with ZIO whether its ZIO native ones or
59 | has a ZIO integration.
60 |
61 | === Data persistence: welcome back SQL
62 |
63 | During the NoSQL trend, SQL implementations got better and we learnt that NoSQL databases drive development cost higher,
64 | thus more and more people are considering SQL databases again by default. So do we.
65 | This doesn't mean that we dislike NoSQL but to be honest, for most projects, a good old Postgres server will make your
66 | life so much easier.
67 |
68 | However, performance is still a concern, as we don't want to use a blocking jdbc driver. We decided to go with
69 | Rob Norris's https://tpolecat.github.io/skunk/[skunk library] for that matter.
70 |
71 | Finally, to manage our database schema, we picked https://flywaydb.org/[flyway].
72 |
73 | === RESTful API: Code First
74 |
75 | Everyone wants a nice OpenAPI documentation but there are a lot of ways to build it.
76 |
77 | A very strong argument of using Scala and typing everything is: the code and the doc are actually so close that
78 | the generation from the code is really decent and efforts done to have a good documentation also enhance the
79 | code directly.
80 |
81 | It's why we decided to use https://tapir.softwaremill.com[tapir]. Of course, it's also well integrated with ZIO,
82 | which makes our life easier.
83 |
84 | === The toolbox
85 |
86 | There's always a bunch of tools that solve well defined problems like JSON serialization, creation of zero-cost
87 | value objects, etc.
88 |
89 | Here is our shopping list:
90 |
91 | * https://circe.github.io/circe/[circe] for JSON because we love type safety
92 | * auth0 jwks-rsa library for JWT validation
93 | * https://github.com/fthomas/refined[refined] for declarative primitive refinements
94 | * https://github.com/estatico/scala-newtype[newtype] for zero-cost value objects
95 | * https://sbt-native-packager.readthedocs.io[sbt-native-packager] for generating a docker image
96 | * https://http4s.org/[http4s] for HTTP implementation
97 | * https://www.testcontainers.org/[testcontainers] for running a real Postgres server during tests
98 |
99 | == Why we built this template
100 |
101 | Why build a template instead of just creating our project straight away? Well, there are many reasons.
102 |
103 | === Reaching a Team consensus
104 |
105 | We all have different experiences and tastes. People could certainly prefer different sets of technologies or choose
106 | different test strategies. It's the kind of topic that is hard to debate while implementing a product.
107 | Why we decided to discuss these choices on a sample project that would be as small as possible while incorporating
108 | every aspect of our real-world products.
109 |
110 | With this strategy, It was easy to benchmark two solutions by creating a PR on the project and to reach a team consensus.
111 |
112 | === Initial setup is often overlooked
113 |
114 | As much as we try to pick ZIO-friendly libs, the cost of putting everything together and having a working prototype
115 | is far from small. We found that dedicating a project with concrete tasks is less stressful to manage than to pay
116 | the price during the implementation of a feature.
117 |
118 | === We are growing fast
119 |
120 | As the company is growing really fast and will continue that way, it's important to spread knowledge into
121 | the existing team and to make the knowledge easily accessible for new hires.
122 |
123 | It's easier to learn from a project with a handful of classes with a trivial domain than having to learn
124 | a new domain and all the new technologies at the same time.
125 |
126 | Think of this template as guidelines defining how we code at Conduktor.
127 |
128 | === Many projects
129 |
130 | We are not microservices fans, we will keep our domains in a modular monolith in a monorepo as long as it's doable.
131 |
132 | But at some point, we'll need to have several projects with their own lifecycle.
133 |
134 | This template will be kept up-to-date with our practices and technologies to ensure that new projects can start with the
135 | same strategies.
136 |
137 | === Experiment is cheaper on a small codebase
138 |
139 | As time passes, we will want to update libs, replace them or include new ones. We will probably also want to migrate
140 | from one Scala version to another. Trying these changes on a small codebase using the same techs as our production
141 | apps will be a huge benefit. The cost involved in a proof-of-concept will be quite small, this will be our
142 | sandbox to try new things.
143 |
144 | === Be useful to our Scala community and get feedback
145 |
146 | All this is hard work. To be honest, we read a lot of community template projects and code snippets to build this
147 | template, your hard work. We want to give back to the community, and we hope we have added value on some topics
148 | and that the result is not yet available as-is in the ecosystem.
149 |
150 | Maybe it will be useful to others. Maybe it can inspire people.
151 |
152 | And maybe people will hate what we did, and it's ok too.
153 |
154 | Whatever you want say, we are eager to listen to your feedback because we want to learn from the community.
155 |
156 | == Limitations
157 |
158 | That's one of the best sections of every documentation: after reading all the marketing stuff, all good software developers
159 | go straight to the limitation section, right?
160 |
161 | Here is a list of things you need to know about this template:
162 |
163 | * Postgres won't scale infinitely, don't use it for your 1M req/s project
164 | * It implements neither CQRS nor Event Sourcing: these patterns are awesome but require a lot of very specific
165 | knowledge that are rare in the industry
166 | * Skunk, our Postgres access library is still very young
167 | * We like Kafka but we don't include Kafka support in this template: we will add Kafka support in the future, we are
168 | just not there yet
169 | * There's no metrics yet, so not very ready for production
170 |
171 | == Conclusion
172 |
173 | The code is https://github.com/conduktor/scala-api-template[here], if you consider writing a Scala application and
174 | you share some of our principles, it's probably a good idea to read it.
175 |
176 | If you liked what you just read, maybe you will want to join us?
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "[]"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright 2021 Conduktor.io
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------