tapir
tapir copied to clipboard
[Feature Proposal] Support custom paths and multiple components in custom Validators
The Validator.apply method returns a List[ValidationError[_]], and each ValidationError has a path.
However, these aren't available to Custom validators, which can only return ValidationResult, which have messages but no paths.
For example, consider this (artificial) example:
case class Book(
author: String,
title: String
)
object Book {
implicit val schema: Schema[Book] = Schema
.derived[Book]
.validate(
Validator.nonEmptyString.contramap { _.author }
)
}
case class Bookshelf(
authorLetter: String,
books: Seq[Book],
floor: Int,
building: String
)
object Bookshelf {
implicit val schema: Schema[Bookshelf] = Schema
.derived[Bookshelf]
.validate(
Validator.fixedLength(1).contramap { _.authorLetter }
)
.validate(
Validator.custom(
bookshelf => {
if (bookshelf.books.forall { book => book.author.startsWith(bookshelf.authorLetter) }) {
ValidationResult.Invalid("books.author must start with authorLetter")
} else {
ValidationResult.Valid
}
},
Some("books.author must start with authorLetter")
)
)
}
(I think this style of validation is generally discouraged by Tapir, but when migrating from another framework, it can be very difficult to change the types of existing input classes, so this is very much an unavoidable situation, at least temporarily)
The above validation results in several issues:
- The validation errors do not have paths when executed
- Custom validators cannot, for example, indicate which index in an array caused a validation error
- The validations do not have paths to indicate in generated documentation
contramapresults in the validation being attributed to the wrong field
Proposal
I propose adjusting the Mapped validator to add a path parameter, which automatically calls prependPath on each returned error:
// (draft code is untested)
class Mapped[TT, T](
wrapped: Validator[T],
g: TT => T,
path: Seq[FieldName] = Seq.empty
) extends Validator[TT] {
def prependPath(err: ValidationError[_]): ValidationError[_] = {
path.reverseIterator.foldLeft(err) { _.prependPath(_) }
}
override def apply(t: T): List[ValidationError[_]] = {
wrapped.apply(part(t)).map { this.prependPath(_) }
}
}
Although the primary use of contramap / Mapped in the wild seems to be for opaque types / value-holders, I do also find many examples of using .validate(.... .contramap { _.someField }) in a search on GitHub: https://github.com/search?q=schema+%22contramap%22+%22.validate%28%22+NOT+%22package+sttp%22+NOT+%22def+contramap%22+NOT+%22def+validate%22+language%3AScala&type=code
So this does seem to be a common thing that is tripped over in the real world.
Proposal: Custom validators for iterables / maps
It would also be useful to have a IterableMapped class which prepends each with an index.
// basic proposal. It may be better to be more general to support, for example, Map[K, V] types.
class IterableMapped[T, M <: Iterable[T]](wrapped: Validator[T]) extends Validator[M] {
override def apply(m: M): List[ValidationError[_]] = {
m.zipWithIndex.flatMap { case (element, index) =>
lazy val indexField = FieldName(s"[$index]")
wrapped.apply(element).map { _.prependPath(indexField) }
}
}
}
Workarounds / Alternatives
Workaround: Construct Schemas for each field separately
- Attach the
validatecall to theSchemafor the individual field- Requires the field name be known statically
- Precludes the use of Schema derivation
- Existing codebases may use generic types like
IntorStringwhich cannot have resolve aSchemafor only one field
- Existing codebases may use generic types like
Workaround: Extend existing non-final case classes
It's possible to directly implement the above in the current version of Tapir, by extending from the non-final Mapped / Custom validators. However, this is brittle as they are case classes and don't play well with extension:
/** Like `v.contramap` / `new Mapped(v, ...)`, but prepends path information to each error.
*/
def partValidator[T, P](
part: T => P,
partPath: String = "",
v: Validator[P]
): Validator[T] = {
new Validator.Mapped[T, P](v, part) {
override def apply(t: T): List[ValidationError[?]] = {
val superErrors = super.apply(t)
if (partPath.nonEmpty) {
superErrors.map { _.prependPath(FieldName(partPath)) }
} else {
superErrors
}
}
}
}
def collectionValidator[T, M](wrapped: Validator[T])(elements: M => Seq[(Seq[FieldName], T)]): Validator[M] = {
new Validator.Custom[M](
collection => {
// N.B.: This version loses path information, but is not actually used by the
// apply override below
val errors = elements(collection).flatMap { case (_, element) =>
wrapped.apply(element).map { err =>
err.customMessage.orElse(err.validator.show)
}
}.toList
if (errors.nonEmpty) {
ValidationResult.Invalid(errors.collect { case Some(message) => message })
} else {
ValidationResult.Valid
}
},
wrapped.show
) {
override def apply(collection: M): List[ValidationError[M]] = {
elements(collection).flatMap { case (path, element) =>
wrapped.apply(element).map { err =>
val elementError = prependPath(err, path)
// `Custom`'s override of `apply` forces the `validator` to be `this`,
// but `validator` affects how `customMessage = None` is rendered
elementError.copy[M](
validator = this,
customMessage = elementError.customMessage.orElse(wrapped.show)
)
}
}.toList
}
}
}
private def prependPath(base: ValidationError[_], path: Seq[FieldName]): ValidationError[_] = {
path.reverseIterator.foldLeft(base)(_.prependPath(_))
}
/** Validates each element in a seq, prepending index to the error.
*/
def seqValidator[T, M <: Seq[T]](wrapped: Validator[T]): Validator[M] = {
collectionValidator(wrapped) {
_.zipWithIndex.map { case (element, index) =>
Seq(FieldName(s"[$index]")) -> element
}
}
}
def setValidator[T, M <: Set[T]](wrapped: Validator[T])(show: T => String): Validator[M] = {
collectionValidator(wrapped) {
_.toSeq.map { element =>
Seq(FieldName(s"(${show(element)})")) -> element
}
}
}
def optionValidator[T](wrapped: Validator[T]): Validator[Option[T]] = {
collectionValidator(wrapped) { _.toSeq.map { Seq.empty -> _ } }
}