Initial commit

This commit is contained in:
Paul-Henri Froidmont 2025-02-27 18:45:46 +01:00
commit 1919e4b72c
Signed by: phfroidmont
GPG key ID: BE948AFD7E7873BE
14 changed files with 640 additions and 0 deletions

10
.envrc Normal file
View file

@ -0,0 +1,10 @@
if ! has nix_direnv_version || ! nix_direnv_version 2.2.1; then
source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/2.2.1/direnvrc" "sha256-zelF0vLbEl5uaqrfIzbgNzJWGmLzCmYAkInj/LNxvKs="
fi
watch_file flake.nix
watch_file flake.lock
if ! use flake . --no-pure-eval
then
echo "devenv could not be built. The devenv environment was not loaded. Make the necessary changes to devenv.nix and hit enter to try again." >&2
fi

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
.devenv
.direnv
out
.bloop
.metals

24
.scalafmt.conf Normal file
View file

@ -0,0 +1,24 @@
version = "3.8.3"
preset=defaultWithAlign
assumeStandardLibraryStripMargin = true
maxColumn = 100
continuationIndent.callSite = 2
continuationIndent.defnSite = 2
align.arrowEnumeratorGenerator = true
align.openParenDefnSite = false
align.stripMargin = true
rewrite.rules = [RedundantBraces, Imports, RedundantParens, SortModifiers, PreferCurlyFors]
rewrite.redundantBraces.ifElseExpressions = true
rewrite.redundantBraces.stringInterpolation = true
verticalMultiline.atDefnSite = true
verticalMultiline.newlineAfterOpenParen = true
optIn.breaksInsideChains = true
lineEndings = unix
runner.dialect = scala3
rewrite.scala3.convertToNewSyntax = true
rewrite.scala3.removeOptionalBraces = yes
rewrite.scala3.insertEndMarkerMinLines = 30
rewrite.scala3.removeEndMarkerMaxLines = 29

View file

@ -0,0 +1,71 @@
package lu.foyer
import lu.foyer.clients.ClientState
import zio.*
import zio.Console.*
import zio.http.*
import zio.schema.*
import zio.http.endpoint.*
import zio.http.codec.*
import java.net.URI
import zio.http.endpoint.openapi.OpenAPIGen
import zio.http.endpoint.openapi.SwaggerUI
import zio.http.codec.PathCodec.path
case class JsonApiResponseSingle[T](
data: JsonApiResponseEntity[T],
links: JsonApiResponseLinks)
derives Schema
case class JsonApiResponseMultiple[T](
data: List[JsonApiResponseEntity[T]],
links: JsonApiResponseLinks,
meta: JsonApiResponseMeta)
derives Schema
case class JsonApiResponseLinks(
self: String,
first: Option[String] = None,
prev: Option[String] = None,
next: Option[String] = None,
last: Option[String] = None)
derives Schema
case class JsonApiResponseMeta(totalRecords: Int, totalPages: Int) derives Schema
final case class JsonApiResponseEntity[T](id: String, `type`: String, attributes: T) derives Schema
case class Page(number: Int, size: Int, totals: Boolean)
val pageParams =
(HttpCodec.query[Option[Int]]("page[number]")
& HttpCodec.query[Option[Int]]("page[size]")
& HttpCodec.query[Option[Boolean]]("page[totals]"))
.transform[Page]((number, size, totals) =>
Page(number.getOrElse(0), size.getOrElse(50), totals.getOrElse(false))
)(p => (Some(p._1), Some(p._2), Some(p._3)))
object ClientsController:
private val fetchMany =
Endpoint(Method.GET / "clients").query(pageParams).out[JsonApiResponseMultiple[ClientState]]
private val fetchManyRoute =
fetchMany.implement(page =>
ZIO.succeed(
JsonApiResponseMultiple[ClientState](
List.empty,
JsonApiResponseLinks("https://api.example.org"),
meta = JsonApiResponseMeta(0, 1)
)
)
)
val endpoints = List(fetchMany)
val routes = Routes(fetchManyRoute)
object App extends ZIOAppDefault:
val openAPI = OpenAPIGen.fromEndpoints(ClientsController.endpoints)
val routes = ClientsController.routes ++ SwaggerUI.routes("docs" / "openapi", openAPI)
override def run = Server.serve(routes).provide(Server.default)

