Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for refined #405

Merged
merged 5 commits into from
Feb 6, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ lazy val rootProject = (project in file("."))
.aggregate(
core,
tapirCats,
tapirRefined,
circeJson,
playJson,
sprayJson,
Expand Down Expand Up @@ -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",
Expand All @@ -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"))
Expand Down
15 changes: 13 additions & 2 deletions doc/endpoint/customtypes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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).
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 I like the last line :)


## Next

Read on about [validation](validation.html).
Original file line number Diff line number Diff line change
@@ -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))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package sttp.tapir.codec

package object refined extends TapirCodecRefined
Original file line number Diff line number Diff line change
@@ -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=>}
}
}
1 change: 1 addition & 0 deletions project/Versions.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}