Skip to content

Commit

Permalink
Use typesafe domain (#125)
Browse files Browse the repository at this point in the history
  • Loading branch information
lenguyenthanh authored Jun 9, 2024
2 parents 9f87e45 + 9ca039d commit d90cdf0
Show file tree
Hide file tree
Showing 14 changed files with 215 additions and 117 deletions.
2 changes: 1 addition & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ lazy val domain = (project in file("modules/domain"))
.settings(
name := "domain",
commonSettings
)
).dependsOn(types)

lazy val db = (project in file("modules/db"))
.settings(
Expand Down
28 changes: 18 additions & 10 deletions modules/api/src/main/scala/providers.scala
Original file line number Diff line number Diff line change
@@ -1,25 +1,33 @@
package fide.spec

import cats.syntax.all.*
import fide.spec.*
import fide.types.*
import fide.types.{ NonEmptySet, PageNumber, PageSize }
import smithy4s.*

object providers:
given RefinementProvider[PageFormat, String, Natural] =
Refinement.drivenBy(Natural.fromString, _.toString)
given RefinementProvider[PageFormat, String, PageNumber] =
Refinement.drivenBy(PageNumber.fromString, _.toString)

given RefinementProvider.Simple[smithy.api.Range, Natural] =
RefinementProvider.rangeConstraint(x => x: Int)
given RefinementProvider.Simple[smithy.api.Range, fide.types.PageSize] =
RefinementProvider.rangeConstraint(_.toInt)

given RefinementProvider[PageSizeFormat, Int, Natural] =
Refinement.drivenBy(Natural.either, identity)
given RefinementProvider[PageSizeFormat, Int, fide.types.PageSize] =
Refinement.drivenBy(PageSize.either, _.toInt)

given RefinementProvider[PlayerIdFormat, Int, fide.types.PlayerId] =
Refinement.drivenBy(fide.types.PlayerId.either, _.value)

given RefinementProvider[RatingFormat, Int, fide.types.Rating] =
Refinement.drivenBy(fide.types.Rating.either, _.value)

given RefinementProvider[FederationIdFormat, String, fide.types.FederationId] =
Refinement.drivenBy(fide.types.FederationId.either, _.value)

given [A]: RefinementProvider[NonEmptySetFormat, Set[A], NonEmptySet[A]] =
Refinement.drivenBy[NonEmptySetFormat](
NonEmptySet.either,
(b: NonEmptySet[A]) => b.value
)

given RefinementProvider.Simple[smithy.api.Length, fide.types.NonEmptySet[fide.spec.PlayerId]] =
RefinementProvider.lengthConstraint(x => x.value.size)
given [A]: RefinementProvider.Simple[smithy.api.Length, fide.types.NonEmptySet[A]] =
RefinementProvider.lengthConstraint(_.value.size)
33 changes: 30 additions & 3 deletions modules/api/src/main/smithy/_global.smithy
Original file line number Diff line number Diff line change
Expand Up @@ -26,22 +26,49 @@ structure InternalServerError {
message: String
}

@trait(selector: "integer")
@refinement(
targetType: "fide.types.PlayerId"
providerImport: "fide.spec.providers.given"
)
structure PlayerIdFormat {}

@PlayerIdFormat
@unwrap
integer PlayerId

@trait(selector: "string")
@refinement(
targetType: "fide.types.FederationId"
providerImport: "fide.spec.providers.given"
)
structure FederationIdFormat {}

@unwrap
@FederationIdFormat
string FederationId

@range(min: 0, max: 4000)
@trait(selector: "integer")
@refinement(
targetType: "fide.types.Rating"
providerImport: "fide.spec.providers.given"
)
structure RatingFormat {}

@RatingFormat
@unwrap
integer Rating

@trait(selector: "string")
@refinement(
targetType: "fide.types.Natural"
targetType: "fide.types.PageNumber"
providerImport: "fide.spec.providers.given"
)
structure PageFormat {}

@trait(selector: "integer")
@refinement(
targetType: "fide.types.Natural"
targetType: "fide.types.PageSize"
providerImport: "fide.spec.providers.given"
)
structure PageSizeFormat { }
Expand Down
27 changes: 13 additions & 14 deletions modules/backend/src/main/scala/service.federation.scala
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import cats.syntax.all.*
import fide.db.Db
import fide.domain.Models.Pagination
import fide.domain.{ FederationSummary, Models }
import fide.spec.*
import fide.types.Natural
import fide.spec.{ FederationId as _, PageNumber as _, PageSize as _, PlayerId as _, Rating as _, * }
import fide.types.*
import io.github.arainko.ducktape.*
import org.typelevel.log4cats.Logger
import org.typelevel.log4cats.syntax.*
Expand All @@ -18,8 +18,8 @@ class FederationServiceImpl(db: Db)(using Logger[IO]) extends FederationService[

override def getFederationPlayersById(
id: FederationId,
page: Natural,
pageSize: Natural,
page: PageNumber,
pageSize: PageSize,
sortBy: Option[SortBy],
order: Option[Order],
isActive: Option[Boolean],
Expand All @@ -31,14 +31,14 @@ class FederationServiceImpl(db: Db)(using Logger[IO]) extends FederationService[
blitzMax: Option[Rating],
name: Option[String]
): IO[GetFederationPlayersByIdOutput] =
val paging = Models.Pagination.fromPageAndSize(page, pageSize)
val paging = Models.Pagination(page, pageSize)
val sorting = Models.Sorting.fromOption(sortBy.map(_.to[Models.SortBy]), order.map(_.to[Models.Order]))
val filter = Models.PlayerFilter(
isActive,
Models.RatingRange(standardMin.map(_.value), standardMax.map(_.value)),
Models.RatingRange(rapidMin.map(_.value), rapidMax.map(_.value)),
Models.RatingRange(blitzMin.map(_.value), blitzMax.map(_.value)),
id.value.some
Models.RatingRange(standardMin, standardMax),
Models.RatingRange(rapidMin, rapidMax),
Models.RatingRange(blitzMin, blitzMax),
id.some
)
name
.fold(db.allPlayers(sorting, paging, filter))(db.playersByName(_, sorting, paging, filter))
Expand All @@ -53,18 +53,18 @@ class FederationServiceImpl(db: Db)(using Logger[IO]) extends FederationService[
)

override def getFederationSummaryById(id: FederationId): IO[GetFederationSummaryByIdOutput] =
db.federationSummaryById(id.value)
db.federationSummaryById(id)
.handleErrorWith: e =>
error"Error in getFederationSummaryById: $id, $e" *>
IO.raiseError(InternalServerError("Internal server error"))
.flatMap:
_.fold(IO.raiseError(FederationNotFound(id)))(_.transform.pure)

override def getFederationsSummary(
page: Natural,
pageSize: Natural
page: PageNumber,
pageSize: PageSize
): IO[GetFederationsSummaryOutput] =
db.allFederationsSummary(Pagination.fromPageAndSize(page, pageSize))
db.allFederationsSummary(Pagination(page, pageSize))
.handleErrorWith: e =>
error"Error in getFederationsSummary: $e" *>
IO.raiseError(InternalServerError("Internal server error"))
Expand All @@ -76,7 +76,6 @@ class FederationServiceImpl(db: Db)(using Logger[IO]) extends FederationService[
)

object FederationTransformers:
given Transformer.Derived[String, FederationId] = Transformer.Derived.FromFunction(FederationId.apply)
extension (p: FederationSummary)
def transform: GetFederationSummaryByIdOutput =
p.to[GetFederationSummaryByIdOutput]
21 changes: 9 additions & 12 deletions modules/backend/src/main/scala/service.player.scala
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import cats.effect.*
import cats.syntax.all.*
import fide.db.Db
import fide.domain.Models
import fide.spec.*
import fide.spec.{ FederationId as _, PageNumber as _, PageSize as _, PlayerId as _, Rating as _, * }
import fide.types.*
import io.github.arainko.ducktape.*
import org.typelevel.log4cats.Logger
Expand All @@ -18,8 +18,8 @@ class PlayerServiceImpl(db: Db)(using Logger[IO]) extends PlayerService[IO]:
import PlayerTransformers.*

override def getPlayers(
page: Natural,
pageSize: Natural,
page: PageNumber,
pageSize: PageSize,
sortBy: Option[SortBy],
order: Option[Order],
isActive: Option[Boolean],
Expand All @@ -31,13 +31,13 @@ class PlayerServiceImpl(db: Db)(using Logger[IO]) extends PlayerService[IO]:
blitzMax: Option[Rating],
name: Option[String]
): IO[GetPlayersOutput] =
val paging = Models.Pagination.fromPageAndSize(page, pageSize)
val paging = Models.Pagination(page, pageSize)
val sorting = Models.Sorting.fromOption(sortBy.map(_.to[Models.SortBy]), order.map(_.to[Models.Order]))
val filter = Models.PlayerFilter(
isActive,
Models.RatingRange(standardMin.map(_.value), standardMax.map(_.value)),
Models.RatingRange(rapidMin.map(_.value), rapidMax.map(_.value)),
Models.RatingRange(blitzMin.map(_.value), blitzMax.map(_.value)),
Models.RatingRange(standardMin, standardMax),
Models.RatingRange(rapidMin, rapidMax),
Models.RatingRange(blitzMin, blitzMax),
None
)
name
Expand All @@ -53,7 +53,7 @@ class PlayerServiceImpl(db: Db)(using Logger[IO]) extends PlayerService[IO]:
)

override def getPlayerById(id: PlayerId): IO[GetPlayerByIdOutput] =
db.playerById(id.value)
db.playerById(id)
.handleErrorWith: e =>
error"Error in getPlayerById: $id, $e" *>
IO.raiseError(InternalServerError("Internal server error"))
Expand All @@ -62,17 +62,14 @@ class PlayerServiceImpl(db: Db)(using Logger[IO]) extends PlayerService[IO]:
_.transform.pure[IO]

override def getPlayerByIds(ids: NonEmptySet[PlayerId]): IO[GetPlayerByIdsOutput] =
db.playersByIds(ids.value.map(_.value))
db.playersByIds(ids.value)
.handleErrorWith: e =>
error"Error in getPlayersByIds: $ids, $e" *>
IO.raiseError(InternalServerError("Internal server error"))
.map(_.map(p => p.id.toString -> p.transform).toMap)
.map(GetPlayerByIdsOutput.apply)

object PlayerTransformers:
given Transformer.Derived[Int, Rating] = Transformer.Derived.FromFunction(Rating.apply)
given Transformer.Derived[String, FederationId] = Transformer.Derived.FromFunction(FederationId.apply)
given Transformer.Derived[Int, PlayerId] = Transformer.Derived.FromFunction(PlayerId.apply)
given Transformer.Derived[OffsetDateTime, Timestamp] =
Transformer.Derived.FromFunction(Timestamp.fromOffsetDateTime)

Expand Down
18 changes: 11 additions & 7 deletions modules/crawler/src/main/scala/Crawler.scala
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import cats.effect.IO
import cats.syntax.all.*
import fide.db.{ Db, KVStore }
import fide.domain.*
import fide.types.*
import org.http4s.*
import org.http4s.client.Client
import org.http4s.implicits.*
Expand Down Expand Up @@ -69,10 +70,13 @@ object Downloader:
.handleErrorWith(e => error"Error while parsing line: $line, error: $e".as(none))

def parse(line: String): Option[(NewPlayer, Option[NewFederation])] =
def string(start: Int, end: Int) = line.substring(start, end).trim.some.filter(_.nonEmpty)
def number(start: Int, end: Int) = string(start, end).flatMap(_.toIntOption)
def string(start: Int, end: Int): Option[String] = line.substring(start, end).trim.some.filter(_.nonEmpty)

def number(start: Int, end: Int): Option[Int] = string(start, end).flatMap(_.toIntOption)
def rating(start: Int, end: Int): Option[Rating] = string(start, end) >>= Rating.fromString

for
id <- number(0, 15)
id <- number(0, 15) >>= PlayerId.option
name <- string(15, 76).map(_.filterNot(_.isDigit).trim)
if name.sizeIs > 2
title = string(84, 89) >>= Title.apply
Expand All @@ -81,16 +85,16 @@ object Downloader:
sex = string(79, 82) >>= Sex.apply
year = number(152, 156).filter(_ > 1000)
inactiveFlag = string(158, 160)
federationId = string(76, 79).map(FederationId.apply)
federationId = string(76, 79) >>= FederationId.option
yield NewPlayer(
id = id,
name = name,
title = title,
womenTitle = wTitle,
otherTitles = otherTitles,
standard = number(113, 117),
rapid = number(126, 132),
blitz = number(139, 145),
standard = rating(113, 117),
rapid = rating(126, 132),
blitz = rating(139, 145),
sex = sex,
birthYear = year,
active = inactiveFlag.isEmpty
Expand Down
51 changes: 39 additions & 12 deletions modules/db/src/main/scala/Db.scala
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import cats.effect.*
import cats.syntax.all.*
import fide.domain.*
import fide.domain.Models.*
import fide.types.*
import org.typelevel.log4cats.Logger
import skunk.*

Expand Down Expand Up @@ -98,8 +99,34 @@ private object Codecs:
import skunk.codec.all.*
import skunk.data.{ Arr, Type }

val title: Codec[Title] = `enum`[Title](_.value, Title.apply, Type("title"))
val sex: Codec[Sex] = `enum`[Sex](_.value, Sex.apply, Type("sex"))
// copy from https://github.com/Iltotore/iron/blob/main/skunk/src/io.github.iltotore.iron/skunk.scala for skunk 1.0.0
import io.github.iltotore.iron.*
import io.github.iltotore.iron.constraint.all.*

/** Explicit conversion for refining a [[Codec]]. Decodes to the underlying type then checks the constraint.
*
* @param constraint
* the [[Constraint]] implementation to test the decoded value
*/
extension [A](codec: Codec[A])
inline def refined[C](using inline constraint: Constraint[A, C]): Codec[A :| C] =
codec.eimap[A :| C](_.refineEither[C])(_.asInstanceOf[A])

/** A [[Codec]] for refined types. Decodes to the underlying type then checks the constraint.
*
* @param codec
* the [[Codec]] of the underlying type
* @param constraint
* the [[Constraint]] implementation to test the decoded value
*/
inline given [A, C](using inline codec: Codec[A], inline constraint: Constraint[A, C]): Codec[A :| C] =
codec.refined

val title: Codec[Title] = `enum`[Title](_.value, Title.apply, Type("title"))
val sex: Codec[Sex] = `enum`[Sex](_.value, Sex.apply, Type("sex"))
val ratingCodec: Codec[Rating] = int4.refined[RatingConstraint].imap(Rating.apply)(_.value)
val federationIdCodec: Codec[FederationId] = text.refined[NonEmpty].imap(FederationId.apply)(_.value)
val playerIdCodec: Codec[PlayerId] = int4.refined[Positive].imap(PlayerId.apply)(_.value)

val otherTitleArr: Codec[Arr[OtherTitle]] =
Codec.array(
Expand All @@ -111,23 +138,23 @@ private object Codecs:
val otherTitles: Codec[List[OtherTitle]] = otherTitleArr.opt.imap(_.fold(Nil)(_.toList))(Arr(_*).some)

val insertPlayer: Codec[InsertPlayer] =
(int4 *: text *: title.opt *: title.opt *: otherTitles *: int4.opt *: int4.opt *: int4.opt *: sex.opt *: int4.opt *: bool *: text.opt)
(playerIdCodec *: text *: title.opt *: title.opt *: otherTitles *: ratingCodec.opt *: ratingCodec.opt *: ratingCodec.opt *: sex.opt *: int4.opt *: bool *: federationIdCodec.opt)
.to[InsertPlayer]

val newFederation: Codec[NewFederation] =
(text *: text).to[NewFederation]
(federationIdCodec *: text).to[NewFederation]

val federationInfo: Codec[FederationInfo] =
(text *: text).to[FederationInfo]
(federationIdCodec *: text).to[FederationInfo]

val stats: Codec[Stats] =
(int4 *: int4 *: int4).to[Stats]

val federationSummary: Codec[FederationSummary] =
(text *: text *: int4 *: stats *: stats *: stats).to[FederationSummary]
(federationIdCodec *: text *: int4 *: stats *: stats *: stats).to[FederationSummary]

val playerInfo: Codec[PlayerInfo] =
(int4 *: text *: title.opt *: title.opt *: otherTitles *: int4.opt *: int4.opt *: int4.opt *: sex.opt *: int4.opt *: bool *: timestamptz *: timestamptz *: federationInfo.opt)
(playerIdCodec *: text *: title.opt *: title.opt *: otherTitles *: ratingCodec.opt *: ratingCodec.opt *: ratingCodec.opt *: sex.opt *: int4.opt *: bool *: timestamptz *: timestamptz *: federationInfo.opt)
.to[PlayerInfo]

private object Sql:
Expand All @@ -153,10 +180,10 @@ private object Sql:
""".command

lazy val playerById: Query[PlayerId, PlayerInfo] =
sql"$allPlayersFragment WHERE p.id = $int4".query(playerInfo)
sql"$allPlayersFragment WHERE p.id = $playerIdCodec".query(playerInfo)

lazy val playersByFederationId: Query[FederationId, PlayerInfo] =
sql"$allPlayersFragment WHERE p.federation_id = $text".query(playerInfo)
sql"$allPlayersFragment WHERE p.federation_id = $federationIdCodec".query(playerInfo)

lazy val upsertFederation: Command[NewFederation] =
sql"$insertIntoFederation VALUES ($newFederation) $onConflictDoNothing".command
Expand Down Expand Up @@ -188,7 +215,7 @@ private object Sql:

lazy val federationSummaryById: Query[FederationId, FederationSummary] =
sql"""$allFederationsSummaryFragment
WHERE id = $text""".query(federationSummary)
WHERE id = $federationIdCodec""".query(federationSummary)

private val void: AppliedFragment = sql"".apply(Void)
private val and: AppliedFragment = sql"AND ".apply(Void)
Expand Down Expand Up @@ -240,10 +267,10 @@ private object Sql:

private def pagingFragment(page: Pagination): AppliedFragment =
sql"""
LIMIT ${int4} OFFSET ${int4}""".apply(page.limit, page.offset)
LIMIT ${int4} OFFSET ${int4}""".apply(page.size, page.offset)

private def federationIdFragment(id: FederationId): AppliedFragment =
sql"""p.federation_id = $text""".apply(id)
sql"""p.federation_id = $federationIdCodec""".apply(id)

private def sortingFragment(sorting: Sorting): AppliedFragment =
val column = s"p.${sorting.sortBy.value}"
Expand Down
Loading

0 comments on commit d90cdf0

Please sign in to comment.