softwaremill / softwaremill/tapir
[Feature Proposal] Support custom paths and multiple components in custom Validators
Nobody has claimed this yet.
- Dominant language
- Scala
- Stars
- 1.5k
- Forks
- 468
- Avg merge
- 5h 37m
- Merged PRs (30d)
- 34
Description
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 -> _ } }
}
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with Validator.Mapped, Validator.Custom, and the Validator.apply/ValidationError path behavior described in the issue. Compare the proposed mapped and iterable cases with existing schema derivation and contramap usage. Done means the supported custom-validator API preserves field and collection paths in runtime errors and generated documentation, with behavior agreed by maintainers.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- scala
- Domain
- backend-api-design
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Stale
- Clarity
- Mostly clear
- Newbie friendliness
- 30/100