├── 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 | --------------------------------------------------------------------------------