├── project ├── build.properties ├── plugins.sbt ├── Settings.scala └── Dependencies.scala ├── .gitignore ├── .github └── workflows │ ├── scala-steward.yml │ └── scala.yml ├── .scalafmt.conf ├── server └── src │ └── main │ ├── resources │ └── logback.xml │ └── scala │ └── com │ └── psisoyev │ └── train │ └── station │ ├── HttpServer.scala │ ├── EventLogger.scala │ ├── TrackerEngine.scala │ ├── Routes.scala │ ├── Resources.scala │ ├── Config.scala │ └── Main.scala ├── domain └── src │ └── main │ └── scala │ └── com │ └── psisoyev │ └── train │ └── station │ ├── Event.scala │ └── package.scala ├── service └── src │ ├── main │ └── scala │ │ └── com │ │ └── psisoyev │ │ └── train │ │ └── station │ │ ├── Tracing.scala │ │ ├── arrival │ │ ├── ExpectedTrains.scala │ │ ├── Arrivals.scala │ │ └── ArrivalValidator.scala │ │ ├── Context.scala │ │ └── departure │ │ ├── DepartureTracker.scala │ │ └── Departures.scala │ └── test │ └── scala │ └── com │ └── psisoyev │ └── train │ └── station │ ├── departure │ ├── DepartureTrackerSpec.scala │ └── DeparturesSpec.scala │ ├── Generators.scala │ ├── arrival │ ├── ArrivalsSpec.scala │ └── ArrivalValidatorSpec.scala │ └── BaseSpec.scala ├── docker-compose.yml ├── README.md └── route └── src └── main └── scala └── com └── psisoyev └── train └── station └── StationRoutes.scala /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version = 1.9.9 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | .idea/ 3 | cache/ 4 | out/ 5 | .metals 6 | .bloop 7 | metals.sbt 8 | .bsp/ 9 | .vscode/ -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("io.github.davidgregory084" % "sbt-tpolecat" % "0.4.3") 2 | addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.2") 3 | addSbtPlugin("com.github.sbt" % "sbt-native-packager" % "1.9.16") 4 | -------------------------------------------------------------------------------- /.github/workflows/scala-steward.yml: -------------------------------------------------------------------------------- 1 | # This workflow will launch at 00:00 every day 2 | on: 3 | schedule: 4 | - cron: '0 0 * * *' 5 | 6 | jobs: 7 | scala-steward: 8 | runs-on: ubuntu-latest 9 | name: Launch Scala Steward 10 | steps: 11 | - name: Launch Scala Steward 12 | uses: scala-steward-org/scala-steward-action@v2 13 | with: 14 | github-token: ${{ secrets.ADMIN_GITHUB_TOKEN }} 15 | -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version = "2.7.5" 2 | maxColumn = 150 3 | align = most 4 | continuationIndent.defnSite = 2 5 | assumeStandardLibraryStripMargin = true 6 | docstrings = JavaDoc 7 | lineEndings = preserve 8 | includeCurlyBraceInSelectChains = false 9 | danglingParentheses = true 10 | spaces { 11 | inImportCurlyBraces = true 12 | } 13 | optIn.annotationNewlines = true 14 | includeNoParensInSelectChains = true 15 | 16 | rewrite.rules = [SortImports, RedundantBraces] -------------------------------------------------------------------------------- /server/src/main/resources/logback.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /server/src/main/scala/com/psisoyev/train/station/HttpServer.scala: -------------------------------------------------------------------------------- 1 | package com.psisoyev.train.station 2 | 3 | import cats.effect.{ ConcurrentEffect, Timer } 4 | import com.psisoyev.train.station.Main.{ platform, Routes } 5 | import org.http4s.server.blaze.BlazeServerBuilder 6 | 7 | object HttpServer { 8 | def start[Init[_]: ConcurrentEffect: Timer]( 9 | config: Config, 10 | routes: Routes[Init] 11 | ): Init[Unit] = 12 | BlazeServerBuilder[Init](platform.executor.asEC) 13 | .bindHttp(config.httpPort.value, "0.0.0.0") 14 | .withHttpApp(routes) 15 | .serve 16 | .compile 17 | .drain 18 | } 19 | -------------------------------------------------------------------------------- /domain/src/main/scala/com/psisoyev/train/station/Event.scala: -------------------------------------------------------------------------------- 1 | package com.psisoyev.train.station 2 | 3 | import io.circe._ 4 | import io.circe.generic.semiauto._ 5 | 6 | sealed trait Event { 7 | def id: EventId 8 | def trainId: TrainId 9 | def created: Timestamp 10 | } 11 | 12 | object Event { 13 | final case class Departed(id: EventId, trainId: TrainId, from: From, to: To, expected: Expected, created: Timestamp) extends Event 14 | final case class Arrived(id: EventId, trainId: TrainId, from: From, to: To, expected: Expected, created: Timestamp) extends Event 15 | 16 | implicit val eventEncoder: Encoder[Event] = deriveEncoder 17 | implicit val eventDecoder: Decoder[Event] = deriveDecoder 18 | } 19 | -------------------------------------------------------------------------------- /server/src/main/scala/com/psisoyev/train/station/EventLogger.scala: -------------------------------------------------------------------------------- 1 | package com.psisoyev.train.station 2 | 3 | import cats.Show 4 | import cats.implicits.toShow 5 | import cr.pulsar.Topic 6 | import org.typelevel.log4cats.StructuredLogger 7 | import io.circe.Encoder 8 | import io.circe.syntax._ 9 | 10 | object EventLogger { 11 | 12 | sealed trait EventFlow 13 | object EventFlow { 14 | case object In extends EventFlow 15 | case object Out extends EventFlow 16 | 17 | implicit def show: Show[EventFlow] = Show.fromToString 18 | } 19 | 20 | def logEvents[F[_]: StructuredLogger, E: Encoder](flow: EventFlow): E => Topic.URL => F[Unit] = 21 | event => topic => F.info(Map("topic" -> topic.value, "flow" -> flow.show))(event.asJson.noSpaces) 22 | 23 | } 24 | -------------------------------------------------------------------------------- /server/src/main/scala/com/psisoyev/train/station/TrackerEngine.scala: -------------------------------------------------------------------------------- 1 | package com.psisoyev.train.station 2 | 3 | import cats.effect.Concurrent 4 | import com.psisoyev.train.station.Context.RunsCtx 5 | import com.psisoyev.train.station.Event.Departed 6 | import com.psisoyev.train.station.departure.DepartureTracker 7 | import cr.pulsar.Consumer 8 | import fs2.Stream 9 | import tofu.generate.GenUUID 10 | 11 | object TrackerEngine { 12 | def start[ 13 | Init[_]: Concurrent: GenUUID, 14 | Run[_]: RunsCtx[*[_], Init] 15 | ]( 16 | consumers: List[Consumer[Init, Event]], 17 | departureTracker: DepartureTracker[Run] 18 | ): Init[Unit] = 19 | Stream 20 | .emits(consumers) 21 | .map(_.autoSubscribe) 22 | .parJoinUnbounded 23 | .collect { case e: Departed => e } 24 | .evalMap(e => Context.withSystemContext(departureTracker.save(e))) 25 | .compile 26 | .drain 27 | } 28 | -------------------------------------------------------------------------------- /service/src/main/scala/com/psisoyev/train/station/Tracing.scala: -------------------------------------------------------------------------------- 1 | package com.psisoyev.train.station 2 | 3 | import cats.FlatMap 4 | import com.psisoyev.train.station.Context._ 5 | import org.typelevel.log4cats.StructuredLogger 6 | import tofu.syntax.context.askF 7 | import tofu.syntax.monadic._ 8 | 9 | trait Tracing[F[_]] { 10 | def traced[A](opName: String)(fa: F[A]): F[A] 11 | } 12 | object Tracing { 13 | def make[F[_]: FlatMap: StructuredLogger: WithCtx]: Tracing[F] = new Tracing[F] { 14 | def traced[A](opName: String)(fa: F[A]): F[A] = 15 | askF[F] { ctx: Context => 16 | val context = Map("traceId" -> ctx.traceId.value, "operation" -> opName) 17 | F.trace(context)("") *> fa 18 | } 19 | } 20 | 21 | object ops { 22 | implicit class TracingOps[F[_], A](private val fa: F[A]) extends AnyVal { 23 | def traced(opName: String)(implicit F: Tracing[F]): F[A] = F.traced(opName)(fa) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /service/src/main/scala/com/psisoyev/train/station/arrival/ExpectedTrains.scala: -------------------------------------------------------------------------------- 1 | package com.psisoyev.train.station.arrival 2 | 3 | import cats.Functor 4 | import cats.effect.concurrent.Ref 5 | import cats.implicits._ 6 | import com.psisoyev.train.station.arrival.ExpectedTrains.ExpectedTrain 7 | import com.psisoyev.train.station.{ Expected, From, TrainId } 8 | 9 | trait ExpectedTrains[F[_]] { 10 | def get(id: TrainId): F[Option[ExpectedTrain]] 11 | def remove(id: TrainId): F[Unit] 12 | def update(id: TrainId, expectedTrain: ExpectedTrain): F[Unit] 13 | } 14 | 15 | object ExpectedTrains { 16 | case class ExpectedTrain(from: From, time: Expected) 17 | 18 | def make[F[_]: Functor]( 19 | ref: Ref[F, Map[TrainId, ExpectedTrain]] 20 | ): ExpectedTrains[F] = new ExpectedTrains[F] { 21 | override def get(id: TrainId): F[Option[ExpectedTrain]] = ref.get.map(_.get(id)) 22 | override def remove(id: TrainId): F[Unit] = ref.update(_.removed(id)) 23 | override def update(id: TrainId, train: ExpectedTrain): F[Unit] = ref.update(_.updated(id, train)) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /domain/src/main/scala/com/psisoyev/train/station/package.scala: -------------------------------------------------------------------------------- 1 | package com.psisoyev.train 2 | 3 | import derevo.cats.eqv 4 | import derevo.circe.{ decoder, encoder } 5 | import io.estatico.newtype.macros.newtype 6 | import derevo.derive 7 | 8 | import java.time.Instant 9 | import java.util.UUID 10 | 11 | package object station { 12 | @derive(decoder, encoder) 13 | @newtype case class Actual(value: Instant) { 14 | def toTimestamp: Timestamp = Timestamp(value) 15 | } 16 | 17 | @derive(decoder, encoder) 18 | @newtype case class Expected(value: Instant) 19 | 20 | @derive(decoder, encoder) 21 | @newtype case class Timestamp(value: Instant) 22 | 23 | @derive(decoder, encoder) 24 | @newtype case class EventId(value: String) 25 | object EventId { 26 | def apply(uuid: UUID): EventId = EventId(uuid.toString) 27 | } 28 | 29 | @derive(decoder, encoder) 30 | @newtype case class TrainId(value: String) 31 | 32 | @derive(decoder, encoder, eqv) 33 | @newtype case class City(value: String) 34 | 35 | @derive(decoder, encoder) 36 | @newtype case class From(city: City) 37 | 38 | @derive(decoder, encoder) 39 | @newtype case class To(city: City) 40 | } 41 | -------------------------------------------------------------------------------- /service/src/test/scala/com/psisoyev/train/station/departure/DepartureTrackerSpec.scala: -------------------------------------------------------------------------------- 1 | package com.psisoyev.train.station.departure 2 | 3 | import cats.effect.concurrent.Ref 4 | import cats.implicits._ 5 | import com.psisoyev.train.station.Generators._ 6 | import com.psisoyev.train.station.arrival.ExpectedTrains 7 | import com.psisoyev.train.station.arrival.ExpectedTrains.ExpectedTrain 8 | import com.psisoyev.train.station.{ BaseSpec, TrainId } 9 | import zio.interop.catz._ 10 | import zio.test.Assertion.equalTo 11 | import zio.test.environment.TestEnvironment 12 | import zio.test._ 13 | 14 | object DepartureTrackerSpec extends BaseSpec { 15 | override def spec: ZSpec[TestEnvironment, Failure] = 16 | suite("DepartureTrackerSpec")( 17 | testM("Expect trains departing to $city") { 18 | checkM(departedList, city) { (departed, city) => 19 | for { 20 | ref <- Ref.of[F, Map[TrainId, ExpectedTrain]](Map.empty) 21 | expectedTrains = ExpectedTrains.make[F](ref) 22 | tracker = DepartureTracker.make[F](city, expectedTrains) 23 | _ <- departed.traverse(tracker.save) 24 | result <- ref.get 25 | } yield assert(result.size)(equalTo(departed.count(_.to.city === city))) 26 | } 27 | } 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /service/src/test/scala/com/psisoyev/train/station/Generators.scala: -------------------------------------------------------------------------------- 1 | package com.psisoyev.train.station 2 | 3 | import com.psisoyev.train.station.Event.Departed 4 | import zio.random.Random 5 | import zio.test.Gen._ 6 | import zio.test.{ Gen, Sized } 7 | 8 | object Generators { 9 | val trainId: Gen[Random with Sized, TrainId] = alphaNumericString.map(TrainId(_)) 10 | val city: Gen[Random with Sized, City] = alphaNumericString.map(City(_)) 11 | val from: Gen[Random with Sized, From] = city.map(From(_)) 12 | val to: Gen[Random with Sized, To] = city.map(To(_)) 13 | val expected: Gen[Random, Expected] = anyInstant.map(Expected(_)) 14 | val actual: Gen[Random, Actual] = anyInstant.map(Actual(_)) 15 | val timestamp: Gen[Random, Timestamp] = anyInstant.map(Timestamp(_)) 16 | val eventId: Gen[Random, EventId] = anyUUID.map(EventId(_)) 17 | val departed: Gen[Random with Sized, Departed] = for { 18 | id <- eventId 19 | tId <- trainId 20 | f <- from 21 | t <- to 22 | e <- expected 23 | ts <- timestamp 24 | } yield Departed(id, tId, f, t, e, ts) 25 | val departedList: Gen[Random with Sized, List[Departed]] = listOf(departed) 26 | } 27 | -------------------------------------------------------------------------------- /server/src/main/scala/com/psisoyev/train/station/Routes.scala: -------------------------------------------------------------------------------- 1 | package com.psisoyev.train.station 2 | 3 | import cats.Monad 4 | import cats.effect.Sync 5 | import com.psisoyev.train.station.Context.RunsCtx 6 | import com.psisoyev.train.station.Main.Routes 7 | import com.psisoyev.train.station.arrival.ArrivalValidator.ArrivalError 8 | import com.psisoyev.train.station.arrival.{ ArrivalValidator, Arrivals, ExpectedTrains } 9 | import com.psisoyev.train.station.departure.Departures 10 | import com.psisoyev.train.station.departure.Departures.DepartureError 11 | import cr.pulsar.Producer 12 | import org.http4s.implicits._ 13 | import tofu.generate.GenUUID 14 | import tofu.logging.Logging 15 | 16 | object Routes { 17 | def make[ 18 | Init[_]: Sync, 19 | Run[_]: Monad: GenUUID: RunsCtx[*[_], Init]: Logging: Tracing: DepartureError.Raising: ArrivalError.Raising 20 | ]( 21 | config: Config, 22 | producer: Producer[Init, Event], 23 | expectedTrains: ExpectedTrains[Run] 24 | ): Routes[Init] = { 25 | val arrivalValidator = ArrivalValidator.make[Run](expectedTrains) 26 | val arrivals = Arrivals.make[Run](config.city, expectedTrains) 27 | val departures = Departures.make[Run](config.city, config.connectedTo) 28 | 29 | new StationRoutes[Init, Run](arrivals, arrivalValidator, producer, departures).routes.orNotFound 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | services: 3 | bern: 4 | image: "train-station:latest" 5 | restart: unless-stopped 6 | depends_on: 7 | - pulsar 8 | ports: 9 | - "8081:8081" 10 | environment: 11 | - "PULSAR_SERVICE_URL=pulsar://pulsar:6650" 12 | - "CITY=Bern" 13 | - "HTTP_PORT=8081" 14 | - "CONNECTED_TO=Zurich,Geneva" 15 | 16 | zurich: 17 | image: "train-station:latest" 18 | restart: unless-stopped 19 | depends_on: 20 | - pulsar 21 | ports: 22 | - "8082:8082" 23 | environment: 24 | - "PULSAR_SERVICE_URL=pulsar://pulsar:6650" 25 | - "CITY=Zurich" 26 | - "HTTP_PORT=8082" 27 | - "CONNECTED_TO=Bern" 28 | 29 | geneva: 30 | image: "train-station:latest" 31 | restart: unless-stopped 32 | depends_on: 33 | - pulsar 34 | ports: 35 | - "8083:8083" 36 | environment: 37 | - "PULSAR_SERVICE_URL=pulsar://pulsar:6650" 38 | - "CITY=Geneva" 39 | - "HTTP_PORT=8083" 40 | - "CONNECTED_TO=Bern" 41 | 42 | pulsar: 43 | image: kafkaesqueio/pulsar:2.6.1_kesque_3_jdk11 44 | ports: 45 | - "8080:8080" 46 | - "6650:6650" 47 | environment: 48 | PULSAR_MEM: " -Xms512m -Xmx512m -XX:MaxDirectMemorySize=1g" 49 | command: > 50 | /bin/bash -c 51 | "bin/apply-config-from-env.py conf/standalone.conf 52 | && bin/pulsar standalone" 53 | -------------------------------------------------------------------------------- /service/src/main/scala/com/psisoyev/train/station/Context.scala: -------------------------------------------------------------------------------- 1 | package com.psisoyev.train.station 2 | 3 | import cats.FlatMap 4 | import cats.implicits._ 5 | import com.psisoyev.train.station.Context.{ TraceId, UserId } 6 | import derevo.derive 7 | import io.estatico.newtype.Coercible 8 | import io.estatico.newtype.macros.newtype 9 | import tofu.generate.GenUUID 10 | import tofu.logging.Loggable 11 | import tofu.logging.derivation.loggable 12 | import tofu.syntax.context.runContext 13 | import tofu.{ WithContext, WithProvide } 14 | 15 | @derive(loggable) 16 | case class Context(traceId: TraceId, userId: UserId) 17 | 18 | object Context { 19 | type WithCtx[F[_]] = WithContext[F, Context] 20 | type RunsCtx[F[_], G[_]] = WithProvide[F, G, Context] 21 | 22 | @newtype case class TraceId(value: String) 23 | @newtype case class UserId(value: String) 24 | 25 | implicit def coercibleLoggable[A: Coercible[B, *], B: Loggable]: Loggable[A] = 26 | Loggable[B].contramap[A](_.asInstanceOf[B]) 27 | 28 | def withUserContext[ 29 | I[_]: GenUUID: FlatMap, 30 | F[_]: RunsCtx[*[_], I], 31 | T 32 | ](userId: UserId)(action: F[T]): I[T] = 33 | I.randomUUID.map(id => TraceId(id.toString)).flatMap { traceId => 34 | runContext(action)(Context(traceId, userId)) 35 | } 36 | 37 | def withSystemContext[ 38 | I[_]: GenUUID: FlatMap, 39 | F[_]: RunsCtx[*[_], I], 40 | T 41 | ](action: F[T]): I[T] = 42 | withUserContext[I, F, T](UserId("system"))(action) 43 | } 44 | -------------------------------------------------------------------------------- /service/src/main/scala/com/psisoyev/train/station/departure/DepartureTracker.scala: -------------------------------------------------------------------------------- 1 | package com.psisoyev.train.station.departure 2 | 3 | import cats.{ Applicative, FlatMap, Monad } 4 | import com.psisoyev.train.station.City 5 | import com.psisoyev.train.station.Event.Departed 6 | import com.psisoyev.train.station.arrival.ExpectedTrains 7 | import com.psisoyev.train.station.arrival.ExpectedTrains.ExpectedTrain 8 | import derevo.derive 9 | import derevo.tagless.applyK 10 | import tofu.higherKind.Mid 11 | import tofu.logging.Logging 12 | import tofu.syntax.monadic._ 13 | 14 | @derive(applyK) 15 | trait DepartureTracker[F[_]] { 16 | def save(e: Departed): F[Unit] 17 | } 18 | 19 | object DepartureTracker { 20 | 21 | private class Log[F[_]: FlatMap: Logging] extends DepartureTracker[Mid[F, *]] { 22 | def save(e: Departed): Mid[F, Unit] = 23 | _ *> F.info(s"${e.to.city} is expecting ${e.trainId} from ${e.from} at ${e.expected}") 24 | } 25 | 26 | private class Impl[F[_]: Applicative](city: City, expectedTrains: ExpectedTrains[F]) extends DepartureTracker[F] { 27 | def save(e: Departed): F[Unit] = 28 | expectedTrains 29 | .update(e.trainId, ExpectedTrain(e.from, e.expected)) 30 | .whenA(e.to.city == city) 31 | } 32 | 33 | def make[F[_]: Monad: Logging]( 34 | city: City, 35 | expectedTrains: ExpectedTrains[F] 36 | ): DepartureTracker[F] = { 37 | val service = new Impl[F](city, expectedTrains) 38 | 39 | (new Log[F]).attach(service) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /service/src/test/scala/com/psisoyev/train/station/departure/DeparturesSpec.scala: -------------------------------------------------------------------------------- 1 | package com.psisoyev.train.station.departure 2 | 3 | import com.psisoyev.train.station.Event.Departed 4 | import com.psisoyev.train.station.Generators._ 5 | import com.psisoyev.train.station.departure.Departures.{ Departure, DepartureError } 6 | import com.psisoyev.train.station.{ BaseSpec, From } 7 | import zio.interop.catz._ 8 | import zio.test.Assertion._ 9 | import zio.test._ 10 | import zio.test.environment.TestEnvironment 11 | 12 | object DeparturesSpec extends BaseSpec { 13 | override def spec: ZSpec[TestEnvironment, Failure] = 14 | suite("DeparturesSpec")( 15 | testM("Fail to register departing train to an unexpected destination city") { 16 | checkM(trainId, to, city, expected, actual) { (trainId, to, city, expected, actual) => 17 | Departures 18 | .make[F](city, List()) 19 | .register(Departure(trainId, to, expected, actual)) 20 | .flip 21 | .map(assert(_)(equalTo(DepartureError.UnexpectedDestination(to.city)))) 22 | } 23 | }, 24 | testM("Register departing train") { 25 | checkM(trainId, to, city, expected, actual) { (trainId, to, city, expected, actual) => 26 | Departures 27 | .make[F](city, List(to.city)) 28 | .register(Departure(trainId, to, expected, actual)) 29 | .map { result => 30 | val departed = Departed(eventId, trainId, From(city), to, expected, actual.toTimestamp) 31 | assert(result)(equalTo(departed)) 32 | } 33 | } 34 | } 35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /service/src/test/scala/com/psisoyev/train/station/arrival/ArrivalsSpec.scala: -------------------------------------------------------------------------------- 1 | package com.psisoyev.train.station.arrival 2 | 3 | import cats.effect.concurrent.Ref 4 | import com.psisoyev.train.station.Event.Arrived 5 | import com.psisoyev.train.station.Generators._ 6 | import com.psisoyev.train.station.arrival.ArrivalValidator.ValidatedArrival 7 | import com.psisoyev.train.station.arrival.ExpectedTrains.ExpectedTrain 8 | import com.psisoyev.train.station.{ BaseSpec, To, TrainId } 9 | import zio.interop.catz._ 10 | import zio.test.Assertion._ 11 | import zio.test._ 12 | import zio.test.environment.TestEnvironment 13 | 14 | object ArrivalsSpec extends BaseSpec { 15 | override def spec: ZSpec[TestEnvironment, Failure] = 16 | suite("ArrivalsSpec")( 17 | testM("Register expected train") { 18 | checkM(trainId, from, city, expected, actual) { (trainId, from, city, expected, actual) => 19 | val expectedTrain = ExpectedTrain(from, expected) 20 | val expectedTrains = Map(trainId -> expectedTrain) 21 | 22 | for { 23 | ref <- Ref.of[F, Map[TrainId, ExpectedTrain]](expectedTrains) 24 | expectedTrains = ExpectedTrains.make[F](ref) 25 | arrivals = Arrivals.make[F](city, expectedTrains) 26 | result <- arrivals.register(ValidatedArrival(trainId, actual, expectedTrain)) 27 | expectedTrainsMap <- ref.get 28 | } yield { 29 | val arrived = Arrived(eventId, trainId, from, To(city), expected, actual.toTimestamp) 30 | 31 | assert(result)(equalTo(arrived)) && 32 | assert(expectedTrainsMap.isEmpty)(isTrue) 33 | } 34 | } 35 | } 36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /project/Settings.scala: -------------------------------------------------------------------------------- 1 | import Dependencies._ 2 | import com.typesafe.sbt.packager.Keys._ 3 | import org.scalafmt.sbt.ScalafmtPlugin.autoImport.scalafmtOnCompile 4 | import sbt.Keys.{ scalacOptions, _ } 5 | import sbt._ 6 | 7 | object Settings { 8 | 9 | val commonSettings = 10 | Seq( 11 | scalaVersion := "2.13.12", 12 | scalacOptions := Seq( 13 | "-Ymacro-annotations", 14 | "-deprecation", 15 | "-encoding", 16 | "utf-8", 17 | "-explaintypes", 18 | "-feature", 19 | "-unchecked", 20 | "-language:postfixOps", 21 | "-language:higherKinds", 22 | "-language:implicitConversions", 23 | "-Xcheckinit", 24 | "-Xfatal-warnings" 25 | ), 26 | version := (version in ThisBuild).value, 27 | scalafmtOnCompile := true, 28 | testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework"), 29 | cancelable in Global := true, 30 | fork in Global := true, // https://github.com/sbt/sbt/issues/2274 31 | resolvers += "Sonatype OSS Snapshots" at "https://oss.sonatype.org/content/repositories/snapshots", 32 | mainClass in Compile := Some("com.psisoyev.train.station.Main"), 33 | addCompilerPlugin(contextApplied), 34 | addCompilerPlugin(kindProjector), 35 | addCompilerPlugin(betterMonadicFor), 36 | dockerBaseImage := "openjdk:jre-alpine", 37 | dockerUpdateLatest := true, 38 | javaOptions += "-Dlogback.configurationFile=/src/resources/logback.xml" 39 | ) 40 | 41 | val serviceDependencies = List(cats, catsEffect, neutronCore, zioCats) ++ zioTest ++ tofu 42 | val routeDependencies = http4s 43 | val serverDependencies = List(neutronCirce, ciris, logback, log4cats) ++ zio 44 | val domainDependencies = List(newtype) ++ circe ++ derevo 45 | } 46 | -------------------------------------------------------------------------------- /.github/workflows/scala.yml: -------------------------------------------------------------------------------- 1 | name: Scala CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | jobs: 7 | test: 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | include: 12 | - os: ubuntu-latest 13 | java: 11 14 | - os: windows-latest 15 | java: 11 16 | runs-on: ${{ matrix.os }} 17 | env: 18 | # define Java options for both official sbt and sbt-extras 19 | JAVA_OPTS: -Xms2048M -Xmx2048M -Xss6M -XX:ReservedCodeCacheSize=256M -Dfile.encoding=UTF-8 20 | JVM_OPTS: -Xms2048M -Xmx2048M -Xss6M -XX:ReservedCodeCacheSize=256M -Dfile.encoding=UTF-8 21 | steps: 22 | - name: Checkout 23 | uses: actions/checkout@v2 24 | - name: Setup 25 | uses: olafurpg/setup-scala@v10 26 | with: 27 | java-version: "adopt@1.${{ matrix.java }}" 28 | - name: Coursier cache 29 | uses: coursier/cache-action@v5 30 | - name: Cache sbt 31 | uses: actions/cache@v1 32 | with: 33 | path: $HOME/.sbt 34 | key: ${{ runner.os }}-sbt-cache-${{ hashFiles('**/*.sbt') }}-${{ hashFiles('project/build.properties') }} 35 | - name: Build and test 36 | run: | 37 | sbt -v "scalafmtCheckAll; +test;" 38 | rm -rf "$HOME/.ivy2/local" || true 39 | find $HOME/Library/Caches/Coursier/v1 -name "ivydata-*.properties" -delete || true 40 | find $HOME/.ivy2/cache -name "ivydata-*.properties" -delete || true 41 | find $HOME/.ivy2/cache -name "*-LM-SNAPSHOT*" -delete || true 42 | find $HOME/.cache/coursier/v1 -name "ivydata-*.properties" -delete || true 43 | find $HOME/.sbt -name "*.lock" -delete || true 44 | shell: bash -------------------------------------------------------------------------------- /service/src/test/scala/com/psisoyev/train/station/BaseSpec.scala: -------------------------------------------------------------------------------- 1 | package com.psisoyev.train.station 2 | 3 | import cats.Applicative 4 | import cats.effect.Sync 5 | import cats.effect.concurrent.Ref 6 | import cats.implicits._ 7 | import com.psisoyev.train.station.arrival.ExpectedTrains.ExpectedTrain 8 | import cr.pulsar.{ MessageKey, Producer } 9 | import zio.interop.catz._ 10 | import org.apache.pulsar.client.api.MessageId 11 | import tofu.generate.GenUUID 12 | import tofu.logging.Logging 13 | import zio.Task 14 | import zio.test.DefaultRunnableSpec 15 | 16 | import java.util.UUID 17 | 18 | trait BaseSpec extends DefaultRunnableSpec { 19 | type F[A] = Task[A] 20 | type ExpectedTrains = Map[TrainId, ExpectedTrain] 21 | 22 | def fakeProducer[F[_]: Sync]: F[(Ref[F, List[Event]], Producer[F, Event])] = 23 | Ref.of[F, List[Event]](List.empty).map { ref => 24 | ref -> new Producer[F, Event] { 25 | override def send(msg: Event): F[MessageId] = ref.update(_ :+ msg).as(MessageId.latest) 26 | override def send_(msg: Event): F[Unit] = send(msg).void 27 | override def send(msg: Event, key: MessageKey): F[MessageId] = ??? 28 | override def send_(msg: Event, key: MessageKey): F[Unit] = ??? 29 | } 30 | } 31 | 32 | val fakeUuid: UUID = UUID.randomUUID() 33 | val eventId: EventId = EventId(fakeUuid) 34 | implicit def fakeUuidGen[F[_]: Applicative]: GenUUID[F] = new GenUUID[F] { 35 | override def randomUUID: F[UUID] = F.pure(fakeUuid) 36 | } 37 | implicit def fakeTracing: Tracing[F] = new Tracing[F] { 38 | override def traced[A](opName: String)(fa: F[A]): F[A] = fa 39 | } 40 | implicit def fakeLogging: Logging[F] = Logging.empty[F] 41 | } 42 | -------------------------------------------------------------------------------- /service/src/test/scala/com/psisoyev/train/station/arrival/ArrivalValidatorSpec.scala: -------------------------------------------------------------------------------- 1 | package com.psisoyev.train.station.arrival 2 | 3 | import cats.effect.concurrent.Ref 4 | import com.psisoyev.train.station.Generators._ 5 | import com.psisoyev.train.station.arrival.ArrivalValidator.ArrivalError.UnexpectedTrain 6 | import com.psisoyev.train.station.arrival.ArrivalValidator.ValidatedArrival 7 | import com.psisoyev.train.station.arrival.Arrivals.Arrival 8 | import com.psisoyev.train.station.arrival.ExpectedTrains.ExpectedTrain 9 | import com.psisoyev.train.station.{ BaseSpec, TrainId } 10 | import zio.interop.catz._ 11 | import zio.test.Assertion._ 12 | import zio.test._ 13 | import zio.test.environment.TestEnvironment 14 | 15 | object ArrivalValidatorSpec extends BaseSpec { 16 | override def spec: ZSpec[TestEnvironment, Failure] = 17 | suite("ArrivalValidatorSpec")( 18 | testM("Validate arriving train") { 19 | checkM(trainId, from, expected, actual) { (trainId, from, expected, actual) => 20 | val expectedTrains = Map(trainId -> ExpectedTrain(from, expected)) 21 | 22 | for { 23 | ref <- Ref.of[F, Map[TrainId, ExpectedTrain]](expectedTrains) 24 | expectedTrains = ExpectedTrains.make[F](ref) 25 | validator = ArrivalValidator.make[F](expectedTrains) 26 | result <- validator.validate(Arrival(trainId, actual)) 27 | } yield { 28 | val validated = ValidatedArrival(trainId, actual, ExpectedTrain(from, expected)) 29 | assert(result)(equalTo(validated)) 30 | } 31 | } 32 | }, 33 | testM("Reject unexpected train") { 34 | checkM(trainId, actual) { (trainId, actual) => 35 | for { 36 | ref <- Ref.of[F, Map[TrainId, ExpectedTrain]](Map.empty) 37 | expectedTrains = ExpectedTrains.make[F](ref) 38 | validator = ArrivalValidator.make[F](expectedTrains) 39 | result <- validator.validate(Arrival(trainId, actual)).flip 40 | } yield assert(result)(equalTo(UnexpectedTrain(trainId))) 41 | } 42 | } 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Train Station 2 | 3 | ### Demo application based on Apache Pulsar 4 | The purpose of this application is to show how to build a simple event-driven application on top of Apache Pulsar. 5 | This application is written in functional Scala with Tagless Final style and ZIO Task is used as the main effect. 6 | Also, I've used [Tofu toolkit](https://github.com/tofu-tf/tofu) to improve Tagless Final code. 7 | You can find detailed description of the app in the blog post - https://scala.monster/train-station-tofu/ 8 | 9 | #### Testing 10 | In order to run unit tests: 11 | ```sbt 12 | sbt test 13 | ``` 14 | 15 | #### Running 16 | This repository contains a `docker-compose` file, which includes 4 services: 17 | * Apache Pulsar 18 | * Zurich train station 19 | * Bern train station 20 | * Geneva train station 21 | 22 | First, build a docker image of the service by running command: 23 | ```sbt 24 | sbt docker:publishLocal 25 | ``` 26 | 27 | When you have successfully built docker images you can start environment: 28 | ```sbt 29 | docker-compose up -d 30 | ``` 31 | This will start all the services in the background. All train stations will connect to Apache Pulsar. 32 | Services are starting much faster than Apache Pulsar so they will retry until it is ready. 33 | A train station service is ready when you see a similar log message: 34 | ``` 35 | [2020-09-30T19:10:52.064Z] Started train station Bern 36 | ``` 37 | 38 | #### Calling service endpoints 39 | To test if services are working correctly you can send a `Departure` request: 40 | ``` 41 | curl --request POST \ 42 | --url http://localhost:8082/departure \ 43 | --header 'content-type: application/json' \ 44 | --data '{ 45 | "id": "123", 46 | "to": "Bern", 47 | "time": "2020-12-03T10:15:30.00Z", 48 | "actual": "2020-12-03T10:15:30.00Z" 49 | }' 50 | ``` 51 | This will create a departing train from Zurich to Bern. 52 | In order to mark train as arrived send another HTTP request: 53 | ``` 54 | curl --request POST \ 55 | --url http://localhost:8081/arrival \ 56 | --header 'content-type: application/json' \ 57 | --data '{ 58 | "trainId": "123", 59 | "time": "2020-12-03T10:15:30.00Z" 60 | }' 61 | ``` 62 | -------------------------------------------------------------------------------- /server/src/main/scala/com/psisoyev/train/station/Resources.scala: -------------------------------------------------------------------------------- 1 | package com.psisoyev.train.station 2 | 3 | import cats.effect.{ Concurrent, ContextShift, Resource, Sync } 4 | import cats.implicits._ 5 | import cats.{ Inject, Parallel } 6 | import com.psisoyev.train.station.Context.WithCtx 7 | import com.psisoyev.train.station.EventLogger.EventFlow 8 | import cr.pulsar.{ Consumer, Producer, Pulsar, Subscription, Topic, Config => PulsarConfig } 9 | import org.typelevel.log4cats.StructuredLogger 10 | import io.circe.Encoder 11 | 12 | final case class Resources[I[_], F[_], E]( 13 | config: Config, 14 | producer: Producer[I, E], 15 | consumers: List[Consumer[I, E]] 16 | ) 17 | 18 | object Resources { 19 | def make[ 20 | I[_]: Concurrent: ContextShift: Parallel: StructuredLogger, 21 | F[_]: Sync: WithCtx, 22 | E: Inject[*, Array[Byte]]: Encoder 23 | ]: Resource[I, Resources[I, F, E]] = { 24 | def topic(config: PulsarConfig, city: City) = 25 | Topic 26 | .Builder 27 | .withName(Topic.Name(city.value.toLowerCase)) 28 | .withConfig(config) 29 | .withType(Topic.Type.Persistent) 30 | .build 31 | 32 | def consumer(client: Pulsar.T, config: Config, city: City): Resource[I, Consumer[I, E]] = { 33 | val name = s"${city.value}-${config.city.value}" 34 | val subscription = 35 | Subscription 36 | .Builder 37 | .withName(Subscription.Name(name)) 38 | .withType(Subscription.Type.Failover) 39 | .build 40 | 41 | Consumer.withLogger[I, E](client, topic(config.pulsar, city), subscription, EventLogger.logEvents(EventFlow.In)) 42 | } 43 | 44 | def producer(client: Pulsar.T, config: Config): Resource[I, Producer[I, E]] = 45 | Producer.withLogger[I, E](client, topic(config.pulsar, config.city), EventLogger.logEvents(EventFlow.Out)) 46 | 47 | for { 48 | config <- Resource.eval(Config.load[I]) 49 | client <- Pulsar.create[I](config.pulsar.url) 50 | producer <- producer(client, config) 51 | consumers <- config.connectedTo.traverse(consumer(client, config, _)) 52 | } yield Resources[I, F, E](config, producer, consumers) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /server/src/main/scala/com/psisoyev/train/station/Config.scala: -------------------------------------------------------------------------------- 1 | package com.psisoyev.train.station 2 | 3 | import cats.effect.{ Async, ContextShift } 4 | import cats.implicits._ 5 | import ciris._ 6 | import com.psisoyev.train.station.Config.HttpPort 7 | import cr.pulsar.Config.{ PulsarNamespace, PulsarTenant, PulsarURL } 8 | import cr.pulsar.{ Config => PulsarConfig } 9 | import io.estatico.newtype.Coercible 10 | import io.estatico.newtype.macros.newtype 11 | import io.estatico.newtype.ops._ 12 | 13 | case class Config( 14 | pulsar: PulsarConfig, 15 | httpPort: HttpPort, 16 | city: City, 17 | connectedTo: List[City] 18 | ) 19 | 20 | object Config { 21 | @newtype case class HttpPort(value: Int) 22 | 23 | implicit def coercibleDecoder[A: Coercible[B, *], B: ConfigDecoder[String, *]]: ConfigDecoder[String, A] = 24 | ConfigDecoder[String, B].map(_.coerce[A]) 25 | 26 | implicit def listDecoder[A: ConfigDecoder[String, *]]: ConfigDecoder[String, List[A]] = 27 | ConfigDecoder.lift(_.split(",").map(_.trim).toList.traverse(A.decode(None, _))) 28 | 29 | implicit class ConfigOps[A](cv: ConfigValue[A]) { 30 | // Same as `default` but it allows you to use the underlying type of the newtype 31 | def withDefault[T](value: T)(implicit ev: Coercible[T, A]): ConfigValue[A] = 32 | cv.default(value.coerce[A]) 33 | } 34 | 35 | def pulsarConfigValue: ConfigValue[PulsarConfig] = 36 | ( 37 | env("PULSAR_TENANT").as[PulsarTenant].withDefault("public"), 38 | env("PULSAR_NAMESPACE").as[PulsarNamespace].withDefault("default"), 39 | env("PULSAR_SERVICE_URL").as[PulsarURL].withDefault("pulsar://localhost:6650") 40 | ).mapN { case (tenant, namespace, url) => 41 | PulsarConfig 42 | .Builder 43 | .withTenant(tenant) 44 | .withNameSpace(namespace) 45 | .withURL(url) 46 | .build 47 | } 48 | 49 | private def value: ConfigValue[Config] = 50 | ( 51 | pulsarConfigValue, 52 | env("HTTP_PORT").as[HttpPort].withDefault(8080), 53 | env("CITY").as[City].default(City("Zurich")), 54 | env("CONNECTED_TO").as[List[City]].default(List(City("Bern"))) 55 | ).parMapN(Config.apply) 56 | 57 | def load[F[_]: Async: ContextShift]: F[Config] = value.load[F] 58 | } 59 | -------------------------------------------------------------------------------- /server/src/main/scala/com/psisoyev/train/station/Main.scala: -------------------------------------------------------------------------------- 1 | package com.psisoyev.train.station 2 | 3 | import cats.data.Kleisli 4 | import cats.effect.ConcurrentEffect 5 | import cats.effect.concurrent.Ref 6 | import com.psisoyev.train.station.arrival.ExpectedTrains 7 | import com.psisoyev.train.station.arrival.ExpectedTrains.ExpectedTrain 8 | import com.psisoyev.train.station.departure.DepartureTracker 9 | import cr.pulsar.schema.circe.circeBytesInject 10 | import org.typelevel.log4cats.StructuredLogger 11 | import org.http4s.{ Request, Response } 12 | import tofu.logging.Logging 13 | import tofu.logging.log4cats._ 14 | import tofu.logging.zlogs.ZLogs 15 | import tofu.zioInstances.implicits._ 16 | import zio.interop.catz._ 17 | import zio.interop.catz.implicits._ 18 | import zio.{ Ref => _, _ } 19 | 20 | object Main extends zio.App { 21 | type Init[T] = Task[T] 22 | type Run[T] = ZIO[Context, Throwable, T] 23 | type Routes[F[_]] = Kleisli[F, Request[F], Response[F]] 24 | 25 | override def run(args: List[String]): URIO[ZEnv, ExitCode] = 26 | Task.concurrentEffectWith { implicit CE => 27 | for { 28 | global <- ZLogs.withContext[Context].byName("global").map(_.widen[Run]) 29 | pulsar <- ZLogs.uio.byName("pulsar").map(_.widen[Init]) 30 | _ <- startApp(global, pulsar) 31 | } yield () 32 | }.exitCode 33 | 34 | def startApp( 35 | ctxLogger: Logging[Run], 36 | eventLogger: Logging[Init] 37 | )(implicit CE: ConcurrentEffect[Init]): Init[Unit] = { 38 | implicit val runLogging: Logging[Run] = ctxLogger 39 | implicit val initLogger: StructuredLogger[Init] = toLog4CatsLogger(eventLogger) 40 | implicit val tracing: Tracing[Run] = Tracing.make[Run] 41 | 42 | Resources 43 | .make[Init, Run, Event] 44 | .use { case Resources(config, producer, consumers) => 45 | for { 46 | trainRef <- Ref.in[Init, Run, Map[TrainId, ExpectedTrain]](Map.empty) 47 | 48 | expectedTrains = ExpectedTrains.make[Run](trainRef) 49 | tracker = DepartureTracker.make[Run](config.city, expectedTrains) 50 | routes = Routes.make[Init, Run](config, producer, expectedTrains) 51 | 52 | startHttpServer = HttpServer.start(config, routes) 53 | startDepartureTracker = TrackerEngine.start(consumers, tracker) 54 | 55 | _ <- startHttpServer.zipPar(startDepartureTracker) 56 | } yield () 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /route/src/main/scala/com/psisoyev/train/station/StationRoutes.scala: -------------------------------------------------------------------------------- 1 | package com.psisoyev.train.station 2 | 3 | import cats.implicits._ 4 | import cats.{ Defer, FlatMap, Monad } 5 | import com.psisoyev.train.station.Context.{ withUserContext, UserId } 6 | import com.psisoyev.train.station.arrival.ArrivalValidator.ArrivalError 7 | import com.psisoyev.train.station.arrival.Arrivals.Arrival 8 | import com.psisoyev.train.station.arrival.{ ArrivalValidator, Arrivals } 9 | import com.psisoyev.train.station.departure.Departures 10 | import com.psisoyev.train.station.departure.Departures.DepartureError 11 | import cr.pulsar.Producer 12 | import io.circe.Decoder 13 | import org.http4s.circe.CirceEntityEncoder._ 14 | import org.http4s.circe._ 15 | import org.http4s.dsl.Http4sDsl 16 | import org.http4s.{ HttpRoutes, _ } 17 | import tofu.generate.GenUUID 18 | import tofu.syntax.handle._ 19 | import Context.RunsCtx 20 | 21 | class StationRoutes[ 22 | I[_]: Monad: Defer: JsonDecoder: GenUUID: DepartureError.Handling: ArrivalError.Handling, 23 | F[_]: FlatMap: RunsCtx[*[_], I] 24 | ]( 25 | arrivals: Arrivals[F], 26 | arrivalValidator: ArrivalValidator[F], 27 | producer: Producer[I, Event], 28 | departures: Departures[F] 29 | ) extends Http4sDsl[I] { 30 | val routes: HttpRoutes[I] = HttpRoutes.of[I] { 31 | case req @ POST -> Root / "arrival" => 32 | val register = (a: Arrival) => arrivalValidator.validate(a).flatMap(arrivals.register) 33 | authorizedRegistration(req)(register).handleWith(handleArrivalErrors) 34 | case req @ POST -> Root / "departure" => 35 | authorizedRegistration(req)(departures.register) 36 | .handleWith(handleDepartureErrors) 37 | } 38 | 39 | def authorizedRegistration[T: Decoder, E <: Event](req: Request[I])(register: T => F[E]): I[Response[I]] = 40 | for { 41 | // For simplicity UserId is randomly generated. 42 | // Normally, it would be taken from request 43 | userId <- I.randomUUID.map(id => UserId(id.toString)) 44 | action <- req.asJsonDecode[T] 45 | event <- withUserContext(userId)(register(action)) 46 | res <- producer.send_(event) *> Ok() 47 | } yield res 48 | 49 | def handleArrivalErrors: ArrivalError => I[Response[I]] = { case ArrivalError.UnexpectedTrain(id) => 50 | BadRequest(s"Unexpected train $id") 51 | } 52 | 53 | def handleDepartureErrors: DepartureError => I[Response[I]] = { case DepartureError.UnexpectedDestination(city) => 54 | BadRequest(s"Unexpected city $city") 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /service/src/main/scala/com/psisoyev/train/station/arrival/Arrivals.scala: -------------------------------------------------------------------------------- 1 | package com.psisoyev.train.station.arrival 2 | 3 | import cats.{ FlatMap, Functor, Monad } 4 | import com.psisoyev.train.station.Event.Arrived 5 | import com.psisoyev.train.station.Tracing.ops.TracingOps 6 | import com.psisoyev.train.station.arrival.ArrivalValidator.ValidatedArrival 7 | import com.psisoyev.train.station.{ Actual, City, EventId, To, Tracing, TrainId } 8 | import derevo.derive 9 | import derevo.tagless.applyK 10 | import io.circe.Decoder 11 | import io.circe.generic.semiauto._ 12 | import tofu.generate.GenUUID 13 | import tofu.higherKind.Mid 14 | import tofu.logging.Logging 15 | import tofu.syntax.monadic._ 16 | import tofu.syntax.monoid.TofuSemigroupOps 17 | 18 | @derive(applyK) 19 | trait Arrivals[F[_]] { 20 | def register(arrival: ValidatedArrival): F[Arrived] 21 | } 22 | 23 | object Arrivals { 24 | case class Arrival(trainId: TrainId, time: Actual) 25 | object Arrival { 26 | implicit val arrivalDecoder: Decoder[Arrival] = deriveDecoder 27 | } 28 | 29 | private class Log[F[_]: FlatMap: Logging] extends Arrivals[Mid[F, *]] { 30 | def register(arrival: ValidatedArrival): Mid[F, Arrived] = { registration => 31 | val before = F.info(s"Registering $arrival") 32 | val after = F.info(s"Train ${arrival.trainId} successfully arrived") 33 | 34 | before *> registration <* after 35 | } 36 | } 37 | 38 | private class Trace[F[_]: Tracing] extends Arrivals[Mid[F, *]] { 39 | def register(arrival: ValidatedArrival): Mid[F, Arrived] = _.traced("train arrival: register") 40 | } 41 | 42 | private class Clean[F[_]: Monad](expectedTrains: ExpectedTrains[F]) extends Arrivals[Mid[F, *]] { 43 | def register(arrival: ValidatedArrival): Mid[F, Arrived] = 44 | _.flatTap(_ => expectedTrains.remove(arrival.trainId)) 45 | } 46 | 47 | private class Impl[F[_]: Functor: GenUUID](city: City) extends Arrivals[F] { 48 | override def register(arrival: ValidatedArrival): F[Arrived] = 49 | F.randomUUID.map { id => 50 | Arrived( 51 | EventId(id), 52 | arrival.trainId, 53 | arrival.expectedTrain.from, 54 | To(city), 55 | arrival.expectedTrain.time, 56 | arrival.time.toTimestamp 57 | ) 58 | } 59 | } 60 | 61 | def make[F[_]: Monad: GenUUID: Logging: Tracing]( 62 | city: City, 63 | expectedTrains: ExpectedTrains[F] 64 | ): Arrivals[F] = { 65 | val service = new Impl[F](city) 66 | 67 | val log: Arrivals[Mid[F, *]] = new Log[F] 68 | val trace: Arrivals[Mid[F, *]] = new Trace[F] 69 | val clean: Arrivals[Mid[F, *]] = new Clean[F](expectedTrains) 70 | 71 | (log |+| trace |+| clean).attach(service) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /service/src/main/scala/com/psisoyev/train/station/arrival/ArrivalValidator.scala: -------------------------------------------------------------------------------- 1 | package com.psisoyev.train.station.arrival 2 | 3 | import cats.{ FlatMap, Monad } 4 | import com.psisoyev.train.station.Tracing.ops.TracingOps 5 | import com.psisoyev.train.station.arrival.ArrivalValidator.ArrivalError.UnexpectedTrain 6 | import com.psisoyev.train.station.arrival.ArrivalValidator.ValidatedArrival 7 | import com.psisoyev.train.station.arrival.Arrivals.Arrival 8 | import com.psisoyev.train.station.arrival.ExpectedTrains.ExpectedTrain 9 | import com.psisoyev.train.station.{ Actual, Tracing, TrainId } 10 | import derevo.derive 11 | import derevo.tagless.applyK 12 | import tofu.higherKind.Mid 13 | import tofu.logging.Logging 14 | import tofu.syntax.monadic._ 15 | import tofu.syntax.monoid.TofuSemigroupOps 16 | import tofu.syntax.raise._ 17 | import tofu.{ Handle, Raise } 18 | 19 | import scala.util.control.NoStackTrace 20 | 21 | @derive(applyK) 22 | trait ArrivalValidator[F[_]] { 23 | def validate(arrival: Arrival): F[ValidatedArrival] 24 | } 25 | 26 | object ArrivalValidator { 27 | sealed trait ArrivalError extends NoStackTrace 28 | object ArrivalError { 29 | type Handling[F[_]] = Handle[F, ArrivalError] 30 | type Raising[F[_]] = Raise[F, ArrivalError] 31 | 32 | case class UnexpectedTrain(id: TrainId) extends ArrivalError 33 | } 34 | 35 | case class ValidatedArrival(trainId: TrainId, time: Actual, expectedTrain: ExpectedTrain) 36 | 37 | private class Log[F[_]: FlatMap: Logging] extends ArrivalValidator[Mid[F, *]] { 38 | def validate(arrival: Arrival): Mid[F, ValidatedArrival] = { validation => 39 | F.info(s"Validating $arrival") *> validation <* F.info(s"Train ${arrival.trainId} validated") 40 | } 41 | } 42 | 43 | private class Trace[F[_]: Tracing] extends ArrivalValidator[Mid[F, *]] { 44 | def validate(arrival: Arrival): Mid[F, ValidatedArrival] = _.traced("train arrival: validation") 45 | } 46 | 47 | private class Impl[F[_]: Monad: ArrivalError.Raising](expectedTrains: ExpectedTrains[F]) extends ArrivalValidator[F] { 48 | override def validate(arrival: Arrival): F[ValidatedArrival] = 49 | expectedTrains 50 | .get(arrival.trainId) 51 | .flatMap { train => 52 | train 53 | .map(ValidatedArrival(arrival.trainId, arrival.time, _)) 54 | .orRaise(UnexpectedTrain(arrival.trainId)) 55 | } 56 | } 57 | 58 | def make[F[_]: Monad: Logging: ArrivalError.Raising: Tracing]( 59 | expectedTrains: ExpectedTrains[F] 60 | ): ArrivalValidator[F] = { 61 | val service = new Impl[F](expectedTrains) 62 | 63 | val log: ArrivalValidator[Mid[F, *]] = new Log[F] 64 | val trace: ArrivalValidator[Mid[F, *]] = new Trace[F] 65 | 66 | (log |+| trace).attach(service) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /project/Dependencies.scala: -------------------------------------------------------------------------------- 1 | import sbt._ 2 | 3 | object Dependencies { 4 | val zioCore = "dev.zio" %% "zio" % Version.zio 5 | val zioCats = ("dev.zio" %% "zio-interop-cats" % Version.zioCats).excludeAll(ExclusionRule("dev.zio")) 6 | val zio = List(zioCore, zioCats) 7 | 8 | val cats = "org.typelevel" %% "cats-core" % Version.cats 9 | val catsEffect = "org.typelevel" %% "cats-effect" % Version.catsEffect 10 | 11 | val zioTest = List( 12 | "dev.zio" %% "zio-test", 13 | "dev.zio" %% "zio-test-sbt" 14 | ).map(_ % Version.zio % Test) 15 | 16 | val tofu = List( 17 | "tf.tofu" %% "tofu", 18 | "tf.tofu" %% "tofu-logging", 19 | "tf.tofu" %% "tofu-logging-log4cats", 20 | "tf.tofu" %% "tofu-logging-layout", 21 | "tf.tofu" %% "tofu-zio-core", 22 | "tf.tofu" %% "tofu-zio-logging" 23 | ).map(_ % Version.tofu) 24 | 25 | val derevo = List( 26 | "tf.tofu" %% "derevo-cats", 27 | "tf.tofu" %% "derevo-circe" 28 | ).map(_ % Version.derevo) 29 | 30 | val http4s = List( 31 | "org.http4s" %% "http4s-dsl", 32 | "org.http4s" %% "http4s-circe", 33 | "org.http4s" %% "http4s-blaze-server" 34 | ).map(_ % Version.http4s) 35 | 36 | val fs2Core = "co.fs2" %% "fs2-core" % Version.fs2Core 37 | 38 | val newtype = "io.estatico" %% "newtype" % Version.newtype 39 | 40 | val circe = List( 41 | "io.circe" %% "circe-generic", 42 | "io.circe" %% "circe-core", 43 | "io.circe" %% "circe-parser" 44 | ).map(_ % Version.circe) 45 | 46 | val neutronCore = "com.chatroulette" %% "neutron-core" % Version.neutron 47 | val neutronCirce = "com.chatroulette" %% "neutron-circe" % Version.neutron 48 | 49 | val ciris = "is.cir" %% "ciris" % Version.ciris 50 | 51 | val contextApplied = "org.augustjune" %% "context-applied" % Version.contextApplied 52 | val kindProjector = "org.typelevel" %% "kind-projector" % Version.kindProjector cross CrossVersion.full 53 | val betterMonadicFor = "com.olegpy" %% "better-monadic-for" % Version.betterMonadicFor 54 | 55 | val logback = "ch.qos.logback" % "logback-classic" % Version.logback 56 | val log4cats = "org.typelevel" %% "log4cats-core" % Version.log4cats 57 | } 58 | 59 | object Version { 60 | val cats = "2.9.0" 61 | val catsEffect = "2.5.5" 62 | val zioCats = "2.5.1.0" 63 | val zio = "1.0.18" 64 | val fs2Core = "2.4.2" 65 | val kindProjector = "0.13.2" 66 | val ciris = "1.2.1" 67 | val http4s = "1.0-234-d1a2b53" 68 | val circe = "0.14.2" 69 | val newtype = "0.4.4" 70 | val neutron = "0.0.4" 71 | val contextApplied = "0.1.4" 72 | val tofu = "0.10.2" 73 | val derevo = "0.13.0" 74 | val betterMonadicFor = "0.3.1" 75 | val logback = "1.5.3" 76 | val log4cats = "1.7.0" 77 | } 78 | -------------------------------------------------------------------------------- /service/src/main/scala/com/psisoyev/train/station/departure/Departures.scala: -------------------------------------------------------------------------------- 1 | package com.psisoyev.train.station.departure 2 | 3 | import cats.{ Apply, FlatMap, Functor, Monad } 4 | import com.psisoyev.train.station.Event.Departed 5 | import com.psisoyev.train.station.Tracing.ops.TracingOps 6 | import com.psisoyev.train.station._ 7 | import com.psisoyev.train.station.departure.Departures.Departure 8 | import com.psisoyev.train.station.departure.Departures.DepartureError.UnexpectedDestination 9 | import derevo.derive 10 | import derevo.tagless.applyK 11 | import io.circe.Decoder 12 | import io.circe.generic.semiauto.deriveDecoder 13 | import tofu.generate.GenUUID 14 | import tofu.higherKind.Mid 15 | import tofu.logging.Logging 16 | import tofu.syntax.monadic._ 17 | import tofu.syntax.monoid.TofuSemigroupOps 18 | import tofu.syntax.raise._ 19 | import tofu.{ Handle, Raise } 20 | 21 | import scala.util.control.NoStackTrace 22 | 23 | @derive(applyK) 24 | trait Departures[F[_]] { 25 | def register(departure: Departure): F[Departed] 26 | } 27 | 28 | object Departures { 29 | sealed trait DepartureError extends NoStackTrace 30 | object DepartureError { 31 | type Handling[F[_]] = Handle[F, DepartureError] 32 | type Raising[F[_]] = Raise[F, DepartureError] 33 | 34 | case class UnexpectedDestination(city: City) extends DepartureError 35 | } 36 | 37 | case class Departure(id: TrainId, to: To, time: Expected, actual: Actual) 38 | object Departure { 39 | implicit val departureDecoder: Decoder[Departure] = deriveDecoder 40 | } 41 | 42 | private class Log[F[_]: Apply: Logging] extends Departures[Mid[F, *]] { 43 | def register(departure: Departure): Mid[F, Departed] = { registration => 44 | val before = F.info(s"Registering $departure") 45 | val after = F.info(s"Train ${departure.id.value} successfully departed") 46 | 47 | before *> registration <* after 48 | } 49 | } 50 | 51 | private class Trace[F[_]: Tracing] extends Departures[Mid[F, *]] { 52 | def register(departure: Departure): Mid[F, Departed] = _.traced("train departure: register") 53 | } 54 | 55 | private class Validate[F[_]: Monad: DepartureError.Raising](connectedTo: List[City]) extends Departures[Mid[F, *]] { 56 | def register(departure: Departure): Mid[F, Departed] = { registration => 57 | val destination = departure.to.city 58 | 59 | connectedTo 60 | .find(_ == destination) 61 | .orRaise(UnexpectedDestination(destination)) *> registration 62 | } 63 | } 64 | 65 | private class Impl[F[_]: Functor: GenUUID](city: City) extends Departures[F] { 66 | override def register(departure: Departure): F[Departed] = 67 | F.randomUUID.map { id => 68 | Departed( 69 | EventId(id), 70 | departure.id, 71 | From(city), 72 | departure.to, 73 | departure.time, 74 | departure.actual.toTimestamp 75 | ) 76 | } 77 | } 78 | 79 | def make[F[_]: Monad: GenUUID: Logging: DepartureError.Raising: Tracing]( 80 | city: City, 81 | connectedTo: List[City] 82 | ): Departures[F] = { 83 | val service = new Impl[F](city) 84 | 85 | val trace: Departures[Mid[F, *]] = new Trace[F] 86 | val log: Departures[Mid[F, *]] = new Log[F] 87 | val validate: Departures[Mid[F, *]] = new Validate[F](connectedTo) 88 | 89 | (log |+| validate |+| trace).attach(service) 90 | } 91 | } 92 | --------------------------------------------------------------------------------