37
build.sc Normal file
View file

@ -0,0 +1,37 @@
// scalafmt: { runner.dialect = scala213 }
package build
import mill._, scalalib._
object Versions {
val zio = "2.1.15"
val zioJson = "0.7.33"
val zioSchema = "1.6.3"
val zioHttp = "3.0.1"
val zioPrelude = "1.0.0-RC39"
}
trait CommonModule extends ScalaModule {
def scalaVersion = "3.6.3"
def ivyDeps = Agg(
ivy"dev.zio::zio:${Versions.zio}",
ivy"dev.zio::zio-schema:${Versions.zioSchema}",
ivy"dev.zio::zio-schema-derivation:${Versions.zioSchema}",
ivy"dev.zio::zio-prelude:${Versions.zioPrelude}"
)
}
object model extends CommonModule
object core extends CommonModule {
def moduleDeps = Seq(model)
}
object api extends CommonModule {
def moduleDeps = Seq(core)
def ivyDeps = Agg(
ivy"dev.zio::zio:${Versions.zio}",
ivy"dev.zio::zio-http:${Versions.zioHttp}",
ivy"dev.zio::zio-json:${Versions.zioJson}",
ivy"dev.zio::zio-schema-json:${Versions.zioSchema}"
)
}

View file

@ -0,0 +1,23 @@
package lu.foyer
package clients
import zio.schema.*
import java.time.LocalDate
enum ClientCommand derives Schema:
case Create(
lastName: ClientLastName,
firstName: ClientFirstName,
drivingLicenseDate: Option[ClientDrivingLicenseDate],
phoneNumber: Option[PhoneNumberInput],
email: Option[Email],
address: Option[Address])
case Update(
lastName: Option[ClientLastName],
firstName: Option[ClientFirstName],
drivingLicenseDate: Option[ClientDrivingLicenseDate],
phoneNumber: Option[PhoneNumberInput],
email: Option[Email],
address: Option[Address])
case Disable(reason: ClientDisabledReason)

View file

@ -0,0 +1,24 @@
package lu.foyer
package clients
import zio.schema.*
import java.time.LocalDate
enum ClientEvent derives Schema:
case Created(
lastName: ClientLastName,
firstName: ClientFirstName,
birthDate: ClientBirthDate,
drivingLicenseDate: Option[ClientDrivingLicenseDate],
phoneNumber: Option[PhoneNumberInput],
email: Option[Email],
address: Option[Address])
case Updated(
lastName: Option[ClientLastName],
firstName: Option[ClientFirstName],
birthDate: Option[ClientBirthDate],
drivingLicenseDate: Option[ClientDrivingLicenseDate],
phoneNumber: Option[PhoneNumberInput],
email: Option[Email],
address: Option[Address])

View file

@ -0,0 +1,24 @@
package lu.foyer
package clients
import zio.schema.*
import java.time.LocalDate
enum ClientState derives Schema:
case Actif(
lastName: ClientLastName,
firstName: ClientFirstName,
birthDate: ClientBirthDate,
drivingLicenseDate: Option[ClientDrivingLicenseDate],
phoneNumber: Option[PhoneNumberInput],
email: Option[Email],
address: Option[Address])
case Inactif(
lastName: ClientLastName,
firstName: ClientFirstName,
birthDate: ClientBirthDate,
drivingLicenseDate: Option[ClientDrivingLicenseDate],
phoneNumber: Option[PhoneNumberInput],
email: Option[Email],
address: Option[Address])

262
flake.lock generated Normal file
View file

