diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..00cb1cb --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +*.class +*.log +.vscode/ +.bsp/ +.metals/ +.bloop/ +metals.sbt +target/ \ No newline at end of file diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..dc3c884 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,2 @@ +version = "2.7.5" +maxColumn = 120 \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..040364b --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2020 Rewards Network Establishment Services + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..93337eb --- /dev/null +++ b/README.md @@ -0,0 +1,180 @@ +# Combos +![Sonatype Nexus (Releases)](https://img.shields.io/nexus/r/com.rewardsnetwork/combos_2.13?label=latest&server=https%3A%2F%2Foss.sonatype.org) +A validation library for Scala + +## Setup +This library is published for both Scala 2.12 and 2.13. +Scala 3 support will be coming +``` +libraryDependencies += "com.rewardsnetwork" %% "combos" % "" +libraryDependencies += "com.rewardsnetwork" %% "combos-refined" % "" //Optional - adds Refined support +``` + +## License +Copyright 2020 Rewards Network Establishment Services + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +## Motivation +This library was birthed from some projects that needed to share validation logic across multiple projects and sub-projects. +Normal validation often focuses on functions that take in an object with several fields, which is harder to test in isolation as you need to create a lot of mock data. +You can define functions that take in smaller values, such as a single field, but wiring those together is not directly composable. +This library uses Cats to implement certain validation patterns that are applicable for both fail-fast and error-accumulating scenarios alike, and allows you to plug different validations into each other to create more complicated validation processes. + +The end result is a library that allows you to separate your validation logic, data definition, and testing logic in a much easier, more compact way than doing it strictly with functions alone can provide. + +## Basic Usage +A `Validator[E, A]` is a type that validates values of type `A` and produces errors of type `E`. +As opposed to manually composing `Either` and `Validated` values together, a `Validator` allows you to focus on individual "checks" that need to be performed, and then can be composed and "ran" later into one full `Either` value containing your errors. + +To get started, `import com.rewardsnetwork.combos.syntax._` and take a look at the `check` function, which allows you to create a validator. +To compose validators, look at `checkAll` for fail-fast validation, and `parCheckAll` for accumulating errors. +To pass a validator a value to be tested, use `.run`, or if you are only expecting a single error value you can use `.runFailFast`. + +When using `checkAll`, it will return a `ShortCircuit[E, A]` which is equivalent to a `Validator[E, A]`, except it can only return a single error value. +`parCheckAll` returns another `Validator` that can be composed with other validators, and returns all possible error values. +You can transform between the two using `.failFast` on `Validator` and `.accumulate` on `ShortCircuit`. + +When composing validators, you will want to change their input type with `.local`, which acts as a `map`-like function for the input value. +In this way, you can validate case classes and other data structures simply by defining a way to get from your more specific type to the field you are trying to validate. + +Example usage: +```scala +case class MyCaseClass(int: Int, string: String) + +val checkInt: Validator[Boolean, Int] = check { case 5 => false } +val checkString: Validator[Boolean, String] = check { case "thing" => false } + +val checkCaseClass = parCheckAll(List[Validator[Boolean, MyCaseClass]]( + checkInt.local(_.int), + checkString.local(_.string) +)) + +val badCaseClass = MyCaseClass(5, "thing") + +checkCaseClass.run(badCaseClass) +// Left(NonEmptyChain(false, false)) -- both errors + +checkCaseClass.failFast.runFailFast(badCaseClass) +// Left(false) -- first error only +``` + +## Checker +`Checker` is a mix-in trait or importable DSL that is just a shorter way to define multiple validators. +Say you are validating the numerous fields of a case class, and are providing those all in an object. +It would be very tedious to have to specify the error type for every single validation, so a `Checker` solves that for you. +You can `extend Checker[E]` and you will get a `check[A]` function that has a fixed error type in your local scope. +If you feel uneasy about extending the mix-in, simply create a checker and import its values, like so: +```scala +val checker = Checker[String] +checker.check[Boolean] { case false => "can't be false" } + +import checker._ +check[boolean] { case false => "can't be false" } + +//The above two are equivalent +``` + +## Returning Values & Ask +A `Validator` and `ShortCircuit` are instances of `ReturningValidator` and `ReturningShortCircuit` respectively. +These are the same as before, except they also have a known return value. +This can be particularly useful when you are building "chained validators" where the output from one validator should be fed into a subsequent one. + +To produce one of these values, use `checkReturn` instead of `check`, which returns the source input. +It can then be mapped, flatMapped, and transformed like any other monadic value. + +Sometimes you will want to "ask for" a value, but not immediately validate it, possibly to use it as part of a more complex validation scenario. +Consider this example where you are validating a user's age, and want to return the validated `User` object given you know the user's name: +```scala +case class User(name: String, age: Int) + +val askName = ask[String, String] + +val checkAge = checkReturn[String, Int] { + case age if (age < 18) => "User is not an adult" +} + +// Checks the user's age, then adds in a name and returns the validated user. +val checkUser = askName.local[User](_.name).flatMap { name => + checkAge + .local[User](_.age) + .as(age => User(name, age)) +} + +checkUser.run(User("Ryan", 18)) //Right(User("Ryan", 18)) +``` + +In addition, there are special `option` and `either` constructors that will `ask` for a value, and if it exists, try to return it. +These are especially useful when building staged validation where some input is optional and you need to extract it regardless. + +## Special Syntax +Every `Validator` and derivative thereof has special syntax you can use from implicits. +Assuming you have `syntax._` imported, you will get access to these for every validator: + +* `failFast` - Turns into a `ShortCircuit` that can only return at most one error. +* `mapLeft` - Map the error type `E` to a new value of type `E2`. +* `returnInput` - Returns the input to this validator after running. +* `runFailFast` - Shorthand for `.failFast.run` +* `runOption` - Discards any return value, and returns an `Option` of the errors +* `runFailFastOption` - Shorthand for `.failFast.runOption` +* `withF` - Lifts this validator to operate within the effect type `F[_]` specified. Only available on pure validators. + +For every `ShortCircuit`, these are available: + +* `accumulate` - Turns into a `Validator` that can now accumulate multiple values. +* `mapLeft` - Map the error type `E` to a new value of type `E2`. +* `returnInput` - Returns the input to this short circuit after running. +* `runOption` - Discards any return value, and returns an `Option` of the error. +* `withF` - Lifts this short circuit to operate within the effect type `F[_]` specified. Only available on pure (non-effectful) short circuits. + +## Effects +This library also supports arbitrary effects `F[_]` such as Cats Effect `IO`. +For most functionality to work, your `F` needs at least a `Monad` instance from Cats. +For our examples, `IO` should work just fine. + +You can use all of the same operators as the regular validators, except appended with an `F`. +For example, `check` becomes `checkF`, and `ask` becomes `askF`. +You can shorten the type signature burden on yourself significantly if you use an `FChecker`, which is the same as a `Checker` except it also fixes the `F[_]` type as well as the error type `E`. + +Effectful validators can also evaluate effects in `F` and extract their values. +See `askEval`, `optionEval`, and `eitherEval` for ways to get a value of `F[_]` and evaluate it before proceeding with validation. + +To lift a pure validator into an effectful one, use the `withF` operator. +It works similarly to `.lift` on `Kleisli`, but it also ensures that the resulting validator can still accumulate errors via `EitherT`. + +**N.B.** Prefer using `.withF` in cases where you still want to compose with other validators. + +## Refined Support +This library optionally supports refining validators using the popular `refined` library. +To use, add the `combos-refined` dependency to your project, and `import com.rewardsnetwork.combos.refined.syntax._`. +It adds the following new operation: + +* `refine[A, P]` - Ask for a value `A` and validate that it is refinable to `A Refined P`. + +It also enables the following extension methods on existing validators: + +* `refine[P]` - Refines the output of this existing validator with `P`. Assumes your error is of type `String` +* `refineMapLeft[P]` - Refines your output, and also lets you specify a function `String => E` to produce a custom error from this validation. +* `asValidate[P]` - Creates a refined `Validate[T, P]` instance where `T` is the output type of your validator. Can be used to provide integrations with refined including compile-time validation. + +For example, assume we have the following refinement type defined using `refined`: +```scala +type PosInt = Int Refined Positive +``` + +We can ask for a positive integer using the following: +```scala +val askPosInt: ReturningValidator[String, Int, Int Refined Positive] = refine[Int, Positive] +``` + +The other syntax methods work similarly with regards to `P`, the predicate part of your refined type. \ No newline at end of file diff --git a/build.sbt b/build.sbt new file mode 100644 index 0000000..c97d136 --- /dev/null +++ b/build.sbt @@ -0,0 +1,89 @@ +//Core deps +val catsV = "2.3.1" +val catsEffectV = "2.3.1" +val refinedV = "0.9.20" +//Test/build deps +val scalaTestV = "3.2.3" +val scalaCheckV = "1.15.2" +val scalaTestScalacheckV = "3.2.3.0" +val betterMonadicForV = "0.3.1" +val kindProjectorV = "0.11.2" +val silencerV = "1.7.1" +val flexmarkV = "0.35.10" // scala-steward:off + +val scala213 = "2.13.4" +val scala212 = "2.12.12" + +inThisBuild( + List( + organization := "com.rewardsnetwork", + developers := List( + Developer("sloshy", "Ryan Peters", "me@rpeters.dev", url("https://github.com/sloshy")) + ), + homepage := Some(url("https://github.com/rewards-network/combos")), + licenses := List("Apache-2.0" -> url("http://www.apache.org/licenses/LICENSE-2.0")), + githubWorkflowJavaVersions := Seq("adopt@1.11"), + githubWorkflowTargetTags ++= Seq("v*"), + githubWorkflowPublishTargetBranches := Seq(RefPredicate.StartsWith(Ref.Tag("v"))), + githubWorkflowPublish := Seq( + WorkflowStep.Sbt( + List("ci-release"), + env = Map( + "PGP_PASSPHRASE" -> "${{ secrets.PGP_PASSPHRASE }}", + "PGP_SECRET" -> "${{ secrets.PGP_SECRET }}", + "SONATYPE_PASSWORD" -> "${{ secrets.SONATYPE_PASSWORD }}", + "SONATYPE_USERNAME" -> "${{ secrets.SONATYPE_USERNAME }}" + ) + ) + ) + ) +) + +val commonSettings = Seq( + scalaVersion := "2.13.4", + crossScalaVersions := Seq(scala212, scala213), + organization := "com.rewardsnetwork", + name := "combos", + testOptions in Test ++= Seq( + Tests.Argument(TestFrameworks.ScalaTest, "-o"), + Tests.Argument(TestFrameworks.ScalaTest, "-h", "target/test/test-reports") + ), + addCompilerPlugin("com.olegpy" %% "better-monadic-for" % betterMonadicForV), + addCompilerPlugin("org.typelevel" %% "kind-projector" % kindProjectorV cross CrossVersion.full), + addCompilerPlugin("com.github.ghik" % "silencer-plugin" % silencerV cross CrossVersion.full), + scalacOptions += "-P:silencer:pathFilters=.*[/]src_managed[/].*" //Filter compiler warnings from generated source +) + +lazy val root = (project in file(".")) + .aggregate(core, refined) + .settings( + commonSettings, + publish / skip := true + ) + +lazy val core = (project in file("core")) + .settings( + commonSettings, + name := "combos", + libraryDependencies ++= Seq( + //Core deps + "org.typelevel" %% "cats-core" % catsV, + //Test deps + "org.typelevel" %% "cats-effect" % catsEffectV, + "org.typelevel" %% "cats-effect-laws" % catsEffectV % "test", + "org.scalatest" %% "scalatest" % scalaTestV % "test", + "org.scalacheck" %% "scalacheck" % scalaCheckV % "test", + "org.scalatestplus" %% "scalacheck-1-15" % scalaTestScalacheckV % "test", + "com.vladsch.flexmark" % "flexmark-all" % flexmarkV % "test" + ) + ) + +lazy val refined = (project in file("refined")) + .settings( + commonSettings, + name := "combos-refined", + libraryDependencies ++= Seq( + "eu.timepit" %% "refined" % refinedV + ) + ) + .dependsOn(core % "compile->compile;test->test") diff --git a/core/src/main/scala/com/rewardsnetwork/combos/Checker.scala b/core/src/main/scala/com/rewardsnetwork/combos/Checker.scala new file mode 100644 index 0000000..4fbd8e3 --- /dev/null +++ b/core/src/main/scala/com/rewardsnetwork/combos/Checker.scala @@ -0,0 +1,47 @@ +package com.rewardsnetwork.combos + +/** A simple way to define checks that all share a fixed error type. + * To use, either create a new `Checker[E]` and import its contents, or extend `Checker[E]` in your classes/objects. + * + * Example usage (extends Checker): + * ``` + * object MyValidators extends Checker[String] { + * //The error type is pre-fixed to String + * val checkAge = check[Int] { case 20 => "Age can't be 20 for some reason" } + * val checkName = check[String] { case "Hitler" => "Hitler is not a good name" } + * val checkUser = parCheckAll(List[Validator[String, Int]]( + * checkAge.local(_.age), + * checkName.local(_.name) + * )) + * } + * ``` + * + * Alternate usage (import DSL object): + * ``` + * val checkWithString = Checker[String] + * import checkWithString._ + * + * val checkAge = check[Int] { case 20 => "Age can't be 20 for some reason" } + * ``` + */ +trait Checker[E] { + + /** Same as the default `ask[E, A]`, but with a fixed error type. */ + def ask[A] = syntax.ask[E, A] + + /** Same as the default `check[E, A]`, but with a fixed error type. */ + def check[A](pf: PartialFunction[A, E]) = syntax.check(pf) + + /** Same as the default `checkReturn[E, A, B]`, but with a fixed error type. */ + def checkReturn[A, B](f: A => Either[E, B]) = syntax.checkReturn(f) + + /** Same as the default `option[E, A]`, but with a fixed error type. */ + def option[A](ifNone: => E) = syntax.option[E, A](ifNone) + + /** Same as the default `either[E, A]`, but with a fixed error type. */ + def either[A] = syntax.either[E, A] +} + +object Checker { + def apply[E] = new Checker[E] {} +} diff --git a/core/src/main/scala/com/rewardsnetwork/combos/FChecker.scala b/core/src/main/scala/com/rewardsnetwork/combos/FChecker.scala new file mode 100644 index 0000000..a7dd09c --- /dev/null +++ b/core/src/main/scala/com/rewardsnetwork/combos/FChecker.scala @@ -0,0 +1,36 @@ +package com.rewardsnetwork.combos + +import cats.{Applicative, Functor, Monad} + +trait FChecker[F[_], E] { + + /** Alias for `askF`, which will ask for a pure value `A` and lift it to an `F` context. + * Useful for mixing pure values in with effectful validation. + */ + def ask[A](implicit A: Applicative[F]) = syntax.askF[F, E, A] + + /** Ask for some value effectful value `F[A]` and evaluate it, performing no validation. */ + def askEval[A](implicit F: Functor[F]) = syntax.askEval[F, E, A] + + /** Alias for `checkEval`, which is like `check` but accepts result values wrapped in `F[_]` */ + def check[A](pf: PartialFunction[A, F[E]])(implicit A: Applicative[F]) = syntax.checkEval(pf) + + /** Alias for `checkReturnF`, which is like `checkReturn` but accepts result values wrapped in `F[_]` */ + def checkReturn[A, B](f: A => F[Either[E, B]])(implicit A: Applicative[F]) = syntax.checkReturnF(f) + + /** Alias for `optionF`, which is like `option` but returns values wrapped in `F[_]` */ + def option[A](ifNone: => E)(implicit A: Applicative[F]) = syntax.optionF[F, E, A](ifNone) + + /** Alias for `optionEval[F, E, A]`, which evaluates effects and then tries to return the result if it exists. */ + def optionEval[A](ifNone: => E)(implicit A: Applicative[F]) = syntax.optionEval[F, E, A](ifNone) + + /** Alias for `eitherF`, which is like `either` but returns values wrapped in `F[_]` */ + def either[A](implicit A: Applicative[F]) = syntax.eitherF[F, E, A] + + /** Alias for `eitherEval[F, E, A]`, which evaluates effects and then tries to return the `Right` value. */ + def eitherEval[A](implicit A: Applicative[F]) = syntax.eitherEval[F, E, A] +} + +object FChecker { + def apply[F[_]: Monad, E] = new FChecker[F, E] {} +} diff --git a/core/src/main/scala/com/rewardsnetwork/combos/package.scala b/core/src/main/scala/com/rewardsnetwork/combos/package.scala new file mode 100644 index 0000000..ec6e51e --- /dev/null +++ b/core/src/main/scala/com/rewardsnetwork/combos/package.scala @@ -0,0 +1,42 @@ +package com.rewardsnetwork + +import cats.data._ + +package object combos { + + /** A `ShortCircuit` that also returns some value `B`. See `ReturningValidator`. */ + type ReturningShortCircuit[E, A, B] = Kleisli[Either[E, *], A, B] + + /** An alternative to `Validator` that is designed to have at most one error. + * Do not create these directly. Instead, it is preferred for composability reasons to convert with `.failFast` on a `Validator`. + */ + type ShortCircuit[E, A] = Kleisli[Either[E, *], A, Unit] + + /** A `Validator` that also returns some value `B`. + * Useful when chaining operations together for validation, or returning some "context value". + */ + type ReturningValidator[E, A, B] = Kleisli[EitherNec[E, *], A, B] + + /** A type that represents the possibility of accumulating errors. + * Instead of manual composition with `Either` or `Validated`, this focuses on individual field or property testing. + * + * A `Validator[E, A]` will return one or more errors of type `E`, and takes an input of type `A`. + * A validator allows you to separate the defining of validation logic from the actual data that needs to be checked. + * For example, to validate a user's age, simply define a validator where `A` is of type `Int`. + * To pass in a field individually, you can use `.run(user.age)`. or you can map the expected input type using `.local[User](_.age)`. + * Using the latter strategy, it becomes possible to compose multiple pre-existing validators together to produce a single validator. + * For examples, see the `checkAll` and `parCheckAll` functions in `syntax`. + * + * To create a Validator, import `com.rewardsnetwork.combos.syntax._` and use the supplied `check` function. + * For simpler syntax when making multiple validators, extend or import the values of a `Checker`, which fixes the error type for `check`. + */ + type Validator[E, A] = ReturningValidator[E, A, Unit] + + type FReturningShortCircuit[F[_], E, A, B] = Kleisli[EitherT[F, E, *], A, B] + + type FShortCircuit[F[_], E, A] = FReturningShortCircuit[F, E, A, Unit] + + type FReturningValidator[F[_], E, A, B] = Kleisli[EitherT[F, NonEmptyChain[E], *], A, B] + + type FValidator[F[_], E, A] = FReturningValidator[F, E, A, Unit] +} diff --git a/core/src/main/scala/com/rewardsnetwork/combos/syntax.scala b/core/src/main/scala/com/rewardsnetwork/combos/syntax.scala new file mode 100644 index 0000000..dfb6d5b --- /dev/null +++ b/core/src/main/scala/com/rewardsnetwork/combos/syntax.scala @@ -0,0 +1,243 @@ +package com.rewardsnetwork.combos + +import cats._ +import cats.data._ +import cats.implicits._ + +object syntax { + + implicit class ReturningShortCircuitOps[E, A, B](s: Kleisli[Either[E, *], A, B]) { + + /** Turns this `ShortCircuit` into a `Validator` that can accumulate errors. + * The resulting `Validator` will still only return a single error, but it can more easily compose with other `Validator`s. + */ + def accumulate = s.mapF(_.leftMap(NonEmptyChain.one)) + + /** Maps the error type to some new type `E2`. */ + def mapLeft[E2](f: E => E2) = s.mapF(_.leftMap(f)) + + /** Turns this `ShortCircuit` into one that returns its input once validated. + * For more control, use `ask[E, A].flatMap` directly. + */ + def returnInput = ask[E, A].failFast.flatMap(a => s.as(a)) + + /** Run and return an `Option` of the first error that occurs. */ + def runOption(a: A): Option[E] = s.run(a).swap.toOption + + /** Lifts this pure `ShortCircuit` into an `FShortCircuit` for your supplied effect type `F[_]`. */ + def withF[F[_]: Applicative] = s.mapF(EitherT.fromEither[F].apply) + } + + implicit class ReturningValidatorOps[E, A, B](v: Kleisli[EitherNec[E, *], A, B]) { + + /** Turns this `Validator` into a `ShortCircuit` that cannot accumulate errors. */ + def failFast: ReturningShortCircuit[E, A, B] = v.mapF(_.leftMap(_.head)) + + /** Maps the error type `E` to some new type `E2`. */ + def mapLeft[E2](f: E => E2) = v.mapF(_.leftMap(_.map(f))) + + /** Turns this `Validator` into one that returns its input once validated. + * For more control, use `ask[E, A].flatMap` directly. + */ + def returnInput = ask[E, A].flatMap(a => v.as(a)) + + /** Run and return only the first error. */ + def runFailFast(a: A): Either[E, B] = failFast.run(a) + + /** Run and return an `Option` of your errors. */ + def runOption(a: A): Option[NonEmptyChain[E]] = v.run(a).swap.toOption + + /** Run and return an `Option` of the first error that occurs. */ + def runFailFastOption(a: A): Option[E] = runFailFast(a).swap.toOption + + /** Lifts this pure `Validator` into an `FValidator` for your supplied effect type `F[_]`. */ + def withF[F[_]: Applicative] = v.mapF(EitherT.fromEither[F].apply) + + } + + implicit class FReturningShortCircuitOps[F[_], E, A, B](fs: Kleisli[EitherT[F, E, *], A, B]) { + + /** Turns this `FShortCircuit` into an `FValidator` that can accumulate errors. + * The resulting `FValidator` will still only return a single error, but it can more easily compose with other `FValidator`s. + */ + def accumulate(implicit F: Functor[F]): FReturningValidator[F, E, A, B] = fs.mapF(_.leftMap(NonEmptyChain.one)) + + /** Maps the error type to some new type `E2`. */ + def mapLeft[E2](f: E => E2)(implicit F: Functor[F]) = fs.mapF(_.leftMap(f)) + + /** Turns this `FShortCircuit` into one that returns its input once validated. + * For more control, use `askF[F, E, A].flatMap` directly. + */ + def returnInput(implicit M: Monad[F]) = askF[F, E, A].failFast.flatMap(a => fs.as(a)) + + /** Run and return an `OptionT` of the first error that occurs. + * Equivalent to `FValidator#runFailFastOptionT`. + */ + def runOptionT(a: A)(implicit F: Functor[F]): OptionT[F, E] = fs.run(a).swap.toOption + } + + implicit class FReturningValidatorOps[F[_], E, A, B]( + fv: Kleisli[EitherT[F, NonEmptyChain[E], *], A, B] + ) { + + /** Turns this `FValidator` into an `FShortCircuit` that cannot accumulate errors. */ + def failFast(implicit F: Functor[F]): FReturningShortCircuit[F, E, A, B] = fv.mapF(_.leftMap(_.head)) + + /** Maps the error type to some new type `E2`. */ + def mapLeft[E2](f: E => E2)(implicit F: Functor[F]) = fv.mapF(_.leftMap(_.map(f))) + + /** Turns this `FValidator` into one that returns its input once validated. + * For more control, use `askF[F, E, A].flatMap` directly. + */ + def returnInput(implicit M: Monad[F]) = askF[F, E, A].flatMap(a => fv.as(a)) + + /** Run and return only the first error. */ + def runFailFast(a: A)(implicit F: Functor[F]): EitherT[F, E, B] = failFast.run(a) + + /** Run and return an `OptionT` of your errors. */ + def runOptionT(a: A)(implicit F: Functor[F]): OptionT[F, NonEmptyChain[E]] = fv.run(a).swap.toOption + + /** Run and return an `OptionT` of the first error that occurs. */ + def runFailFastOptionT(a: A)(implicit F: Functor[F]): OptionT[F, E] = runFailFast(a).swap.toOption + } + + /** A no-op validator that returns its input value. + * Useful when you want to "ask for" some extra context, like when transforming an existing validator. + */ + def ask[E, A]: ReturningValidator[E, A, A] = Kleisli(_.asRight) + + /** Ask for a pure value `A` and lift it to an `F` context. + * Useful for mixing pure values in with effectful validation. + */ + def askF[F[_]: Applicative, E, A]: FReturningValidator[F, E, A, A] = + Kleisli(a => EitherT.pure[F, NonEmptyChain[E]](a)) + + /** Ask for some value effectful value `F[A]` and evaluate it, performing no validation. */ + def askEval[F[_]: Functor, E, A]: FReturningValidator[F, E, F[A], A] = + Kleisli(fa => EitherT.liftF[F, NonEmptyChain[E], A](fa)) + + /** Constructs a `ReturningValidator` that returns an output value `B`. */ + def checkReturn[E, A, B](f: A => Either[E, B]): ReturningValidator[E, A, B] = + Kleisli(a => f(a).leftMap(NonEmptyChain.one)) + + /** Constructs a `Validator` for some property. + * + * Example usage: + * ``` + * case class MyCaseClass(int: Int) + * val checkInt: Validator[Boolean, Int] = check { case 5 => false } + * val badCaseClass = MyCaseClass(5) + * + * checkInt + * .local[MyCaseClass](_.int) + * .runFailFast(badCaseClass) + * // Left(false) + * ``` + */ + def check[E, A](pf: PartialFunction[A, E]): Validator[E, A] = + checkReturn(a => pf.lift(a).map(_.asLeft).getOrElse(().asRight)) + + /** Turns the supplied list of validators into a `ShortCircuit[E, A]`. + * Fails fast and returns only the first error. + * To compose with other validators after calling this, use `ShortCircuit#accumulate` to lift to a `Validator`. + * + * Example usage: + * ``` + * case class MyCaseClass(int: Int, string: String) + * + * val checkInt: Validator[Boolean, Int] = check { case 5 => false } + * val checkString: Validator[Boolean, String] = check { case "thing" => false} + * + * val badCaseClass = MyCaseClass(5, "thing") + * + * val checkCaseClass = checkAll(List[Validator[Boolean, MyCaseClass]( + * checkInt.local(_.int), + * checkString.local(_.string) + * )) + * + * checkCaseClass.run(badCaseClass) + * // Left(false) - first error + * ``` + */ + def checkAll[E, A](l: List[Validator[E, A]]): ShortCircuit[E, A] = { + l.map(_.failFast).widen[Kleisli[Either[E, *], A, Unit]].sequence_ + } + + /** Like the `checkAll` that takes a list, except this takes varargs. */ + def checkAll[E, A](vs: Validator[E, A]*): ShortCircuit[E, A] = checkAll(vs.toList) + + /** Turns the supplied list of validators into a parallel set of checks that accumulates results. + * See `checkAll` for usage. + * This will produce a new `Validator` which will return the set of all errors accumulated when ran. + */ + def parCheckAll[E, A](l: List[Validator[E, A]]): Validator[E, A] = { + l.widen[Kleisli[EitherNec[E, *], A, Unit]].parSequence_ + } + + /** Turns the supplied list of validators into a parallel set of checks that accumulates results. + * See `checkAll` for usage. + * This will produce a new `Validator` which will return the set of all errors accumulated when ran. + */ + def parCheckAll[E, A](vs: Validator[E, A]*): Validator[E, A] = parCheckAll(vs.toList) + + /** Like `checkReturn`, but accepts values wrapped in `F[_]`. */ + def checkReturnF[F[_]: Applicative, E, A, B](f: A => F[Either[E, B]]): FReturningValidator[F, E, A, B] = + Kleisli(a => EitherT(f(a)).leftMap(NonEmptyChain.one)) + + /** Like `check`, but accepts values wrapped in `F[_]` */ + def checkEval[F[_]: Applicative, E, A](pf: PartialFunction[A, F[E]]): FValidator[F, E, A] = + checkReturnF[F, E, A, Unit] { a => + val optF = pf.lift(a).sequence + optF.map(o => Either.fromOption(o, ()).swap) + } + + /** Like `checkAll`, but accepts a list of `FValidator` values instead. */ + def checkAllF[F[_]: Monad, E, A](l: List[FValidator[F, E, A]]): FShortCircuit[F, E, A] = + l.map(_.failFast) + .widen[Kleisli[EitherT[F, E, *], A, Unit]] + .sequence_ + + /** Like `checkAllF`, but accepts varargs of `FValidator` values. */ + def checkAllF[F[_]: Monad, E, A](fvs: FValidator[F, E, A]*): FShortCircuit[F, E, A] = + checkAllF(fvs.toList) + + /** Like `parCheckAll`, but accepts varargs of */ + def parCheckAllF[F[_]: Monad, E, A](l: List[FValidator[F, E, A]]): FValidator[F, E, A] = + l.widen[Kleisli[EitherT[F, NonEmptyChain[E], *], A, Unit]].parSequence_ + + /** Like `parCheckAllF`, but accepts varargs of `FValidator` values. */ + def parCheckAllF[F[_]: Monad, E, A](fvs: FValidator[F, E, A]*): FValidator[F, E, A] = + parCheckAllF(fvs.toList) + + /** Extract the value supplied from an `Option[A]` */ + def option[E, A](ifNone: => E): ReturningValidator[E, Option[A], A] = + checkReturn(Either.fromOption(_, ifNone)) + + /** Same as `option[E, A]`, but lifts the value into the `F[_]` context. + * Similar to `askF`, but extracts optional values. + */ + def optionF[F[_]: Applicative, E, A](ifNone: => E): FReturningValidator[F, E, Option[A], A] = + Kleisli(oa => EitherT.fromOption[F](oa, NonEmptyChain.one(ifNone))) + + /** Evaluates an effect `F[Option[A]]` and tries to extract the final `A` value if it exists. + * See `option` for pure values, `optionF` for pure values to lift into `F`. + */ + def optionEval[F[_]: Applicative, E, A](ifNone: => E): FReturningValidator[F, E, F[Option[A]], A] = + Kleisli(foa => EitherT.fromOptionF[F, NonEmptyChain[E], A](foa, NonEmptyChain.one(ifNone))) + + /** Extracts the `Right` side from a supplied `Either` and returns the `Left` as an error. */ + def either[E, A]: ReturningValidator[E, Either[E, A], A] = + checkReturn(identity) + + /** Same as `either`, but lifts the result type into the `F[_]` context. + * Similar to `askF`, but extracts the right side value. + */ + def eitherF[F[_]: Applicative, E, A]: FReturningValidator[F, E, Either[E, A], A] = + Kleisli(ea => EitherT.fromEither[F](ea.leftMap(NonEmptyChain.one))) + + /** Evaluates an effect `F[Either[E, A]]` and tries to extract the final `A` value if it exists. + * See `either` for pure values, `eitherF` for pure values to lift into `F`. + */ + def eitherEval[F[_]: Applicative, E, A]: FReturningValidator[F, E, F[Either[E, A]], A] = + Kleisli(fea => EitherT(fea).leftMap(NonEmptyChain.one)) +} diff --git a/core/src/test/scala/com/rewardsnetwork/combos/CheckerSpec.scala b/core/src/test/scala/com/rewardsnetwork/combos/CheckerSpec.scala new file mode 100644 index 0000000..fa44ca6 --- /dev/null +++ b/core/src/test/scala/com/rewardsnetwork/combos/CheckerSpec.scala @@ -0,0 +1,56 @@ +package com.rewardsnetwork.combos + +import Checks._ +import cats.effect.IO + +class CheckerSpec extends TestingBase { + + test("Checker[E].ask[A] == ask[E, A]") { + forAll { i: Int => + val checker = Checker[Unit] + val normalAsk = syntax.ask[Unit, Int] + val checkerAsk = checker.ask[Int] + + normalAsk.run(i) shouldBe checkerAsk.run(i) + } + } + + test("Checker[E].check[A] == check[E, A]") { + forAll { badInt: Int => + val checker = Checker[Boolean] + val normalCheck = intCheck(badInt) + val checkerCheck = checker.check[Int] { + case i if i == badInt => false + } + + normalCheck.run(badInt) shouldBe checkerCheck.run(badInt) + } + } + + test("FChecker[F, E].ask[A] == askF[F, E, A]") { + forAll { i: Int => + val checker = FChecker[IO, Unit] + val normalAsk = syntax.askF[IO, Unit, Int] + val checkerAsk = checker.ask[Int] + + val normalResults = normalAsk.run(i).value.unsafeRunSync() + val checkerResults = checkerAsk.run(i).value.unsafeRunSync() + + normalResults shouldBe checkerResults + } + } + + test("FChecker[F, E].check[A] == checkF[F, E, A]") { + forAll { badInt: Int => + val checker = FChecker[IO, Boolean] + val normalCheck = intCheckIO(badInt) + val checkerCheck = checker.check[Int] { + case i if i == badInt => IO.pure(false) + } + + val normalResult = normalCheck.run(badInt).value.unsafeRunSync() + val checkerResult = checkerCheck.run(badInt).value.unsafeRunSync() + normalResult shouldBe checkerResult + } + } +} diff --git a/core/src/test/scala/com/rewardsnetwork/combos/Checks.scala b/core/src/test/scala/com/rewardsnetwork/combos/Checks.scala new file mode 100644 index 0000000..bc617b0 --- /dev/null +++ b/core/src/test/scala/com/rewardsnetwork/combos/Checks.scala @@ -0,0 +1,13 @@ +package com.rewardsnetwork.combos + +import cats.effect.IO +import com.rewardsnetwork.combos.syntax._ + +object Checks { + def intCheck(badInt: Int) = check[Boolean, Int] { + case i if i == badInt => false + } + def intCheckIO(badInt: Int) = checkEval[IO, Boolean, Int] { + case i if i == badInt => IO.pure(false) + } +} diff --git a/core/src/test/scala/com/rewardsnetwork/combos/TestingBase.scala b/core/src/test/scala/com/rewardsnetwork/combos/TestingBase.scala new file mode 100644 index 0000000..d86fd73 --- /dev/null +++ b/core/src/test/scala/com/rewardsnetwork/combos/TestingBase.scala @@ -0,0 +1,7 @@ +package com.rewardsnetwork.combos + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatestplus.scalacheck.ScalaCheckPropertyChecks +import org.scalatest.matchers.should.Matchers + +abstract class TestingBase extends AnyFunSuite with Matchers with ScalaCheckPropertyChecks {} diff --git a/core/src/test/scala/com/rewardsnetwork/combos/ValidatorSpec.scala b/core/src/test/scala/com/rewardsnetwork/combos/ValidatorSpec.scala new file mode 100644 index 0000000..22afe2c --- /dev/null +++ b/core/src/test/scala/com/rewardsnetwork/combos/ValidatorSpec.scala @@ -0,0 +1,117 @@ +package com.rewardsnetwork.combos + +import cats.data._ +import cats.effect.IO +import cats.implicits._ +import com.rewardsnetwork.combos.syntax._ +import Checks._ + +class ValidatorSpec extends TestingBase { + + test("ask should return its input") { + forAll { i: Int => + val result = ask[Unit, Int].run(i) + result shouldBe i.rightNec + } + } + + test("askF should return its input wrapped in F") { + forAll { i: Int => + val resultIO = askF[IO, Unit, Int].run(i).value + resultIO.unsafeRunSync() shouldBe i.rightNec + } + } + + test("askEval should evaluate an input effect and return the result") { + forAll { i: Int => + val resultIO = askEval[IO, Unit, Int].run(i.pure[IO]).value + resultIO.unsafeRunSync() shouldBe i.asRight + } + } + + test("check should apply test to input") { + forAll { badInt: Int => + val checkInt = intCheck(badInt) + checkInt.run(badInt) shouldBe false.leftNec + checkInt.run(badInt - 1) shouldBe ().rightNec + } + } + + test("checkEval should apply test to input") { + forAll { badInt: Int => + val checkInt = intCheckIO(badInt) + checkInt.run(badInt).value.unsafeRunSync() shouldBe false.leftNec + checkInt.run(badInt - 1).value.unsafeRunSync() shouldBe ().rightNec + } + } + + test("checkAll should fail fast, parCheckAll should accumulate") { + forAll { badInt: Int => + val checkInt = intCheck(badInt) + val checks = List(checkInt, checkInt) + val checkFast = checkAll(checks) + val checkAccumulating = parCheckAll(checks) + + checkFast.run(badInt) shouldBe false.asLeft + checkAccumulating.run(badInt) shouldBe NonEmptyChain(false, false).asLeft + } + } + + test("checkAllF should fail fast, parCheckAllF should accumulate") { + forAll { badInt: Int => + val checkInt = intCheckIO(badInt) + val checks = List(checkInt, checkInt) + val checkFast = checkAllF(checks) + val checkAccumulating = parCheckAllF(checks) + + checkFast.run(badInt).value.unsafeRunSync() shouldBe false.asLeft + checkAccumulating.run(badInt).value.unsafeRunSync() shouldBe NonEmptyChain(false, false).asLeft + } + } + + test("option should extract the optional value") { + forAll { i: Int => + val getInt = option[Unit, Int](()) + getInt.run(i.some) shouldBe i.asRight + getInt.run(none) shouldBe ().leftNec + } + } + + test("optionF should lift the result to F[A]") { + forAll { i: Int => + val getIntF = optionF[IO, Unit, Int](()) + getIntF.run(i.some).value.unsafeRunSync() shouldBe i.asRight + getIntF.run(none).value.unsafeRunSync() shouldBe ().leftNec + } + } + + test("optionEval should evaluate an input effect and return the result") { + forAll { i: Int => + val resultIO = optionEval[IO, Unit, Int](()).run(i.some.pure[IO]).value + resultIO.unsafeRunSync() shouldBe i.asRight + } + } + + test("either should extract the right-side value") { + forAll { i: Int => + val getInt = either[Unit, Int] + getInt.run(i.asRight) shouldBe i.asRight + getInt.run(().asLeft) shouldBe ().leftNec + } + } + + test("eitherF should lift the right-side result to F[A]") { + forAll { i: Int => + val getIntF = eitherF[IO, Unit, Int] + getIntF.run(i.asRight).value.unsafeRunSync() shouldBe i.asRight + getIntF.run(().asLeft).value.unsafeRunSync() shouldBe ().leftNec + } + } + + test("eitherEval should evaluate an input effect and return the result") { + forAll { i: Int => + val resultIO = eitherEval[IO, Unit, Int].run(i.asRight.pure[IO]).value + resultIO.unsafeRunSync() shouldBe i.asRight + } + } +} diff --git a/project/build.properties b/project/build.properties new file mode 100644 index 0000000..d404814 --- /dev/null +++ b/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.4.6 \ No newline at end of file diff --git a/project/plugins.sbt b/project/plugins.sbt new file mode 100644 index 0000000..33892aa --- /dev/null +++ b/project/plugins.sbt @@ -0,0 +1,4 @@ +addSbtPlugin("io.github.davidgregory084" % "sbt-tpolecat" % "0.1.16") //Adds useful compiler warnings +addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.4.2") //Formats code +addSbtPlugin("com.geirsson" % "sbt-ci-release" % "1.5.5") //Allows releasing from CI +addSbtPlugin("com.codecommit" % "sbt-github-actions" % "0.9.5") //Generates github actions YAML diff --git a/refined/src/main/scala/com/rewardsnetwork/validator/refined/syntax.scala b/refined/src/main/scala/com/rewardsnetwork/validator/refined/syntax.scala new file mode 100644 index 0000000..18e2f81 --- /dev/null +++ b/refined/src/main/scala/com/rewardsnetwork/validator/refined/syntax.scala @@ -0,0 +1,110 @@ +package com.rewardsnetwork.combos.refined + +import cats.data._ +import cats.implicits._ +import com.rewardsnetwork.combos._ +import eu.timepit.refined.refineV +import eu.timepit.refined.api.Validate +import eu.timepit.refined.api.Refined +import cats.Monad + +object syntax { + + implicit class RefineShortCircuitOps[E, A, B](rs: Kleisli[Either[E, *], A, B]) { + + /** Refines the output of this `ShortCircuit` using the predicate `P`. + * Assumes your error is of type `String`. + * To massage the error into a custom error type, use `refineMapLeft` instead. + */ + def refine[P](implicit v: Validate[B, P], ev: String =:= E) = + rs.flatMapF(b => refineV[P](b).leftMap(ev.apply)) + + /** Like `refine`, but allows mapping the error from `Refined` into a custom error `E`. */ + def refineMapLeft[P](f: String => E)(implicit v: Validate[B, P]) = + rs.flatMapF(b => refineV[P](b).leftMap(f)) + + /** Turn this short circuit into a Refined `Validate` instance. + * You must supply a function that describes the error in case of failure, and a `P` value representing your predicate. + * Example: + * ``` + * //Our predicate + * case class OverEighteen() + * + * val checkAge = check[Unit, Int] { + * case i if (i <= 18) => () + * } + * implicit val ageValidate = checkAge.asValidate(i => s"\$i > 18", OverEighteen()) + * + * refineV[OverEighteen](17) //Left("Predicate failed: (17 > 18)") + * ``` + */ + def asValidate[P](showExpr: A => String, p: P): Validate.Plain[A, P] = + Validate.fromPredicate(a => rs.run(a).isRight, showExpr, p) + } + + implicit class RefineValidatorCircuitOps[E, A, B](rv: Kleisli[Either[NonEmptyChain[E], *], A, B]) { + + /** Refines the output of this `Validator` using the predicate `P`. + * Assumes your error is of type `String`. + * To massage the error into a custom error type, use `refineMapLeft` instead. + */ + def refine[P](implicit v: Validate[B, P], ev: String =:= E) = + rv.flatMapF(b => refineV[P](b).leftMap(s => NonEmptyChain.one(ev(s)))) + + /** Like `refine`, but allows mapping the error from `Refined` into a custom error `E`. */ + def refineMapLeft[P](f: String => E)(implicit v: Validate[B, P]) = + rv.flatMapF(b => refineV[P](b).leftMap(s => NonEmptyChain.one(f(s)))) + + /** Turn this validator into a Refined `Validate` instance + * You must supply a function that describes the error in case of failure, and a `P` value representing your predicate. + * Example: + * ``` + * //Our predicate + * case class OverEighteen() + * + * val checkAge = check[Unit, Int] { + * case i if (i <= 18) => () + * } + * implicit val ageValidate = checkAge.asValidate(i => s"\$i > 18", OverEighteen()) + * + * refineV[OverEighteen](17) //Left("Predicate failed: (17 > 18)") + * ``` + */ + def asValidate[P](showExpr: A => String, p: P): Validate.Plain[A, P] = + Validate.fromPredicate(a => rv.run(a).isRight, showExpr, p) + } + + implicit class RefineFShortCircuitCircuitOps[F[_]: Monad, E, A, B](frs: Kleisli[EitherT[F, E, *], A, B]) { + + /** Refines the output of this `FShortCircuit` using the predicate `P`. + * Assumes your error is of type `String`. + * To massage the error into a custom error type, use `refineMapLeft` instead. + */ + def refine[P](implicit v: Validate[B, P], ev: String =:= E) = + frs.flatMapF(b => EitherT.fromEither[F](refineV[P](b).leftMap(ev.apply))) + + /** Like `refine`, but allows mapping the error from `Refined` into a custom error `E`. */ + def refineMapLeft[P](f: String => E)(implicit v: Validate[B, P]) = + frs.flatMapF(b => EitherT.fromEither[F](refineV[P](b).leftMap(f))) + } + + implicit class RefineFValidatorCircuitOps[F[_]: Monad, E, A, B](frv: Kleisli[EitherT[F, NonEmptyChain[E], *], A, B]) { + + /** Refines the output of this `FValidator` using the predicate `P`. + * Assumes your error is of type `String`. + * To massage the error into a custom error type, use `refineMapLeft` instead. + */ + def refine[P](implicit v: Validate[B, P], ev: String =:= E) = + frv.flatMapF(b => EitherT.fromEither[F](refineV[P](b).leftMap(s => NonEmptyChain.one(ev(s))))) + + /** Like `refine`, but allows mapping the error from `Refined` into a custom error `E`. */ + def refineMapLeft[P](f: String => E)(implicit v: Validate[B, P]) = + frv.flatMapF(b => EitherT.fromEither[F](refineV[P](b).leftMap(s => NonEmptyChain.one(f(s))))) + } + + /** Defines a `Validator` that `ask`s for input of type `A` and validates to `P` with `Refined`. + * To refine an existing validator, please use the `.refine` extension method. + */ + def refine[A, P](implicit v: Validate[A, P]): ReturningValidator[String, A, Refined[A, P]] = + Kleisli(a => refineV[P](a).leftMap(NonEmptyChain.one)) +} diff --git a/refined/src/test/scala/com/rewardsnetwork/validator/refined/RefinedSyntaxSpec.scala b/refined/src/test/scala/com/rewardsnetwork/validator/refined/RefinedSyntaxSpec.scala new file mode 100644 index 0000000..4674f12 --- /dev/null +++ b/refined/src/test/scala/com/rewardsnetwork/validator/refined/RefinedSyntaxSpec.scala @@ -0,0 +1,18 @@ +package com.rewardsnetwork.combos.refined + +import cats.implicits._ +import com.rewardsnetwork.combos.TestingBase +import eu.timepit.refined.api.Refined +import eu.timepit.refined.numeric.Positive +import org.scalacheck.Gen +import syntax._ + +class RefinedSyntaxSpec extends TestingBase { + test("refined should refine a value") { + forAll(Gen.posNum[Int]) { i: Int => + val refineInt = refine[Int, Positive] + val result = refineInt.run(i) + result shouldBe Refined.unsafeApply[Int, Positive](i).asRight + } + } +}