From 1919e4b72cf18509bd0374d7f7135daf67d98c53 Mon Sep 17 00:00:00 2001 From: Paul-Henri Froidmont Date: Thu, 27 Feb 2025 18:45:46 +0100 Subject: [PATCH] Initial commit --- .envrc | 10 + .gitignore | 5 + .scalafmt.conf | 24 ++ api/src/lu/foyer/App.scala | 71 +++++ build.sc | 37 +++ core/src/lu/foyer/clients/ClientCommand.scala | 23 ++ core/src/lu/foyer/clients/ClientEvent.scala | 24 ++ core/src/lu/foyer/clients/ClientState.scala | 24 ++ flake.lock | 262 ++++++++++++++++++ flake.nix | 58 ++++ model/src/lu/foyer/RefinedType.scala | 26 ++ model/src/lu/foyer/clients/Address.scala | 20 ++ model/src/lu/foyer/clients/Client.scala | 49 ++++ model/src/lu/foyer/clients/Country.scala | 7 + 14 files changed, 640 insertions(+) create mode 100644 .envrc create mode 100644 .gitignore create mode 100644 .scalafmt.conf create mode 100644 api/src/lu/foyer/App.scala create mode 100644 build.sc create mode 100644 core/src/lu/foyer/clients/ClientCommand.scala create mode 100644 core/src/lu/foyer/clients/ClientEvent.scala create mode 100644 core/src/lu/foyer/clients/ClientState.scala create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 model/src/lu/foyer/RefinedType.scala create mode 100644 model/src/lu/foyer/clients/Address.scala create mode 100644 model/src/lu/foyer/clients/Client.scala create mode 100644 model/src/lu/foyer/clients/Country.scala diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..dc25ec7 --- /dev/null +++ b/.envrc @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ad23fff --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.devenv +.direnv +out +.bloop +.metals diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..f97a335 --- /dev/null +++ b/.scalafmt.conf @@ -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 diff --git a/api/src/lu/foyer/App.scala b/api/src/lu/foyer/App.scala new file mode 100644 index 0000000..40c40e4 --- /dev/null +++ b/api/src/lu/foyer/App.scala @@ -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) diff --git a/build.sc b/build.sc new file mode 100644 index 0000000..f09ce35 --- /dev/null +++ b/build.sc @@ -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}" + ) +} diff --git a/core/src/lu/foyer/clients/ClientCommand.scala b/core/src/lu/foyer/clients/ClientCommand.scala new file mode 100644 index 0000000..49b1933 --- /dev/null +++ b/core/src/lu/foyer/clients/ClientCommand.scala @@ -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) diff --git a/core/src/lu/foyer/clients/ClientEvent.scala b/core/src/lu/foyer/clients/ClientEvent.scala new file mode 100644 index 0000000..40749d1 --- /dev/null +++ b/core/src/lu/foyer/clients/ClientEvent.scala @@ -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]) diff --git a/core/src/lu/foyer/clients/ClientState.scala b/core/src/lu/foyer/clients/ClientState.scala new file mode 100644 index 0000000..3bef782 --- /dev/null +++ b/core/src/lu/foyer/clients/ClientState.scala @@ -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]) diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..c371fe3 --- /dev/null +++ b/flake.lock @@ -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 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..4975e23 --- /dev/null +++ b/flake.nix @@ -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; + }; + + } + ]; + }; + } + ); + }; +} diff --git a/model/src/lu/foyer/RefinedType.scala b/model/src/lu/foyer/RefinedType.scala new file mode 100644 index 0000000..74d0c02 --- /dev/null +++ b/model/src/lu/foyer/RefinedType.scala @@ -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)) diff --git a/model/src/lu/foyer/clients/Address.scala b/model/src/lu/foyer/clients/Address.scala new file mode 100644 index 0000000..6f9036e --- /dev/null +++ b/model/src/lu/foyer/clients/Address.scala @@ -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 diff --git a/model/src/lu/foyer/clients/Client.scala b/model/src/lu/foyer/clients/Client.scala new file mode 100644 index 0000000..a4e8ed2 --- /dev/null +++ b/model/src/lu/foyer/clients/Client.scala @@ -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 diff --git a/model/src/lu/foyer/clients/Country.scala b/model/src/lu/foyer/clients/Country.scala new file mode 100644 index 0000000..2d1ddf5 --- /dev/null +++ b/model/src/lu/foyer/clients/Country.scala @@ -0,0 +1,7 @@ +package lu.foyer +package clients + +import zio.schema.* + +enum Country derives Schema: + case LU, FR, BE