@ -0,0 +1,262 @@
{
"nodes": {
"cachix": {
"inputs": {
"devenv": [
"devenv"
],
"flake-compat": [
"devenv"
],
"git-hooks": [
"devenv"
],
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1737621947,
"narHash": "sha256-8HFvG7fvIFbgtaYAY2628Tb89fA55nPm2jSiNs0/Cws=",
"owner": "cachix",
"repo": "cachix",
"rev": "f65a3cd5e339c223471e64c051434616e18cc4f5",
"type": "github"
},
"original": {
"owner": "cachix",
"ref": "latest",
"repo": "cachix",
"type": "github"
}
},
"devenv": {
"inputs": {
"cachix": "cachix",
"flake-compat": "flake-compat",
"git-hooks": "git-hooks",
"nix": "nix",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1740460834,
"narHash": "sha256-RUL1r8zH5wG5L1YipNj1bmt0Oi8L9qwzXsf/ww8WxBc=",
"owner": "cachix",
"repo": "devenv",
"rev": "9e4003b2702483bd962dac3d4ff43e8dafb93cda",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "devenv",
"type": "github"
}
},
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1733328505,
"narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"flake-parts": {
"inputs": {
"nixpkgs-lib": [
"devenv",
"nix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1712014858,
"narHash": "sha256-sB4SWl2lX95bExY2gMFG5HIzvva5AVMJd4Igm+GpZNw=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "9126214d0a59633752a136528f5f3b9aa8565b7d",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"git-hooks": {
"inputs": {
"flake-compat": [
"devenv"
],
"gitignore": "gitignore",
"nixpkgs": [
"devenv",
"nixpkgs"
]
},
"locked": {
"lastModified": 1737465171,
"narHash": "sha256-R10v2hoJRLq8jcL4syVFag7nIGE7m13qO48wRIukWNg=",
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "9364dc02281ce2d37a1f55b6e51f7c0f65a75f17",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "git-hooks.nix",
"type": "github"
}
},
"gitignore": {
"inputs": {
"nixpkgs": [
"devenv",
"git-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1709087332,
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"libgit2": {
"flake": false,
"locked": {
"lastModified": 1697646580,
"narHash": "sha256-oX4Z3S9WtJlwvj0uH9HlYcWv+x1hqp8mhXl7HsLu2f0=",
"owner": "libgit2",
"repo": "libgit2",
"rev": "45fd9ed7ae1a9b74b957ef4f337bc3c8b3df01b5",
"type": "github"
},
"original": {
"owner": "libgit2",
"repo": "libgit2",
"type": "github"
}
},
"nix": {
"inputs": {
"flake-compat": [
"devenv"
],
"flake-parts": "flake-parts",
"libgit2": "libgit2",
"nixpkgs": "nixpkgs_2",
"nixpkgs-23-11": [
"devenv"
],
"nixpkgs-regression": [
"devenv"
],
"pre-commit-hooks": [
"devenv"
]
},
"locked": {
"lastModified": 1734114420,
"narHash": "sha256-n52PUzub5jZWc8nI/sR7UICOheU8rNA+YZ73YaHeCBg=",
"owner": "domenkozar",
"repo": "nix",
"rev": "bde6a1a0d1f2af86caa4d20d23eca019f3d57eee",
"type": "github"
},
"original": {
"owner": "domenkozar",
"ref": "devenv-2.24",
"repo": "nix",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1733212471,
"narHash": "sha256-M1+uCoV5igihRfcUKrr1riygbe73/dzNnzPsmaLCmpo=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "55d15ad12a74eb7d4646254e13638ad0c4128776",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1717432640,
"narHash": "sha256-+f9c4/ZX5MWDOuB1rKoWj+lBNm0z0rs4CK47HBLxy1o=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "88269ab3044128b7c2f4c7d68448b2fb50456870",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "release-24.05",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_3": {
"locked": {
"lastModified": 1733477122,
"narHash": "sha256-qamMCz5mNpQmgBwc8SB5tVMlD5sbwVIToVZtSxMph9s=",
"owner": "cachix",
"repo": "devenv-nixpkgs",
"rev": "7bd9e84d0452f6d2e63b6e6da29fe73fac951857",
"type": "github"
},
"original": {
"owner": "cachix",
"ref": "rolling",
"repo": "devenv-nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"devenv": "devenv",
"nixpkgs": "nixpkgs_3",
"systems": "systems"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

58
flake.nix Normal file
View file

@ -0,0 +1,58 @@
{
inputs = {
nixpkgs.url = "github:cachix/devenv-nixpkgs/rolling";
systems.url = "github:nix-systems/default";
devenv.url = "github:cachix/devenv";
devenv.inputs.nixpkgs.follows = "nixpkgs";
};
nixConfig = {
extra-trusted-public-keys = "devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw=";
extra-substituters = "https://devenv.cachix.org";
};
outputs =
{
self,
nixpkgs,
devenv,
systems,
...
}@inputs:
let
forEachSystem = nixpkgs.lib.genAttrs (import systems);
in
{
packages = forEachSystem (system: {
devenv-up = self.devShells.${system}.default.config.procfileScript;
devenv-test = self.devShells.${system}.default.config.test;
});
devShells = forEachSystem (
system:
let
pkgs = nixpkgs.legacyPackages.${system};
in
{
default = devenv.lib.mkShell {
inherit inputs pkgs;
modules = [
{
# https://devenv.sh/reference/options/
packages = [
pkgs.mill
pkgs.kafkactl
];
services = {
kafka.enable = true;
mongodb.enable = true;
};
}
];
};
}
);
};
}

View file

@ -0,0 +1,26 @@
package lu.foyer
import zio.prelude.*
import zio.schema.Schema
import java.time.LocalDate
trait RefinedType[Base, New]:
inline def assume(value: Base): New = value.asInstanceOf[New]
def validation(value: Base): Validation[String, New]
trait RefinedString[New <: String] extends RefinedType[String, New]:
given Schema[New] = Schema[String].transformOrFail(
validation(_).toEither.left.map(_.toList.mkString(", ")),
Right(_)
)
trait RefinedLocalDate[New <: LocalDate] extends RefinedType[LocalDate, New]:
override def validation(value: LocalDate): Validation[String, New] =
Validation.succeed(assume(value))
given Schema[New] = Schema[LocalDate].transform(assume, identity)
trait NonBlankString[New <: String] extends RefinedString[New]:
override def validation(value: String): Validation[String, New] =
if value.isBlank then Validation.fail("Cannot be blank")
else Validation.succeed(assume(value))

View file

@ -0,0 +1,20 @@
package lu.foyer
package clients
import zio.schema.*
opaque type AddressStreet <: String = String
object AddressStreet extends NonBlankString[AddressStreet]
opaque type AddressPostalCode <: String = String
object AddressPostalCode extends NonBlankString[AddressPostalCode]
opaque type AddressLocality <: String = String
object AddressLocality extends NonBlankString[AddressLocality]
case class Address(
street: AddressStreet,
postalCode: AddressPostalCode,
locality: AddressLocality,
country: Country)
derives Schema

View file

@ -0,0 +1,49 @@
package lu.foyer
package clients
import zio.schema.*
import java.time.Instant
import java.time.LocalDate
opaque type ClientLastName <: String = String
object ClientLastName extends NonBlankString[ClientLastName]
opaque type ClientFirstName <: String = String
object ClientFirstName extends NonBlankString[ClientFirstName]
opaque type ClientBirthDate <: LocalDate = LocalDate
object ClientBirthDate extends RefinedLocalDate[ClientBirthDate]
opaque type ClientDrivingLicenseDate <: LocalDate = LocalDate
object ClientDrivingLicenseDate extends RefinedLocalDate[ClientDrivingLicenseDate]
opaque type Email <: String = String
object Email extends NonBlankString[Email]
opaque type NationalNumber <: String = String
object NationalNumber extends NonBlankString[NationalNumber]
case class Client(
lastName: ClientFirstName,
firstName: ClientLastName,
drivingLicenseDate: ClientDrivingLicenseDate,
phoneNumber: PhoneNumber,
email: Email,
address: Address)
derives Schema
// TODO validate using libphonenumber
case class PhoneNumber(
country: Country,
nationalNumber: NationalNumber,
lastVerifiedAt: Option[Instant])
derives Schema
case class PhoneNumberInput(
country: Country,
nationalNumber: String)
derives Schema
enum ClientDisabledReason derives Schema:
case GDPR, Death

View file

@ -0,0 +1,7 @@
package lu.foyer
package clients
import zio.schema.*
enum Country derives Schema:
case LU, FR, BE