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