diff --git a/build.sbt b/build.sbt index e973cf8bdf..7c1af20cbd 100644 --- a/build.sbt +++ b/build.sbt @@ -41,6 +41,7 @@ lazy val rootProject = (project in file(".")) .aggregate( core, tapirCats, + tapirRefined, circeJson, playJson, sprayJson, @@ -100,7 +101,7 @@ lazy val tests: Project = (project in file("tests")) // cats -lazy val tapirCats: Project = (project in file("cats")) +lazy val tapirCats: Project = (project in file("integration/cats")) .settings(commonSettings) .settings( name := "tapir-cats", @@ -112,6 +113,17 @@ lazy val tapirCats: Project = (project in file("cats")) ) .dependsOn(core) +lazy val tapirRefined: Project = (project in file("integration/refined")) + .settings(commonSettings) + .settings( + name := "tapir-refined", + libraryDependencies ++= Seq( + "eu.timepit" %% "refined" % Versions.refined, + scalaTest % "test" + ) + ) + .dependsOn(core) + // json lazy val circeJson: Project = (project in file("json/circe")) diff --git a/doc/endpoint/customtypes.md b/doc/endpoint/customtypes.md index cd63b619af..049c81b79d 100644 --- a/doc/endpoint/customtypes.md +++ b/doc/endpoint/customtypes.md @@ -92,8 +92,7 @@ For example, given following coproduct: sealed trait Entity{ def kind: String } -case class Person(firstName:String, lastName:String) extends Entity { - def kind: String = "person" +case class Person(firstName:String, lastName:String) extends Entity { def kind: String = "person" } case class Organization(name: String) extends Entity { def kind: String = "org" @@ -139,6 +138,18 @@ Non-standard collections can be unwrapped in the modification path by providing The `tapir-cats` module contains `Schema[_]` instances for some cats datatypes. See the `tapir.codec.cats.TapirCodecCats` trait or `import sttp.tapir.codec.cats._` to bring the implicit values into scope. +### Schema for refined type + +If you use [refined](https://github.com/fthomas/refined), the `tapir-refined` module will provide an implicit codecs for +`T Refined P` as long as a codecs for `T` already exists. +It will add a validator to your already existing codecs and just wrap/unwrap the value from/to its refined equivalent. +Some predicates will bind correctly to the vanilla tapir Validator, while others will bind to a custom validator that +might not be very clear when reading the generated OpenAPI documentation. Correctly bound predicates can be found in +`integration/refined/src/main/scala/sttp/tapir/codec/refined/TapirCodecRefined.scala`. +If you are not satisfied with the validator generated by `tapir-refined`, you can provide an implicit +`RefinedValidatorTranslation[T, P]` in scope using `RefinedValidator.fromPrimitiveValidator' to build it (do not +hesitate to contribute your work). + ## Next Read on about [validation](validation.html). diff --git a/cats/src/main/scala/sttp/tapir/codec/cats/TapirCodecCats.scala b/integration/cats/src/main/scala/sttp/tapir/codec/cats/TapirCodecCats.scala similarity index 100% rename from cats/src/main/scala/sttp/tapir/codec/cats/TapirCodecCats.scala rename to integration/cats/src/main/scala/sttp/tapir/codec/cats/TapirCodecCats.scala diff --git a/cats/src/main/scala/sttp/tapir/codec/cats/package.scala b/integration/cats/src/main/scala/sttp/tapir/codec/cats/package.scala similarity index 100% rename from cats/src/main/scala/sttp/tapir/codec/cats/package.scala rename to integration/cats/src/main/scala/sttp/tapir/codec/cats/package.scala diff --git a/cats/src/test/scala/sttp/tapir/codec/cats/TapirCodecCatsTest.scala b/integration/cats/src/test/scala/sttp/tapir/codec/cats/TapirCodecCatsTest.scala similarity index 100% rename from cats/src/test/scala/sttp/tapir/codec/cats/TapirCodecCatsTest.scala rename to integration/cats/src/test/scala/sttp/tapir/codec/cats/TapirCodecCatsTest.scala diff --git a/integration/refined/src/main/scala/sttp/tapir/codec/refined/TapirCodecRefined.scala b/integration/refined/src/main/scala/sttp/tapir/codec/refined/TapirCodecRefined.scala new file mode 100644 index 0000000000..8f106cae2d --- /dev/null +++ b/integration/refined/src/main/scala/sttp/tapir/codec/refined/TapirCodecRefined.scala @@ -0,0 +1,66 @@ +package sttp.tapir.codec.refined + +import sttp.tapir._ +import eu.timepit.refined.api.{Max, Refined, Validate} +import eu.timepit.refined.collection.NonEmpty +import eu.timepit.refined.refineV +import eu.timepit.refined.string.MatchesRegex +import eu.timepit.refined.numeric.{Greater, GreaterEqual, Less, LessEqual} +import shapeless.Witness + +import scala.reflect.ClassTag + +trait RefinedValidatorTranslation[V, P] { + def tapirValidator: Validator[V] + def listError(value: V, refinedErrorMessage: String): List[ValidationError[_]] +} + +object RefinedValidatorTranslation { + def fromPrimitiveValidator[V, P](validator: Validator.Primitive[V]) = new RefinedValidatorTranslation[V, P] { + override def tapirValidator: Validator[V] = validator + override def listError(value: V, refinedErrorMessage: String): List[ValidationError[_]] = List(ValidationError[V](validator, value)) + } +} + +trait TapirCodecRefined extends ImplicitGenericRefinedValidator { + implicit def codecForRefined[V, P, CF <: CodecFormat, R](implicit tm: Codec[V, CF, R], refinedValidator: Validate[V, P], refinedValidatorTranslation: RefinedValidatorTranslation[V, P]): Codec[V Refined P, CF, R] = { + implicitly[Codec[V, CF, R]] + .validate(refinedValidatorTranslation.tapirValidator) // in reality if this validator has to fail, it will fail before in mapDecode while trying to construct refined type + .mapDecode { v: V => + refineV[P](v) match { + case Right(refined) => DecodeResult.Value(refined) + case Left(errorMessage) => { + DecodeResult.InvalidValue(refinedValidatorTranslation.listError(v, errorMessage)) + } + } + }(_.value) + } + + implicit val nonEmptyStringRefinedTranslator: RefinedValidatorTranslation[String, NonEmpty] = + RefinedValidatorTranslation.fromPrimitiveValidator[String, NonEmpty](Validator.minLength(1)) + + implicit def matchesRegexRefinedTranslator[S <: String](implicit ws: Witness.Aux[S]): RefinedValidatorTranslation[String, MatchesRegex[S]] = + RefinedValidatorTranslation.fromPrimitiveValidator(Validator.pattern(ws.value)) + + implicit def lessRefinedTranslator[N: Numeric, NM <: N](implicit ws: Witness.Aux[NM]): RefinedValidatorTranslation[N, Less[NM]] = + RefinedValidatorTranslation.fromPrimitiveValidator(Validator.max(ws.value, exclusive = true)) + + implicit def lessEqualRefinedTranslator[N: Numeric, NM <: N](implicit ws: Witness.Aux[NM]): RefinedValidatorTranslation[N, LessEqual[NM]] = + RefinedValidatorTranslation.fromPrimitiveValidator(Validator.max(ws.value, exclusive = false)) + + implicit def maxRefinedTranslator[N: Numeric, NM <: N](implicit ws: Witness.Aux[NM]): RefinedValidatorTranslation[N, Greater[NM]] = + RefinedValidatorTranslation.fromPrimitiveValidator(Validator.min(ws.value, exclusive = true)) + + implicit def maxEqualRefinedTranslator[N: Numeric, NM <: N](implicit ws: Witness.Aux[NM]): RefinedValidatorTranslation[N, GreaterEqual[NM]] = + RefinedValidatorTranslation.fromPrimitiveValidator(Validator.min(ws.value, exclusive = false)) +} + +trait ImplicitGenericRefinedValidator { + implicit def genericRefinedValidatorTranslation[V, P: ClassTag](implicit refinedValidator: Validate[V, P]): RefinedValidatorTranslation[V, P] = new RefinedValidatorTranslation[V, P] { + override val tapirValidator: Validator.Custom[V] = Validator.Custom( + refinedValidator.isValid(_), + implicitly[ClassTag[P]].runtimeClass.toString) //for the moment there is no way to get a human description of a predicate/validator without having a concrete value to run it + + override def listError(value: V, refinedErrorMessage: String): List[ValidationError[_]] = List(ValidationError[V](tapirValidator.copy(message = refinedErrorMessage), value)) + } +} diff --git a/integration/refined/src/main/scala/sttp/tapir/codec/refined/package.scala b/integration/refined/src/main/scala/sttp/tapir/codec/refined/package.scala new file mode 100644 index 0000000000..c41822793a --- /dev/null +++ b/integration/refined/src/main/scala/sttp/tapir/codec/refined/package.scala @@ -0,0 +1,3 @@ +package sttp.tapir.codec + +package object refined extends TapirCodecRefined diff --git a/integration/refined/src/test/scala/sttp/tapir/codec/refined/TapirCodecRefinedTest.scala b/integration/refined/src/test/scala/sttp/tapir/codec/refined/TapirCodecRefinedTest.scala new file mode 100644 index 0000000000..3222175c6e --- /dev/null +++ b/integration/refined/src/test/scala/sttp/tapir/codec/refined/TapirCodecRefinedTest.scala @@ -0,0 +1,79 @@ +package sttp.tapir.codec.refined + +import eu.timepit.refined.api.{Max, Refined} +import eu.timepit.refined.collection.NonEmpty +import eu.timepit.refined.numeric.{Greater, GreaterEqual, Less, LessEqual} +import eu.timepit.refined.string.{IPv4, MatchesRegex} +import eu.timepit.refined.{W, refineMV, refineV} +import eu.timepit.refined.types.string.NonEmptyString +import org.scalatest.{FlatSpec, Matchers} +import sttp.tapir.Codec.PlainCodec +import sttp.tapir.{DecodeResult, ValidationError, Validator} + +class TapirCodecRefinedTest extends FlatSpec with Matchers with TapirCodecRefined { + + val nonEmptyStringCodec = implicitly[PlainCodec[NonEmptyString]] + + + "Generated codec" should "return DecodResult.Invalid if subtype can't be refined with correct tapir validator if available" in { + val expectedValidator: Validator[String] = Validator.minLength(1) + nonEmptyStringCodec.decode("") should matchPattern{case DecodeResult.InvalidValue(List(ValidationError(validator, "", _))) if validator == expectedValidator=>} + } + + it should "correctly delegate to raw parser and refine it" in { + nonEmptyStringCodec.decode("vive le fromage") shouldBe DecodeResult.Value(refineMV[NonEmpty]("vive le fromage")) + } + + it should "return DecodResult.Invalid if subtype can't be refined with derived tapir validator if non tapir validator available" in { + type IPString = String Refined IPv4 + val IPStringCodec = implicitly[PlainCodec[IPString]] + + val expectedMsg = refineV[IPv4]("192.168.0.1000").left.get + IPStringCodec.decode("192.168.0.1000") should matchPattern{case DecodeResult.InvalidValue(List(ValidationError(Validator.Custom(_, `expectedMsg`), "192.168.0.1000", _)))=>} + } + + "Generated codec for MatchesRegex" should "use tapir Validator.Pattern" in { + type VariableConstraint = MatchesRegex[W.`"[a-zA-Z][-a-zA-Z0-9_]*"`.T] + type VariableString = String Refined VariableConstraint + val identifierCodec = implicitly[PlainCodec[VariableString]] + + val expectedValidator: Validator[String] = Validator.pattern("[a-zA-Z][-a-zA-Z0-9_]*") + identifierCodec.decode("-bad") should matchPattern{case DecodeResult.InvalidValue(List(ValidationError(validator, "-bad", _))) if validator == expectedValidator=>} + } + + "Generated codec for Less" should "use tapir Validator.drMax" in { + type IntConstraint = Less[W.`3`.T] + type LimitedInt = Int Refined IntConstraint + val limitedIntCodec = implicitly[PlainCodec[LimitedInt]] + + val expectedValidator: Validator[Int] = Validator.max(3, exclusive = true) + limitedIntCodec.decode("3") should matchPattern{case DecodeResult.InvalidValue(List(ValidationError(validator, 3, _))) if validator == expectedValidator=>} + } + + "Generated codec for LessEqual" should "use tapir Validator.drMax" in { + type IntConstraint = LessEqual[W.`3`.T] + type LimitedInt = Int Refined IntConstraint + val limitedIntCodec = implicitly[PlainCodec[LimitedInt]] + + val expectedValidator: Validator[Int] = Validator.max(3, exclusive = false) + limitedIntCodec.decode("4") should matchPattern{case DecodeResult.InvalidValue(List(ValidationError(validator, 4, _))) if validator == expectedValidator=>} + } + + "Generated codec for Max" should "use tapir Validator.drMax" in { + type IntConstraint = Greater[W.`3`.T] + type LimitedInt = Int Refined IntConstraint + val limitedIntCodec = implicitly[PlainCodec[LimitedInt]] + + val expectedValidator: Validator[Int] = Validator.min(3, exclusive = true) + limitedIntCodec.decode("3") should matchPattern{case DecodeResult.InvalidValue(List(ValidationError(validator, 3, _))) if validator == expectedValidator=>} + } + + "Generated codec for MaxEqual" should "use tapir Validator.drMax" in { + type IntConstraint = GreaterEqual[W.`3`.T] + type LimitedInt = Int Refined IntConstraint + val limitedIntCodec = implicitly[PlainCodec[LimitedInt]] + + val expectedValidator: Validator[Int] = Validator.min(3, exclusive = false) + limitedIntCodec.decode("2") should matchPattern{case DecodeResult.InvalidValue(List(ValidationError(validator, 2, _))) if validator == expectedValidator=>} + } +} diff --git a/project/Versions.scala b/project/Versions.scala index ef310aa1f2..ee35534516 100644 --- a/project/Versions.scala +++ b/project/Versions.scala @@ -15,4 +15,5 @@ object Versions { val sprayJson = "1.3.5" val scalaCheck = "1.14.1" val scalaTest = "3.0.8" + val refined = "0.9.12" }