Skip to content

Commit daa0a8d

Browse files
authored
Merge pull request #405 from strokyl/add_codec_support_for_refined
Add support for refined
2 parents 1909ebd + 68519ec commit daa0a8d

File tree

9 files changed

+175
-3
lines changed

9 files changed

+175
-3
lines changed

build.sbt

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ lazy val rootProject = (project in file("."))
4141
.aggregate(
4242
core,
4343
tapirCats,
44+
tapirRefined,
4445
circeJson,
4546
playJson,
4647
sprayJson,
@@ -100,7 +101,7 @@ lazy val tests: Project = (project in file("tests"))
100101

101102
// cats
102103

103-
lazy val tapirCats: Project = (project in file("cats"))
104+
lazy val tapirCats: Project = (project in file("integration/cats"))
104105
.settings(commonSettings)
105106
.settings(
106107
name := "tapir-cats",
@@ -112,6 +113,17 @@ lazy val tapirCats: Project = (project in file("cats"))
112113
)
113114
.dependsOn(core)
114115

116+
lazy val tapirRefined: Project = (project in file("integration/refined"))
117+
.settings(commonSettings)
118+
.settings(
119+
name := "tapir-refined",
120+
libraryDependencies ++= Seq(
121+
"eu.timepit" %% "refined" % Versions.refined,
122+
scalaTest % "test"
123+
)
124+
)
125+
.dependsOn(core)
126+
115127
// json
116128

117129
lazy val circeJson: Project = (project in file("json/circe"))

doc/endpoint/customtypes.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,7 @@ For example, given following coproduct:
9292
sealed trait Entity{
9393
def kind: String
9494
}
95-
case class Person(firstName:String, lastName:String) extends Entity {
96-
def kind: String = "person"
95+
case class Person(firstName:String, lastName:String) extends Entity { def kind: String = "person"
9796
}
9897
case class Organization(name: String) extends Entity {
9998
def kind: String = "org"
@@ -139,6 +138,18 @@ Non-standard collections can be unwrapped in the modification path by providing
139138
The `tapir-cats` module contains `Schema[_]` instances for some cats datatypes. See the `tapir.codec.cats.TapirCodecCats`
140139
trait or `import sttp.tapir.codec.cats._` to bring the implicit values into scope.
141140

141+
### Schema for refined type
142+
143+
If you use [refined](https://github.com/fthomas/refined), the `tapir-refined` module will provide an implicit codecs for
144+
`T Refined P` as long as a codecs for `T` already exists.
145+
It will add a validator to your already existing codecs and just wrap/unwrap the value from/to its refined equivalent.
146+
Some predicates will bind correctly to the vanilla tapir Validator, while others will bind to a custom validator that
147+
might not be very clear when reading the generated OpenAPI documentation. Correctly bound predicates can be found in
148+
`integration/refined/src/main/scala/sttp/tapir/codec/refined/TapirCodecRefined.scala`.
149+
If you are not satisfied with the validator generated by `tapir-refined`, you can provide an implicit
150+
`RefinedValidatorTranslation[T, P]` in scope using `RefinedValidator.fromPrimitiveValidator' to build it (do not
151+
hesitate to contribute your work).
152+
142153
## Next
143154

144155
Read on about [validation](validation.html).

cats/src/main/scala/sttp/tapir/codec/cats/TapirCodecCats.scala renamed to integration/cats/src/main/scala/sttp/tapir/codec/cats/TapirCodecCats.scala

File renamed without changes.

cats/src/main/scala/sttp/tapir/codec/cats/package.scala renamed to integration/cats/src/main/scala/sttp/tapir/codec/cats/package.scala

File renamed without changes.

cats/src/test/scala/sttp/tapir/codec/cats/TapirCodecCatsTest.scala renamed to integration/cats/src/test/scala/sttp/tapir/codec/cats/TapirCodecCatsTest.scala

File renamed without changes.
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package sttp.tapir.codec.refined
2+
3+
import sttp.tapir._
4+
import eu.timepit.refined.api.{Max, Refined, Validate}
5+
import eu.timepit.refined.collection.NonEmpty
6+
import eu.timepit.refined.refineV
7+
import eu.timepit.refined.string.MatchesRegex
8+
import eu.timepit.refined.numeric.{Greater, GreaterEqual, Less, LessEqual}
9+
import shapeless.Witness
10+
11+
import scala.reflect.ClassTag
12+
13+
trait RefinedValidatorTranslation[V, P] {
14+
def tapirValidator: Validator[V]
15+
def listError(value: V, refinedErrorMessage: String): List[ValidationError[_]]
16+
}
17+
18+
object RefinedValidatorTranslation {
19+
def fromPrimitiveValidator[V, P](validator: Validator.Primitive[V]) = new RefinedValidatorTranslation[V, P] {
20+
override def tapirValidator: Validator[V] = validator
21+
override def listError(value: V, refinedErrorMessage: String): List[ValidationError[_]] = List(ValidationError[V](validator, value))
22+
}
23+
}
24+
25+
trait TapirCodecRefined extends ImplicitGenericRefinedValidator {
26+
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] = {
27+
implicitly[Codec[V, CF, R]]
28+
.validate(refinedValidatorTranslation.tapirValidator) // in reality if this validator has to fail, it will fail before in mapDecode while trying to construct refined type
29+
.mapDecode { v: V =>
30+
refineV[P](v) match {
31+
case Right(refined) => DecodeResult.Value(refined)
32+
case Left(errorMessage) => {
33+
DecodeResult.InvalidValue(refinedValidatorTranslation.listError(v, errorMessage))
34+
}
35+
}
36+
}(_.value)
37+
}
38+
39+
implicit val nonEmptyStringRefinedTranslator: RefinedValidatorTranslation[String, NonEmpty] =
40+
RefinedValidatorTranslation.fromPrimitiveValidator[String, NonEmpty](Validator.minLength(1))
41+
42+
implicit def matchesRegexRefinedTranslator[S <: String](implicit ws: Witness.Aux[S]): RefinedValidatorTranslation[String, MatchesRegex[S]] =
43+
RefinedValidatorTranslation.fromPrimitiveValidator(Validator.pattern(ws.value))
44+
45+
implicit def lessRefinedTranslator[N: Numeric, NM <: N](implicit ws: Witness.Aux[NM]): RefinedValidatorTranslation[N, Less[NM]] =
46+
RefinedValidatorTranslation.fromPrimitiveValidator(Validator.max(ws.value, exclusive = true))
47+
48+
implicit def lessEqualRefinedTranslator[N: Numeric, NM <: N](implicit ws: Witness.Aux[NM]): RefinedValidatorTranslation[N, LessEqual[NM]] =
49+
RefinedValidatorTranslation.fromPrimitiveValidator(Validator.max(ws.value, exclusive = false))
50+
51+
implicit def maxRefinedTranslator[N: Numeric, NM <: N](implicit ws: Witness.Aux[NM]): RefinedValidatorTranslation[N, Greater[NM]] =
52+
RefinedValidatorTranslation.fromPrimitiveValidator(Validator.min(ws.value, exclusive = true))
53+
54+
implicit def maxEqualRefinedTranslator[N: Numeric, NM <: N](implicit ws: Witness.Aux[NM]): RefinedValidatorTranslation[N, GreaterEqual[NM]] =
55+
RefinedValidatorTranslation.fromPrimitiveValidator(Validator.min(ws.value, exclusive = false))
56+
}
57+
58+
trait ImplicitGenericRefinedValidator {
59+
implicit def genericRefinedValidatorTranslation[V, P: ClassTag](implicit refinedValidator: Validate[V, P]): RefinedValidatorTranslation[V, P] = new RefinedValidatorTranslation[V, P] {
60+
override val tapirValidator: Validator.Custom[V] = Validator.Custom(
61+
refinedValidator.isValid(_),
62+
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
63+
64+
override def listError(value: V, refinedErrorMessage: String): List[ValidationError[_]] = List(ValidationError[V](tapirValidator.copy(message = refinedErrorMessage), value))
65+
}
66+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
package sttp.tapir.codec
2+
3+
package object refined extends TapirCodecRefined
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package sttp.tapir.codec.refined
2+
3+
import eu.timepit.refined.api.{Max, Refined}
4+
import eu.timepit.refined.collection.NonEmpty
5+
import eu.timepit.refined.numeric.{Greater, GreaterEqual, Less, LessEqual}
6+
import eu.timepit.refined.string.{IPv4, MatchesRegex}
7+
import eu.timepit.refined.{W, refineMV, refineV}
8+
import eu.timepit.refined.types.string.NonEmptyString
9+
import org.scalatest.{FlatSpec, Matchers}
10+
import sttp.tapir.Codec.PlainCodec
11+
import sttp.tapir.{DecodeResult, ValidationError, Validator}
12+
13+
class TapirCodecRefinedTest extends FlatSpec with Matchers with TapirCodecRefined {
14+
15+
val nonEmptyStringCodec = implicitly[PlainCodec[NonEmptyString]]
16+
17+
18+
"Generated codec" should "return DecodResult.Invalid if subtype can't be refined with correct tapir validator if available" in {
19+
val expectedValidator: Validator[String] = Validator.minLength(1)
20+
nonEmptyStringCodec.decode("") should matchPattern{case DecodeResult.InvalidValue(List(ValidationError(validator, "", _))) if validator == expectedValidator=>}
21+
}
22+
23+
it should "correctly delegate to raw parser and refine it" in {
24+
nonEmptyStringCodec.decode("vive le fromage") shouldBe DecodeResult.Value(refineMV[NonEmpty]("vive le fromage"))
25+
}
26+
27+
it should "return DecodResult.Invalid if subtype can't be refined with derived tapir validator if non tapir validator available" in {
28+
type IPString = String Refined IPv4
29+
val IPStringCodec = implicitly[PlainCodec[IPString]]
30+
31+
val expectedMsg = refineV[IPv4]("192.168.0.1000").left.get
32+
IPStringCodec.decode("192.168.0.1000") should matchPattern{case DecodeResult.InvalidValue(List(ValidationError(Validator.Custom(_, `expectedMsg`), "192.168.0.1000", _)))=>}
33+
}
34+
35+
"Generated codec for MatchesRegex" should "use tapir Validator.Pattern" in {
36+
type VariableConstraint = MatchesRegex[W.`"[a-zA-Z][-a-zA-Z0-9_]*"`.T]
37+
type VariableString = String Refined VariableConstraint
38+
val identifierCodec = implicitly[PlainCodec[VariableString]]
39+
40+
val expectedValidator: Validator[String] = Validator.pattern("[a-zA-Z][-a-zA-Z0-9_]*")
41+
identifierCodec.decode("-bad") should matchPattern{case DecodeResult.InvalidValue(List(ValidationError(validator, "-bad", _))) if validator == expectedValidator=>}
42+
}
43+
44+
"Generated codec for Less" should "use tapir Validator.drMax" in {
45+
type IntConstraint = Less[W.`3`.T]
46+
type LimitedInt = Int Refined IntConstraint
47+
val limitedIntCodec = implicitly[PlainCodec[LimitedInt]]
48+
49+
val expectedValidator: Validator[Int] = Validator.max(3, exclusive = true)
50+
limitedIntCodec.decode("3") should matchPattern{case DecodeResult.InvalidValue(List(ValidationError(validator, 3, _))) if validator == expectedValidator=>}
51+
}
52+
53+
"Generated codec for LessEqual" should "use tapir Validator.drMax" in {
54+
type IntConstraint = LessEqual[W.`3`.T]
55+
type LimitedInt = Int Refined IntConstraint
56+
val limitedIntCodec = implicitly[PlainCodec[LimitedInt]]
57+
58+
val expectedValidator: Validator[Int] = Validator.max(3, exclusive = false)
59+
limitedIntCodec.decode("4") should matchPattern{case DecodeResult.InvalidValue(List(ValidationError(validator, 4, _))) if validator == expectedValidator=>}
60+
}
61+
62+
"Generated codec for Max" should "use tapir Validator.drMax" in {
63+
type IntConstraint = Greater[W.`3`.T]
64+
type LimitedInt = Int Refined IntConstraint
65+
val limitedIntCodec = implicitly[PlainCodec[LimitedInt]]
66+
67+
val expectedValidator: Validator[Int] = Validator.min(3, exclusive = true)
68+
limitedIntCodec.decode("3") should matchPattern{case DecodeResult.InvalidValue(List(ValidationError(validator, 3, _))) if validator == expectedValidator=>}
69+
}
70+
71+
"Generated codec for MaxEqual" should "use tapir Validator.drMax" in {
72+
type IntConstraint = GreaterEqual[W.`3`.T]
73+
type LimitedInt = Int Refined IntConstraint
74+
val limitedIntCodec = implicitly[PlainCodec[LimitedInt]]
75+
76+
val expectedValidator: Validator[Int] = Validator.min(3, exclusive = false)
77+
limitedIntCodec.decode("2") should matchPattern{case DecodeResult.InvalidValue(List(ValidationError(validator, 2, _))) if validator == expectedValidator=>}
78+
}
79+
}

project/Versions.scala

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,5 @@ object Versions {
1515
val sprayJson = "1.3.5"
1616
val scalaCheck = "1.14.1"
1717
val scalaTest = "3.0.8"
18+
val refined = "0.9.12"
1819
}

0 commit comments

Comments
 (0)