diff --git a/README.md b/README.md index a55691f..dab9530 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,10 @@ Scala training repository used to learn Scala and Functional Programming by solv ### List of katas: -| # | Kata Statement | Pull Request | -|---|----------------|---------------| -| 1 | [Maxibons](https://github.com/Karumi/MaxibonKataJava#-kata-maxibon-for-java-) | [https://github.com/Karumi/ScalaKatas/pull/1](https://github.com/Karumi/ScalaKatas/pull/1) | +| # | Kata Statement | PR | Topic | +|---|----------------|----|-------| +| 1 | [Maxibons](https://github.com/Karumi/MaxibonKataJava#-kata-maxibon-for-java-) | [https://github.com/Karumi/ScalaKatas/pull/1](https://github.com/Karumi/ScalaKatas/pull/1) | Polymorphic programming | +| 2 | [Form validation](https://gist.github.com/pedrovgs/d83fe1f096928715a6f31946e557995a) | [https://github.com/Karumi/ScalaKatas/pull/2](https://github.com/Karumi/ScalaKatas/pull/2) | Validated data type| ### Executing tests: diff --git a/build.sbt b/build.sbt index 415d324..2015ae1 100644 --- a/build.sbt +++ b/build.sbt @@ -10,7 +10,7 @@ scalacOptions += "-Ypartial-unification" libraryDependencies += "org.typelevel" %% "cats-core" % "1.5.0" libraryDependencies += "org.typelevel" %% "cats-effect" % "1.1.0" -libraryDependencies += "eu.timepit" %% "refined" % "0.9.3" +libraryDependencies += "eu.timepit" %% "refined" % "0.9.4" libraryDependencies += "com.github.julien-truffaut" %% "monocle-core" % "1.5.0" libraryDependencies += "com.github.julien-truffaut" %% "monocle-macro" % "1.5.0" diff --git a/src/main/scala/com/github/pedrovgs/scalakatas/formvalidation/FormValidator.scala b/src/main/scala/com/github/pedrovgs/scalakatas/formvalidation/FormValidator.scala new file mode 100644 index 0000000..f8d8232 --- /dev/null +++ b/src/main/scala/com/github/pedrovgs/scalakatas/formvalidation/FormValidator.scala @@ -0,0 +1,91 @@ +package com.github.pedrovgs.scalakatas.formvalidation + +import java.time.LocalDateTime + +import cats.data.{NonEmptyList, Validated, ValidatedNel} +import cats.syntax.apply._ +import eu.timepit.refined.{W, _} +import eu.timepit.refined.api.{Refined, Validate} +import eu.timepit.refined.collection.NonEmpty +import eu.timepit.refined.string.MatchesRegex + +object FormValidator { + + type FirstName = Refined[String, NonEmpty] + type LastName = Refined[String, NonEmpty] + type ValidDocumentId = MatchesRegex[W.`"""\\d{8}[a-zA-Z]{1}"""`.T] + type DocumentId = Refined[String, ValidDocumentId] + type ValidPhone = MatchesRegex[W.`"""\\d{9}"""`.T] + type Phone = Refined[String, ValidPhone] + type Email = Refined[String, ValidEmail] + + case class ValidEmail() + implicit val emailValidate: Validate.Plain[String, ValidEmail] = + Validate.fromPredicate(e => e.contains("@"), p => s"$p is not a valid email", ValidEmail()) + + final case class UnsafeForm( + firstName: String, + lastName: String, + birthday: LocalDateTime, + documentId: String, + phone: String, + email: String + ) + final case class Form( + firstName: FirstName, + lastName: LastName, + birthday: LocalDateTime, + documentId: DocumentId, + phone: Phone, + email: Email + ) + type FormValidationResult[A] = ValidatedNel[FormError, A] + + def apply(referenceDate: LocalDateTime, form: UnsafeForm): FormValidationResult[Form] = + (validateFirstName(form.firstName), + validateLastName(form.lastName), + validateBirthday(referenceDate, form.birthday), + validateDocumentId(form.documentId), + validatePhone(form.phone), + validateEmail(form.email)).mapN(Form) + + private def validateFirstName(firstName: String): FormValidationResult[FirstName] = + Validated + .fromEither(refineV[NonEmpty](firstName)) + .leftMap(_ => NonEmptyList.of(EmptyFirstName(firstName))) + + private def validateLastName(lastName: String): FormValidationResult[LastName] = + Validated + .fromEither(refineV[NonEmpty](lastName)) + .leftMap(_ => NonEmptyList.of(EmptyLastName(lastName))) + + private def validateBirthday(refDate: LocalDateTime, birthday: LocalDateTime): FormValidationResult[LocalDateTime] = + if (birthday.compareTo(refDate.minusYears(18)) <= 0) { + Validated.valid(birthday) + } else { + Validated.invalidNel(UserTooYoung(birthday)) + } + private def validateDocumentId(documentId: String): FormValidationResult[DocumentId] = + Validated + .fromEither(refineV[ValidDocumentId](documentId)) + .leftMap(_ => NonEmptyList.of(InvalidDocumentId(documentId))) + + private def validatePhone(phone: String): FormValidationResult[Phone] = + Validated + .fromEither(refineV[ValidPhone](phone)) + .leftMap(_ => NonEmptyList.of(InvalidPhone(phone))) + + private def validateEmail(email: String): FormValidationResult[Email] = + Validated + .fromEither(refineV[ValidEmail](email)) + .leftMap(_ => NonEmptyList.of(InvalidEmail(email))) + +} + +sealed trait FormError +case class EmptyFirstName(firstName: String) extends FormError +final case class EmptyLastName(lastName: String) extends FormError +final case class UserTooYoung(birthday: LocalDateTime) extends FormError +final case class InvalidDocumentId(documentId: String) extends FormError +final case class InvalidPhone(phone: String) extends FormError +final case class InvalidEmail(email: String) extends FormError diff --git a/src/test/scala/com/github/pedrovgs/formvalidation/ArbitraryForms.scala b/src/test/scala/com/github/pedrovgs/formvalidation/ArbitraryForms.scala new file mode 100644 index 0000000..a5ed9ca --- /dev/null +++ b/src/test/scala/com/github/pedrovgs/formvalidation/ArbitraryForms.scala @@ -0,0 +1,20 @@ +package com.github.pedrovgs.formvalidation +import java.time.LocalDateTime + +import com.github.pedrovgs.scalakatas.formvalidation.FormValidator.{FirstName, LastName, UnsafeForm} +import eu.timepit.refined.scalacheck.string._ +import org.scalacheck.{Arbitrary, Gen} + +trait ArbitraryForms { + + implicit val arbitraryForm: Arbitrary[UnsafeForm] = Arbitrary { + for { + firstName <- Arbitrary.arbitrary[FirstName] + lastName <- Arbitrary.arbitrary[LastName] + birthday <- Gen.const(LocalDateTime.MIN) + documentId <- Gen.const("44632508A") + phone <- Gen.const("999673292") + email <- Gen.const("p@k.com") + } yield UnsafeForm(firstName.value, lastName.value, birthday, documentId, phone, email) + } +} diff --git a/src/test/scala/com/github/pedrovgs/formvalidation/FormValidatorSpec.scala b/src/test/scala/com/github/pedrovgs/formvalidation/FormValidatorSpec.scala new file mode 100644 index 0000000..a232acf --- /dev/null +++ b/src/test/scala/com/github/pedrovgs/formvalidation/FormValidatorSpec.scala @@ -0,0 +1,58 @@ +package com.github.pedrovgs.formvalidation +import java.time.LocalDateTime + +import cats.data.{NonEmptyList, Validated} +import com.github.pedrovgs.scalakatas.formvalidation.FormValidator.UnsafeForm +import com.github.pedrovgs.scalakatas.formvalidation._ +import org.scalatest.prop.PropertyChecks +import org.scalatest.{FlatSpec, Matchers} + +class FormValidatorSpec extends FlatSpec with Matchers with PropertyChecks with ArbitraryForms { + + it should "indicate all the invalid values in an unsafe form with every field as invalid" in { + val form = UnsafeForm( + firstName = "", + lastName = "", + birthday = LocalDateTime.now(), + documentId = "48632500", + phone = "6799", + email = "pedro" + ) + + val result = FormValidator(LocalDateTime.now(), form) + + result shouldBe Validated.invalid( + NonEmptyList.of( + EmptyFirstName(form.firstName), + EmptyLastName(form.lastName), + UserTooYoung(form.birthday), + InvalidDocumentId(form.documentId), + InvalidPhone(form.phone), + InvalidEmail(form.email) + )) + } + + it should "indicate just one invalid value in an unsafe form where just one field is invalid" in { + val form = UnsafeForm( + firstName = "Pedro", + lastName = "Gómez", + birthday = LocalDateTime.MIN, + documentId = "38632509C", + phone = "677673297", + email = "pedro" + ) + + val result = FormValidator(LocalDateTime.now(), form) + + result shouldBe Validated.invalid( + NonEmptyList.of( + InvalidEmail(form.email) + )) + } + + it should "consider as valid form with evey field as valid" in { + forAll { form: UnsafeForm => + FormValidator(LocalDateTime.now(), form).isValid shouldBe true + } + } +}