├── .gitignore ├── .scalafmt.conf ├── README.md ├── build.sbt ├── project ├── CompilerSettings.scala ├── Dependencies.scala ├── build.properties └── plugins.sbt └── src ├── main ├── resources │ ├── application.conf │ ├── db │ │ └── migration │ │ │ └── V20220831_1__init.sql │ └── logback.xml └── scala │ └── funcprog │ ├── App.scala │ ├── ElectionService.scala │ ├── HttpServerSettings.scala │ ├── Main.scala │ ├── config │ └── config.scala │ ├── database │ ├── Database.scala │ ├── UniqueConstraintViolation.scala │ └── VoteRecord.scala │ ├── model │ ├── Party.scala │ ├── Person.scala │ ├── Vote.scala │ ├── VoteResult.scala │ └── package.scala │ ├── routes │ └── ElectionServer.scala │ └── validation │ ├── ValidationError.scala │ ├── ValidationResponse.scala │ └── ValidationService.scala └── test ├── resources └── logback-test.xml └── scala └── funcprog ├── Generators.scala └── database └── DatabaseSpec.scala /.gitignore: -------------------------------------------------------------------------------- 1 | # macOS 2 | .DS_Store 3 | 4 | # sbt specific 5 | dist/* 6 | target/ 7 | lib_managed/ 8 | src_managed/ 9 | project/boot/ 10 | project/plugins/project/ 11 | project/local-plugins.sbt 12 | .history 13 | .ensime 14 | .ensime_cache/ 15 | .sbt-scripted/ 16 | local.sbt 17 | 18 | # Bloop 19 | .bsp 20 | 21 | # VS Code 22 | .vscode/ 23 | 24 | # Metals 25 | .bloop/ 26 | .metals/ 27 | metals.sbt 28 | 29 | # IDEA 30 | .idea 31 | .idea_modules 32 | /.worksheet/ 33 | -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version = "3.2.1" 2 | 3 | runner.dialect = "scala213source3" 4 | 5 | maxColumn = 120 6 | align.preset = most 7 | continuationIndent.defnSite = 2 8 | assumeStandardLibraryStripMargin = true 9 | docstrings.style = Asterisk 10 | docstrings.wrap = no 11 | lineEndings = preserve 12 | includeCurlyBraceInSelectChains = true 13 | danglingParentheses.preset = true 14 | optIn.annotationNewlines = true 15 | 16 | align.arrowEnumeratorGenerator = true 17 | 18 | project.excludePaths = [ 19 | "glob:**/metals.sbt" 20 | ] 21 | 22 | rewrite.rules = [SortModifiers, RedundantBraces, RedundantParens, PreferCurlyFors, Imports] 23 | rewrite.imports.sort = scalastyle 24 | rewrite.imports.groups = [ 25 | ["java\\..*"], 26 | ["scala\\..*"], 27 | ["com\\..*"], 28 | ["org\\..*"], 29 | ["funcprog\\..*"], 30 | ["zio\\..*"], 31 | ["doobie\\..*"], 32 | ["cats\\..*"] 33 | ] 34 | rewrite.redundantBraces.stringInterpolation = true 35 | 36 | verticalMultiline.arityThreshold = 4 37 | 38 | newlines.penalizeSingleSelectMultiArgList = false 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Election System - Funcprog 2022 2 | 3 | A great™ election system :) 4 | 5 | To create the PostgreSQL database and user: 6 | 7 | ``` 8 | createdb funcprog 9 | createuser -P -s -e funcprog_user 10 | ``` 11 | 12 | Password for the DB user is `funcprog`. 13 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | import CompilerSettings.* 2 | import Dependencies.* 3 | 4 | lazy val root = project 5 | .in(file(".")) 6 | .settings( 7 | name := "zio-tour", 8 | version := "0.1.0-SNAPSHOT", 9 | scalaVersion := "2.13.8", 10 | scalacOptions := compilerSettings, 11 | Test / fork := true, 12 | compile / run / fork := true, 13 | libraryDependencies ++= dependencies, 14 | testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework") 15 | ) 16 | 17 | addCompilerPlugin("org.typelevel" % "kind-projector" % "0.13.2" cross CrossVersion.full) 18 | -------------------------------------------------------------------------------- /project/CompilerSettings.scala: -------------------------------------------------------------------------------- 1 | object CompilerSettings { 2 | 3 | val strict = true 4 | 5 | lazy val compilerSettings = 6 | if (strict) stdSettings ++ strictSettings 7 | else stdSettings 8 | 9 | private lazy val strictSettings = Seq( 10 | "-Xfatal-warnings", 11 | "-Ywarn-unused", 12 | "-Wunused:imports", 13 | "-Wunused:patvars", 14 | "-Wunused:privates", 15 | "-Wvalue-discard", 16 | ) 17 | 18 | private lazy val stdSettings = Seq( 19 | "-deprecation", 20 | "-encoding", 21 | "UTF-8", 22 | "-feature", 23 | "-language:higherKinds", 24 | "-language:existentials", 25 | "-unchecked", 26 | "-Ywarn-macros:after", 27 | "-Xsource:3", 28 | "-Ymacro-annotations", 29 | ) 30 | 31 | } 32 | -------------------------------------------------------------------------------- /project/Dependencies.scala: -------------------------------------------------------------------------------- 1 | import sbt.* 2 | 3 | object Dependencies { 4 | 5 | val DoobieVersion = "1.0.0-RC2" 6 | 7 | val LogbackVersion = "1.2.11" 8 | val LogbackEncoderVersion = "4.11" 9 | 10 | val tapirVersion = "1.0.2" 11 | 12 | val ZIOVersion = "2.0.1" 13 | val ZIOConfigVersion = "3.0.1" 14 | val ZIOLoggingVersion = "2.0.1" 15 | val ZIOTestContainersVersion = "0.8.0" 16 | 17 | lazy val database = Seq( 18 | "org.tpolecat" %% "doobie-core" % DoobieVersion, 19 | "org.tpolecat" %% "doobie-hikari" % DoobieVersion, 20 | "org.tpolecat" %% "doobie-postgres" % DoobieVersion, 21 | "org.flywaydb" % "flyway-core" % "6.1.0" 22 | ) 23 | 24 | lazy val logging = Seq( 25 | "ch.qos.logback" % "logback-core" % LogbackVersion, 26 | "ch.qos.logback" % "logback-classic" % LogbackVersion, 27 | "org.slf4j" % "slf4j-api" % "1.7.36" 28 | ) 29 | 30 | lazy val tapir = Seq( 31 | "com.softwaremill.sttp.tapir" %% "tapir-core" % tapirVersion, 32 | "com.softwaremill.sttp.tapir" %% "tapir-zio-http-server" % tapirVersion, 33 | "com.softwaremill.sttp.tapir" %% "tapir-swagger-ui-bundle" % tapirVersion, 34 | "com.softwaremill.sttp.tapir" %% "tapir-json-zio" % tapirVersion 35 | ) 36 | 37 | lazy val zio = Seq( 38 | "io.d11" %% "zhttp" % "2.0.0-RC10", 39 | "dev.zio" %% "zio" % ZIOVersion, 40 | "io.github.scottweaver" %% "zio-2-0-db-migration-aspect" % ZIOTestContainersVersion, 41 | "io.github.scottweaver" %% "zio-2-0-testcontainers-postgresql" % ZIOTestContainersVersion, 42 | "dev.zio" %% "zio-config" % ZIOConfigVersion, 43 | "dev.zio" %% "zio-config-typesafe" % ZIOConfigVersion, 44 | "dev.zio" %% "zio-config-magnolia" % ZIOConfigVersion, 45 | "dev.zio" %% "zio-interop-cats" % "3.3.0", 46 | "dev.zio" %% "zio-json" % "0.3.0-RC10", 47 | "dev.zio" %% "zio-mock" % "1.0.0-RC8", 48 | "dev.zio" %% "zio-prelude" % "1.0.0-RC15", 49 | "dev.zio" %% "zio-streams" % ZIOVersion, 50 | "dev.zio" %% "zio-test" % ZIOVersion % Test, 51 | "dev.zio" %% "zio-test-magnolia" % ZIOVersion % Test, 52 | "dev.zio" %% "zio-test-sbt" % ZIOVersion % Test 53 | ) 54 | 55 | lazy val dependencies = database ++ logging ++ tapir ++ zio 56 | 57 | } 58 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.7.1 2 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.4.6") 2 | -------------------------------------------------------------------------------- /src/main/resources/application.conf: -------------------------------------------------------------------------------- 1 | http { 2 | host = "0.0.0.0" 3 | port = 8080 4 | } 5 | 6 | database { 7 | driver = "org.postgresql.Driver" 8 | password = "funcprog" 9 | url = "jdbc:postgresql://localhost:5432/funcprog" 10 | user = "funcprog_user" 11 | } 12 | -------------------------------------------------------------------------------- /src/main/resources/db/migration/V20220831_1__init.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS vote; 2 | 3 | DROP TABLE IF EXISTS person; 4 | 5 | DROP TABLE IF EXISTS party; 6 | 7 | CREATE TABLE person ( 8 | firstname TEXT NOT NULL, 9 | lastname TEXT NOT NULL, 10 | national_identification_number TEXT PRIMARY KEY 11 | ); 12 | 13 | CREATE TABLE party (party_name TEXT PRIMARY KEY); 14 | 15 | CREATE TABLE vote ( 16 | created_at TIMESTAMP WITH TIME ZONE NOT NULL, 17 | national_identification_number TEXT NOT NULL, 18 | party_name TEXT NOT NULL, 19 | FOREIGN KEY (national_identification_number) REFERENCES person(national_identification_number), 20 | FOREIGN KEY (party_name) REFERENCES party(party_name), 21 | PRIMARY KEY (national_identification_number) 22 | ); -------------------------------------------------------------------------------- /src/main/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | %d{HH:mm:ss.SSS} %X{correlation_id} [%thread %X{fiber}] %-5level %logger{36}=%X{spanTime} %msg%n 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/main/scala/funcprog/App.scala: -------------------------------------------------------------------------------- 1 | package funcprog 2 | 3 | import funcprog.config.AppConfig 4 | import funcprog.routes.ElectionServer 5 | 6 | import zio.* 7 | 8 | import zhttp.service.Server 9 | 10 | object App { 11 | 12 | def program = ZIO.scoped { 13 | for { 14 | config <- ZIO.service[AppConfig] 15 | httpApp <- ElectionServer.httpApp 16 | start <- Server(httpApp).withBinding(config.http.host, config.http.port).make.orDie 17 | _ <- ZIO.logInfo(s"Server started on port: ${start.port}") 18 | _ <- ZIO.never 19 | } yield () 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /src/main/scala/funcprog/ElectionService.scala: -------------------------------------------------------------------------------- 1 | package funcprog 2 | 3 | import funcprog.ElectionService.* 4 | import funcprog.database.* 5 | import funcprog.model.* 6 | import funcprog.validation.* 7 | 8 | import zio.* 9 | import zio.json.* 10 | 11 | import sttp.tapir.Schema 12 | 13 | trait ElectionService { 14 | def createParty(party: Party): ZIO[Any, Nothing, Party] 15 | def registerVote(vote: Vote): ZIO[Any, Error.InvalidInput, Unit] 16 | def getVote(nationalIdNumber: Nin): ZIO[Any, Error.NotFound, Vote] 17 | } 18 | 19 | object ElectionService { 20 | 21 | lazy val live: ZLayer[Database & ValidationService, Nothing, ElectionService] = ZLayer { 22 | for { 23 | database <- ZIO.service[Database] 24 | validationService <- ZIO.service[ValidationService] 25 | } yield ElectionServiceLive(database, validationService) 26 | } 27 | 28 | final case class ElectionServiceLive( 29 | database: Database, 30 | validationService: ValidationService 31 | ) extends ElectionService { 32 | 33 | override def createParty(party: Party): ZIO[Any, Nothing, Party] = 34 | database 35 | .insertParty(party) 36 | .tap(inserted => ZIO.logInfo(s"Created party: $inserted")) 37 | .catchAll { e => 38 | ZIO.logInfo(s"Got unique constraint violation error: $e. Ignoring...").as(party) 39 | } 40 | 41 | override def registerVote(vote: Vote): ZIO[Any, Error.InvalidInput, Unit] = { 42 | 43 | def validateVote(vote: Vote) = 44 | validationService 45 | .validateVote(vote) 46 | .retry( 47 | Schedule.recurs(5) && Schedule.spaced(10.millis) && Schedule.recurWhile(_ == ValidationError.Temporary) 48 | ) 49 | .tapError(e => ZIO.logError(s"Failed to validate vote: $vote with error: $e")) 50 | .orDie 51 | 52 | def handleResponse(response: ValidationResponse) = response match { 53 | case ValidationResponse.Valid => 54 | ZIO.logInfo(s"Vote: $vote is valid. Inserting.") *> database.insertVote(vote) 55 | case ValidationResponse.Invalid => 56 | ZIO.fail(Error.InvalidInput(s"Invalid vote: $vote")) 57 | } 58 | 59 | for { 60 | _ <- ZIO.logInfo(s"Registering vote: $vote") 61 | response <- validateVote(vote) 62 | _ <- handleResponse(response) 63 | } yield () 64 | 65 | } 66 | 67 | override def getVote(nationalIdNumber: Nin): ZIO[Any, Error.NotFound, Vote] = 68 | database 69 | .getVote(nationalIdNumber) 70 | .someOrFail(Error.NotFound(s"Vote for $nationalIdNumber not found.")) 71 | .map(_.toVote) 72 | } 73 | 74 | sealed trait Error 75 | object Error { 76 | implicit lazy val codec: JsonCodec[Error] = DeriveJsonCodec.gen 77 | 78 | case class InvalidInput(error: String) extends Error 79 | object InvalidInput { 80 | implicit lazy val codec: JsonCodec[InvalidInput] = DeriveJsonCodec.gen 81 | implicit lazy val schema: Schema[InvalidInput] = Schema.derived 82 | } 83 | 84 | case class NotFound(message: String) extends Error 85 | object NotFound { 86 | implicit lazy val codec: JsonCodec[NotFound] = DeriveJsonCodec.gen 87 | implicit lazy val schema: Schema[NotFound] = Schema.derived 88 | } 89 | } 90 | 91 | } 92 | -------------------------------------------------------------------------------- /src/main/scala/funcprog/HttpServerSettings.scala: -------------------------------------------------------------------------------- 1 | package funcprog 2 | 3 | import zio.* 4 | 5 | import io.netty.channel.{ChannelFactory, ServerChannel} 6 | import zhttp.service.EventLoopGroup 7 | import zhttp.service.server.ServerChannelFactory 8 | 9 | object HttpServerSettings { 10 | type HttpServerSettings = ChannelFactory[ServerChannel] & EventLoopGroup 11 | lazy val default: ZLayer[Any, Nothing, HttpServerSettings] = EventLoopGroup.auto(0) ++ ServerChannelFactory.auto 12 | } 13 | -------------------------------------------------------------------------------- /src/main/scala/funcprog/Main.scala: -------------------------------------------------------------------------------- 1 | package funcprog 2 | 3 | import funcprog.config.AppConfig 4 | import funcprog.database.Database 5 | import funcprog.routes.ElectionServer 6 | import funcprog.validation.ValidationService 7 | 8 | import zio.* 9 | 10 | object Main extends ZIOAppDefault { 11 | 12 | override val run = 13 | App.program.provide( 14 | HttpServerSettings.default, 15 | AppConfig.live, 16 | ElectionServer.live, 17 | ElectionService.live, 18 | Database.live, 19 | ValidationService.live 20 | ) 21 | 22 | } 23 | -------------------------------------------------------------------------------- /src/main/scala/funcprog/config/config.scala: -------------------------------------------------------------------------------- 1 | package funcprog 2 | 3 | import com.typesafe.config.ConfigFactory 4 | 5 | import zio.* 6 | import zio.config.* 7 | import zio.config.magnolia.* 8 | import zio.config.syntax.* 9 | import zio.config.typesafe.TypesafeConfigSource.fromTypesafeConfig 10 | import zio.prelude.* 11 | 12 | package object config { 13 | 14 | type ConfigEnv = AppConfig & HttpConfig & DatabaseConfig 15 | 16 | final case class AppConfig( 17 | database: DatabaseConfig, 18 | http: HttpConfig 19 | ) 20 | 21 | object AppConfig { 22 | private lazy val appConfigLayer: ZLayer[Any, Nothing, AppConfig] = ZLayer { 23 | val getTypesafeConfig = ZIO.attempt(ConfigFactory.load.resolve) 24 | val getConfig = read(descriptor[AppConfig].from(fromTypesafeConfig(getTypesafeConfig))) 25 | getConfig.orDie 26 | } 27 | 28 | val live: ZLayer[Any, Nothing, ConfigEnv] = ZLayer.make[ConfigEnv]( 29 | appConfigLayer, 30 | appConfigLayer.narrow(_.database), 31 | appConfigLayer.narrow(_.http) 32 | ) 33 | } 34 | 35 | // Http config 36 | 37 | case class HttpConfig( 38 | host: Host, 39 | port: Port 40 | ) 41 | 42 | type Host = Host.Type 43 | object Host extends Subtype[String] { 44 | implicit lazy val d: Descriptor[Host] = derive(implicitly[Descriptor[String]]) 45 | } 46 | 47 | type Port = Port.Type 48 | object Port extends Subtype[Int] { 49 | implicit lazy val d: Descriptor[Port] = derive(implicitly[Descriptor[Int]]) 50 | } 51 | 52 | // Database config 53 | 54 | case class DatabaseConfig( 55 | driver: DbDriver, 56 | password: DbPassword, 57 | url: DbUrl, 58 | user: DbUser 59 | ) 60 | 61 | type DbDriver = DbDriver.Type 62 | object DbDriver extends Subtype[String] { 63 | implicit lazy val d: Descriptor[DbDriver] = derive(implicitly[Descriptor[String]]) 64 | } 65 | 66 | type DbPassword = DbPassword.Type 67 | object DbPassword extends Subtype[String] { 68 | implicit lazy val d: Descriptor[DbPassword] = derive(implicitly[Descriptor[String]]) 69 | } 70 | 71 | type DbUrl = DbUrl.Type 72 | object DbUrl extends Subtype[String] { 73 | implicit lazy val d: Descriptor[DbUrl] = derive(implicitly[Descriptor[String]]) 74 | } 75 | 76 | type DbUser = DbUser.Type 77 | object DbUser extends Subtype[String] { 78 | implicit lazy val d: Descriptor[DbUser] = derive(implicitly[Descriptor[String]]) 79 | } 80 | 81 | } 82 | -------------------------------------------------------------------------------- /src/main/scala/funcprog/database/Database.scala: -------------------------------------------------------------------------------- 1 | package funcprog.database 2 | 3 | import java.time.Instant 4 | 5 | import org.flywaydb.core.Flyway 6 | 7 | import funcprog.config.* 8 | import funcprog.model.* 9 | 10 | import zio.* 11 | import zio.interop.catz.* 12 | import zio.interop.catz.implicits.* 13 | 14 | import doobie.* 15 | import doobie.hikari.* 16 | import doobie.implicits.* 17 | import doobie.implicits.legacy.instant.* 18 | import doobie.util.ExecutionContexts 19 | 20 | trait Database { 21 | def insertParty(party: Party): ZIO[Any, UniqueConstraintViolation, Party] 22 | def insertVote(vote: Vote): ZIO[Any, Nothing, Unit] 23 | def getVote(nationalIdNumber: Nin): ZIO[Any, Nothing, Option[VoteRecord]] 24 | 25 | private[database] def deleteAllRows: ZIO[Any, Nothing, Unit] 26 | } 27 | 28 | object Database { 29 | 30 | def insertParty(party: Party): ZIO[Database, UniqueConstraintViolation, Party] = 31 | ZIO.serviceWithZIO[Database](_.insertParty(party)) 32 | 33 | def insertVote(vote: Vote): ZIO[Database, Nothing, Unit] = ZIO.serviceWithZIO[Database](_.insertVote(vote)) 34 | 35 | def getVote(nationalIdNumber: Nin): ZIO[Database, Nothing, Option[VoteRecord]] = 36 | ZIO.serviceWithZIO[Database](_.getVote(nationalIdNumber)) 37 | 38 | def deleteAllRows: ZIO[Database, Nothing, Unit] = 39 | ZIO.serviceWithZIO[Database](_.deleteAllRows) 40 | 41 | lazy val live: ZLayer[DatabaseConfig, Throwable, Database] = ZLayer.scoped { 42 | for { 43 | config <- ZIO.service[DatabaseConfig] 44 | _ <- loadAndMigrateFlyway(config) 45 | ec <- ExecutionContexts.fixedThreadPool[Task](32).toScopedZIO 46 | transactor <- HikariTransactor 47 | .newHikariTransactor[Task]( 48 | config.driver, 49 | config.url, 50 | config.user, 51 | config.password, 52 | ec 53 | ) 54 | .toScopedZIO 55 | database = DatabaseLive(transactor) 56 | _ <- database.deleteAllRows // Delete all rows to get a fresh DB each start 57 | } yield database 58 | 59 | } 60 | 61 | case class DatabaseLive(transactor: HikariTransactor[Task]) extends Database { 62 | 63 | override def insertParty(party: Party): ZIO[Any, UniqueConstraintViolation, Party] = { 64 | val transaction = for { 65 | _ <- SQL.insertParty(party).run 66 | party <- SQL.getParty(party).unique 67 | } yield party 68 | 69 | transaction 70 | .transact(transactor) 71 | .catchAll { 72 | case e: org.postgresql.util.PSQLException if e.getMessage.contains("""unique constraint "party_pkey"""") => 73 | ZIO.fail(UniqueConstraintViolation(e.getMessage)) 74 | 75 | case e => 76 | ZIO.die(e) 77 | } 78 | } 79 | 80 | override def insertVote(vote: Vote): ZIO[Any, Nothing, Unit] = 81 | Clock.instant.flatMap { now => 82 | val transaction = for { 83 | _ <- SQL.insertPerson(vote.person).run 84 | _ <- SQL.insertVote(vote, now).run 85 | } yield () 86 | 87 | transaction.transact(transactor).orDie 88 | } 89 | 90 | override def getVote(nationalIdNumber: Nin): ZIO[Any, Nothing, Option[VoteRecord]] = 91 | SQL 92 | .getVote(nationalIdNumber) 93 | .option 94 | .transact(transactor) 95 | .orDie 96 | 97 | override private[database] def deleteAllRows: ZIO[Any, Nothing, Unit] = 98 | SQL.deleteAllRows.run.transact(transactor).unit.orDie 99 | 100 | } 101 | 102 | object SQL { 103 | 104 | def getParty(party: Party): Query0[Party] = 105 | sql"""SELECT * 106 | FROM party 107 | WHERE party_name = ${PartyName.unwrap(party.partyName)} 108 | """ 109 | .query[String] 110 | .map(partyName => Party(PartyName(partyName))) 111 | 112 | def getVote(nationalIdNumber: Nin): Query0[VoteRecord] = 113 | sql"""SELECT pe.firstname, pe.lastname, vo.national_identification_number, pa.party_name, vo.created_at 114 | FROM vote vo 115 | INNER JOIN person pe ON vo.national_identification_number = pe.national_identification_number 116 | INNER JOIN party pa ON pa.party_name = vo.party_name 117 | WHERE vo.national_identification_number = ${Nin.unwrap(nationalIdNumber)}""" 118 | .query[(String, String, String, String, Instant)] 119 | .map { case (firstname, lastname, nationalIdNumber, partyName, createdAt) => 120 | VoteRecord( 121 | Person(Firstname(firstname), Lastname(lastname), Nin(nationalIdNumber)), 122 | Party(PartyName(partyName)), 123 | createdAt 124 | ) 125 | } 126 | 127 | def insertParty(party: Party): Update0 = 128 | sql"""INSERT INTO party 129 | (party_name) 130 | VALUES (${PartyName.unwrap(party.partyName)}) 131 | """.update 132 | 133 | def insertPerson(person: Person): Update0 = 134 | sql"""INSERT INTO person 135 | ( 136 | firstname, 137 | lastname, 138 | national_identification_number 139 | ) 140 | VALUES ( 141 | ${Firstname.unwrap(person.firstname)}, 142 | ${Lastname.unwrap(person.lastname)}, 143 | ${Nin.unwrap(person.nationalIdNumber)} 144 | ) 145 | """.update 146 | 147 | def insertVote(vote: Vote, now: Instant): Update0 = 148 | sql"""INSERT INTO vote 149 | (national_identification_number, party_name, created_at) 150 | VALUES (${Nin.unwrap(vote.person.nationalIdNumber)}, ${PartyName.unwrap(vote.party.partyName)}, $now) 151 | """.update 152 | 153 | def deleteAllRows = 154 | sql""" 155 | TRUNCATE party, person, vote; 156 | """.update 157 | 158 | } 159 | 160 | def loadAndMigrateFlyway(config: DatabaseConfig): Task[Unit] = 161 | for { 162 | flyway <- ZIO.attempt { 163 | Flyway 164 | .configure() 165 | .dataSource(config.url, config.user, config.password) 166 | .load() 167 | } 168 | _ <- ZIO.attempt(flyway.migrate()) 169 | } yield () 170 | 171 | } 172 | -------------------------------------------------------------------------------- /src/main/scala/funcprog/database/UniqueConstraintViolation.scala: -------------------------------------------------------------------------------- 1 | package funcprog.database 2 | 3 | case class UniqueConstraintViolation(message: String) extends Throwable 4 | -------------------------------------------------------------------------------- /src/main/scala/funcprog/database/VoteRecord.scala: -------------------------------------------------------------------------------- 1 | package funcprog.database 2 | 3 | import java.time.Instant 4 | 5 | import funcprog.model.* 6 | 7 | final case class VoteRecord( 8 | person: Person, 9 | party: Party, 10 | createdAt: Instant 11 | ) { 12 | def toVote: Vote = Vote(person, party) 13 | } 14 | -------------------------------------------------------------------------------- /src/main/scala/funcprog/model/Party.scala: -------------------------------------------------------------------------------- 1 | package funcprog.model 2 | 3 | import zio.json.* 4 | 5 | import sttp.tapir.Schema 6 | 7 | final case class Party(partyName: PartyName) 8 | 9 | object Party { 10 | implicit val jsonCodec: JsonCodec[Party] = DeriveJsonCodec.gen 11 | implicit val schema: Schema[Party] = Schema.derived 12 | } 13 | -------------------------------------------------------------------------------- /src/main/scala/funcprog/model/Person.scala: -------------------------------------------------------------------------------- 1 | package funcprog.model 2 | 3 | import zio.json.* 4 | 5 | import sttp.tapir.Schema 6 | 7 | final case class Person( 8 | firstname: Firstname, 9 | lastname: Lastname, 10 | nationalIdNumber: Nin 11 | ) 12 | 13 | object Person { 14 | implicit val jsonCodec: JsonCodec[Person] = DeriveJsonCodec.gen 15 | implicit val schema: Schema[Person] = Schema.derived 16 | } 17 | -------------------------------------------------------------------------------- /src/main/scala/funcprog/model/Vote.scala: -------------------------------------------------------------------------------- 1 | package funcprog.model 2 | 3 | import zio.json.* 4 | 5 | import sttp.tapir.Schema 6 | 7 | final case class Vote( 8 | person: Person, 9 | party: Party 10 | ) 11 | 12 | object Vote { 13 | implicit val jsonCodec: JsonCodec[Vote] = DeriveJsonCodec.gen 14 | implicit val schema: Schema[Vote] = Schema.derived 15 | } 16 | -------------------------------------------------------------------------------- /src/main/scala/funcprog/model/VoteResult.scala: -------------------------------------------------------------------------------- 1 | package funcprog.model 2 | 3 | import zio.json.* 4 | 5 | import sttp.tapir.Schema 6 | 7 | final case class VoteResult(partyName: String, percentage: Int) 8 | 9 | object VoteResult { 10 | implicit val jsonCodec: JsonCodec[VoteResult] = DeriveJsonCodec.gen 11 | implicit val schema: Schema[VoteResult] = Schema.derived 12 | } 13 | -------------------------------------------------------------------------------- /src/main/scala/funcprog/model/package.scala: -------------------------------------------------------------------------------- 1 | package funcprog 2 | 3 | import java.net.{URLDecoder, URLEncoder} 4 | 5 | import zio.json.* 6 | import zio.prelude.* 7 | 8 | import sttp.tapir.* 9 | import sttp.tapir.CodecFormat.TextPlain 10 | 11 | package object model { 12 | 13 | type Firstname = Firstname.Type 14 | object Firstname extends Subtype[String] { 15 | implicit lazy val jsonCodec: JsonCodec[Firstname] = derive[JsonCodec] 16 | implicit lazy val schema: Schema[Firstname] = derive[Schema] 17 | } 18 | 19 | type Lastname = Lastname.Type 20 | object Lastname extends Subtype[String] { 21 | implicit lazy val jsonCodec: JsonCodec[Lastname] = derive[JsonCodec] 22 | implicit lazy val schema: Schema[Lastname] = derive[Schema] 23 | } 24 | 25 | type Nin = Nin.Type 26 | object Nin extends Subtype[String] { 27 | implicit lazy val jsonCodec: JsonCodec[Nin] = derive[JsonCodec] 28 | implicit lazy val schema: Schema[Nin] = derive[Schema] 29 | implicit lazy val codec: Codec[String, Nin, TextPlain] = 30 | derive[Codec[String, *, TextPlain]]( 31 | Codec.string.map(URLDecoder.decode(_, "UTF-8"))(URLEncoder.encode(_, "UTF-8")) 32 | ) 33 | } 34 | 35 | type PartyName = PartyName.Type 36 | 37 | object PartyName extends Subtype[String] { 38 | implicit lazy val jsonCodec: JsonCodec[PartyName] = derive[JsonCodec] 39 | implicit lazy val schema: Schema[PartyName] = derive[Schema] 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /src/main/scala/funcprog/routes/ElectionServer.scala: -------------------------------------------------------------------------------- 1 | package funcprog.routes 2 | 3 | import funcprog.ElectionService 4 | import funcprog.ElectionService.* 5 | import funcprog.model.* 6 | 7 | import zio.* 8 | 9 | import sttp.apispec.openapi.circe.yaml.* 10 | import sttp.model.StatusCode 11 | import sttp.tapir.docs.openapi.OpenAPIDocsInterpreter 12 | import sttp.tapir.json.zio.* 13 | import sttp.tapir.server.ziohttp.ZioHttpInterpreter 14 | import sttp.tapir.swagger.SwaggerUI 15 | import sttp.tapir.ztapir.* 16 | import zhttp.http.HttpApp 17 | 18 | trait ElectionServer { 19 | def httpApp: ZIO[Any, Nothing, HttpApp[Any, Throwable]] 20 | } 21 | 22 | object ElectionServer { 23 | 24 | lazy val live: ZLayer[ElectionService, Nothing, ElectionServer] = ZLayer { 25 | for { 26 | electionService <- ZIO.service[ElectionService] 27 | } yield ElectionServerLive(electionService) 28 | } 29 | 30 | def httpApp: ZIO[ElectionServer, Nothing, HttpApp[Any, Throwable]] = 31 | ZIO.serviceWithZIO[ElectionServer](_.httpApp) 32 | 33 | } 34 | 35 | final case class ElectionServerLive(service: ElectionService) extends ElectionServer { 36 | 37 | private val baseEndpoint = endpoint.in("election") 38 | 39 | private val getVoteErrorOut = oneOf[Error]( 40 | oneOfVariant(StatusCode.NotFound, jsonBody[Error.NotFound].description("Vote was not found.")) 41 | ) 42 | 43 | private val postVoteErrorOut = oneOf[Error]( 44 | oneOfVariant(StatusCode.BadRequest, jsonBody[Error.InvalidInput].description("Invalid vote.")) 45 | ) 46 | 47 | private val examplePerson = Person(Firstname("Erik"), Lastname("Eriksson"), Nin("199001010000")) 48 | 49 | private val exampleParty = Party(PartyName("FuncProg2022")) 50 | 51 | private val exampleVote = Vote(examplePerson, exampleParty) 52 | 53 | private val partyBody = jsonBody[Party].example(exampleParty) 54 | 55 | private val voteBody = jsonBody[Vote].example(exampleVote) 56 | 57 | private val getVoteEndpoint = 58 | baseEndpoint.get 59 | .in("vote") 60 | .in(path[Nin]("national_identification_number")) 61 | .out(voteBody) 62 | .errorOut(getVoteErrorOut) 63 | 64 | private val putPartyEndpoint = 65 | baseEndpoint.put 66 | .in("party") 67 | .in(partyBody) 68 | .out(partyBody) 69 | 70 | private val postVoteEndpoint = 71 | baseEndpoint.post 72 | .in("vote") 73 | .in(voteBody) 74 | .errorOut(postVoteErrorOut) 75 | 76 | private val getVoteRoute = 77 | getVoteEndpoint.zServerLogic { case nationalIdNumber => 78 | service.getVote(nationalIdNumber) 79 | } 80 | 81 | private val putPartyRoute = 82 | putPartyEndpoint.zServerLogic { case party => 83 | service.createParty(party) 84 | } 85 | 86 | private val postVoteRoute = 87 | postVoteEndpoint.zServerLogic { case vote => 88 | service.registerVote(vote) 89 | } 90 | 91 | private val allRoutes = List( 92 | getVoteRoute, 93 | putPartyRoute, 94 | postVoteRoute 95 | ) 96 | 97 | private val endpoints = { 98 | val endpoints = List( 99 | getVoteEndpoint, 100 | putPartyEndpoint, 101 | postVoteEndpoint 102 | ) 103 | endpoints.map(_.tags(List("Election Endpoints"))) 104 | } 105 | 106 | override def httpApp: ZIO[Any, Nothing, HttpApp[Any, Throwable]] = 107 | for { 108 | openApi <- ZIO.succeed(OpenAPIDocsInterpreter().toOpenAPI(endpoints, "Election Service", "0.1")) 109 | routesHttp <- ZIO.succeed(ZioHttpInterpreter().toHttp(allRoutes)) 110 | endPointsHttp <- ZIO.succeed(ZioHttpInterpreter().toHttp(SwaggerUI[Task](openApi.toYaml))) 111 | } yield routesHttp ++ endPointsHttp 112 | 113 | } 114 | -------------------------------------------------------------------------------- /src/main/scala/funcprog/validation/ValidationError.scala: -------------------------------------------------------------------------------- 1 | package funcprog.validation 2 | 3 | sealed trait ValidationError extends Throwable 4 | 5 | object ValidationError { 6 | case object Temporary extends ValidationError 7 | case class ResponseError(code: String) extends ValidationError 8 | } 9 | -------------------------------------------------------------------------------- /src/main/scala/funcprog/validation/ValidationResponse.scala: -------------------------------------------------------------------------------- 1 | package funcprog.validation 2 | 3 | sealed trait ValidationResponse 4 | 5 | object ValidationResponse { 6 | case object Valid extends ValidationResponse 7 | case object Invalid extends ValidationResponse 8 | } 9 | -------------------------------------------------------------------------------- /src/main/scala/funcprog/validation/ValidationService.scala: -------------------------------------------------------------------------------- 1 | package funcprog.validation 2 | 3 | import funcprog.database.Database 4 | import funcprog.model.* 5 | 6 | import zio.* 7 | 8 | trait ValidationService { 9 | def validateVote(vote: Vote): ZIO[Any, ValidationError, ValidationResponse] 10 | } 11 | 12 | object ValidationService { 13 | 14 | lazy val live: ZLayer[Database, Nothing, ValidationService] = ZLayer { 15 | for { 16 | database <- ZIO.service[Database] 17 | } yield ValidationServiceLive(database) 18 | } 19 | 20 | final case class ValidationServiceLive(database: Database) extends ValidationService { 21 | override def validateVote(vote: Vote): IO[ValidationError, ValidationResponse] = 22 | for { 23 | _ <- ZIO.logInfo(s"Validating vote: $vote...") 24 | } yield ValidationResponse.Valid 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/test/resources/logback-test.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | %d{HH:mm:ss.SSS} %X{correlation_id} [%thread %X{fiber}] %-5level %logger{36}=%X{spanTime} %msg%n 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/test/scala/funcprog/Generators.scala: -------------------------------------------------------------------------------- 1 | package funcprog 2 | 3 | import funcprog.model.* 4 | 5 | import zio.test.* 6 | import zio.test.magnolia.* 7 | 8 | object Generators { 9 | 10 | implicit lazy val genFirstname: Gen[Sized, Firstname] = Gen.string.map(Firstname(_)) 11 | implicit lazy val deriveGenFirstname: DeriveGen[Firstname] = DeriveGen.instance(genFirstname) 12 | 13 | implicit lazy val genLastname: Gen[Sized, Lastname] = Gen.string.map(Lastname(_)) 14 | implicit lazy val deriveGenLastname: DeriveGen[Lastname] = DeriveGen.instance(genLastname) 15 | 16 | implicit lazy val genNin: Gen[Sized, Nin] = Gen.string.map(Nin(_)) 17 | implicit lazy val deriveGenNin: DeriveGen[Nin] = DeriveGen.instance(genNin) 18 | 19 | implicit lazy val genPartyName: Gen[Sized, PartyName] = Gen.string.map(PartyName(_)) 20 | implicit lazy val deriveGenPartyName: DeriveGen[PartyName] = DeriveGen.instance(genPartyName) 21 | 22 | lazy val genParty: Gen[Sized, Party] = DeriveGen[Party] 23 | 24 | lazy val genVote: Gen[Sized, Vote] = DeriveGen[Vote] 25 | 26 | } 27 | -------------------------------------------------------------------------------- /src/test/scala/funcprog/database/DatabaseSpec.scala: -------------------------------------------------------------------------------- 1 | package funcprog.database 2 | 3 | import com.dimafeng.testcontainers.PostgreSQLContainer 4 | 5 | import funcprog.Generators 6 | import funcprog.config.* 7 | 8 | import zio.* 9 | import zio.test.* 10 | import zio.test.TestAspect.* 11 | 12 | import io.github.scottweaver.zio.testcontainers.postgres.* 13 | 14 | object DatabaseSpec extends ZIOSpecDefault { 15 | 16 | override def spec: Spec[TestEnvironment, Throwable] = 17 | databaseSuite 18 | .provideSome( 19 | Database.live, 20 | databaseLayer, 21 | ZPostgreSQLContainer.live, 22 | ZPostgreSQLContainer.Settings.default 23 | ) 24 | 25 | lazy val databaseLayer: ZLayer[PostgreSQLContainer, Nothing, DatabaseConfig] = { 26 | val cfg = ZIO.serviceWith[PostgreSQLContainer] { container => 27 | DatabaseConfig( 28 | DbDriver(container.container.getDriverClassName()), 29 | DbPassword(container.container.getPassword()), 30 | DbUrl(container.container.getJdbcUrl()), 31 | DbUser(container.container.getUsername()) 32 | ) 33 | } 34 | ZLayer(cfg) 35 | } 36 | 37 | lazy val databaseSuite = suite("DatabaseSpec")( 38 | test("should be able to insert party") { 39 | check(Generators.genParty) { party => 40 | for { 41 | _ <- Database.insertParty(party) 42 | _ <- Database.deleteAllRows 43 | } yield assertCompletes 44 | } 45 | }, 46 | test("should fail with UniqueConstraintViolation if inserting twice") { 47 | check(Generators.genParty) { party => 48 | for { 49 | _ <- Database.insertParty(party) 50 | res <- Database.insertParty(party).either 51 | _ <- Database.deleteAllRows 52 | } yield assertTrue(res.isLeft) 53 | } 54 | }, 55 | test("should be able to insert vote") { 56 | check(Generators.genVote) { vote => 57 | for { 58 | _ <- Database.insertParty(vote.party) 59 | _ <- Database.insertVote(vote) 60 | _ <- Database.deleteAllRows 61 | } yield assertCompletes 62 | } 63 | }, 64 | test("should be able to insert and get vote") { 65 | check(Generators.genVote) { vote => 66 | for { 67 | _ <- Database.insertParty(vote.party) 68 | _ <- Database.insertVote(vote) 69 | actual <- Database.getVote(vote.person.nationalIdNumber) 70 | _ <- Database.deleteAllRows 71 | } yield assertTrue(vote == actual.get.toVote) 72 | } 73 | } 74 | ) @@ timed 75 | 76 | } 77 | --------------------------------------------------------------------------------