Showing 28 of 83 files from the diff.
Other files ignored by Codecov
build.sbt has changed.
CHANGELOG.rst has changed.

@@ -10,15 +10,18 @@
Loading
10 10
11 11
private[validation] object CountryCodeConstraintValidator {
12 12
13 +
  /** @see [[https://www.iso.org/iso-3166-country-codes.html ISO 3166]] */
14 +
  val CountryCodes: Set[String] = Locale.getISOCountries.toSet
15 +
13 16
  def errorMessage(resolver: MessageResolver, value: Any): String =
14 -
    resolver.resolve(classOf[CountryCode], toErrorValue(value))
17 +
    resolver.resolve[CountryCode](toErrorValue(value))
15 18
16 19
  private def toErrorValue(value: Any): String =
17 20
    value match {
18 21
      case arrayValue: Array[_] =>
19 -
        arrayValue mkString (",")
20 -
      case traversableValue: Traversable[_] =>
21 -
        traversableValue mkString (",")
22 +
        arrayValue.mkString(",")
23 +
      case traversableValue: Iterable[_] =>
24 +
        traversableValue.mkString(",")
22 25
      case anyValue =>
23 26
        anyValue.toString
24 27
    }
@@ -33,35 +36,33 @@
Loading
33 36
 */
34 37
private[validation] class CountryCodeConstraintValidator(messageResolver: MessageResolver)
35 38
    extends ConstraintValidator[CountryCode, Any](messageResolver) {
36 -
37 -
  private val countryCodes = Locale.getISOCountries.toSet
39 +
  import CountryCodeConstraintValidator._
38 40
39 41
  /* Public */
40 42
  override def isValid(annotation: CountryCode, value: Any): ValidationResult =
41 43
    value match {
42 44
      case typedValue: Array[Any] =>
43 45
        validationResult(typedValue)
44 -
      case typedValue: Traversable[Any] =>
46 +
      case typedValue: Iterable[Any] =>
45 47
        validationResult(typedValue)
46 48
      case anyValue =>
47 49
        validationResult(Seq(anyValue.toString))
48 50
    }
49 51
50 -
  private[this] def validationResult(value: Traversable[Any]) = {
52 +
  private[this] def validationResult(value: Iterable[Any]) = {
51 53
    val invalidCountryCodes = findInvalidCountryCodes(value)
52 54
    ValidationResult.validate(
53 55
      invalidCountryCodes.isEmpty,
54 -
      CountryCodeConstraintValidator.errorMessage(messageResolver, value),
56 +
      errorMessage(messageResolver, value),
55 57
      ErrorCode.InvalidCountryCodes(invalidCountryCodes)
56 58
    )
57 59
  }
58 60
59 61
  /* Private */
60 62
  private[this] def findInvalidCountryCodes(values: Traversable[Any]): Set[String] = {
61 -
    val uppercaseCountryCodes = values.toSet map { value: Any =>
63 +
    val uppercaseCountryCodes = values.toSet.map { value: Any =>
62 64
      value.toString.toUpperCase
63 65
    }
64 -
65 -
    uppercaseCountryCodes.diff(countryCodes)
66 +
    uppercaseCountryCodes.diff(CountryCodes)
66 67
  }
67 68
}

@@ -6,7 +6,7 @@
Loading
6 6
7 7
object ValidatorModule extends ValidatorModule {
8 8
  // java-friendly access to singleton
9 -
  def get(): TwitterModule = this
9 +
  def get(): this.type = this
10 10
}
11 11
12 12
/**

@@ -10,15 +10,17 @@
Loading
10 10
private[validation] object NotEmptyConstraintValidator {
11 11
12 12
  def errorMessage(resolver: MessageResolver): String =
13 -
    resolver.resolve(classOf[NotEmpty])
13 +
    resolver.resolve[NotEmpty]()
14 14
}
15 15
16 16
/**
17 -
 * The validator for [[NotEmpty]] annotation.
18 -
 *
19 -
 * Validate if a given value is not empty.
17 +
 * The validator for [[NotEmpty]] annotation. Validate if a given value is not empty.
20 18
 *
21 19
 * @param messageResolver to resolve error message when validation fails.
20 +
 * @note this does not guarantee that items within a "not empty" container are themselves
21 +
 *       not null or empty. E.g., a `Seq[String]` may not be empty but some of the contained
22 +
 *       Strings may be the empty string or even null. Likewise, a "not empty" `Option`,
23 +
 *       could be a `Some(null)`, etc.
22 24
 */
23 25
private[validation] class NotEmptyConstraintValidator(messageResolver: MessageResolver)
24 26
    extends ConstraintValidator[NotEmpty, Any](messageResolver) {
@@ -31,18 +33,20 @@
Loading
31 33
    value match {
32 34
      case arrayValue: Array[_] =>
33 35
        validationResult(arrayValue)
34 -
      case traversableValue: Traversable[_] =>
36 +
      case map: Map[_, _] =>
37 +
        validationResult(map)
38 +
      case traversableValue: Iterable[_] =>
35 39
        validationResult(traversableValue)
36 40
      case stringValue: String =>
37 41
        validationResult(stringValue)
38 42
      case _ =>
39 43
        throw new IllegalArgumentException(
40 -
          s"Class [${value.getClass}}] is not supported by ${this.getClass}")
44 +
          s"Class [${value.getClass.getName}] is not supported by ${this.getClass.getName}")
41 45
    }
42 46
43 47
  /* Private */
44 48
45 -
  private[this] def validationResult(value: Traversable[_]) =
49 +
  private[this] def validationResult(value: Iterable[_]) =
46 50
    ValidationResult.validate(
47 51
      value.nonEmpty,
48 52
      errorMessage(messageResolver),

@@ -1,17 +1,41 @@
Loading
1 1
package com.twitter.finatra.validation
2 2
3 +
import scala.util.control.NoStackTrace
4 +
3 5
/**
4 6
 * Used to signal validation errors during case class validations.
5 7
 *
8 +
 * @param includeFieldNames If field names should be included in the carried per-field details
9 +
 *                          message field. In the stand alone (post-construction) Validation
10 +
 *                          Framework API case, this will be true to provide fidelity in error
11 +
 *                          reporting. In the Jackson CaseClassDeserializer case this will be
12 +
 *                          false as this information is carried in the deserializer and does not
13 +
 *                          need to be repeated in the message field.
6 14
 * @param results Per-field details (of type [[ValidationResult]]) are carried to
7 -
 *                       provide the ability to iterate over all invalid results from validating a
8 -
 *                       case class field or a case class method.
15 +
 *                provide the ability to iterate over all invalid results from validating a
16 +
 *                case class field or a case class method.
9 17
 */
10 -
class ValidationException private[validation] (results: Seq[ValidationResult]) extends Exception {
18 +
class ValidationException private[validation] (
19 +
  includeFieldNames: Boolean,
20 +
  results: Set[ValidationResult])
21 +
    extends Exception
22 +
    with NoStackTrace {
23 +
24 +
  private[validation] def this(results: Set[ValidationResult]) {
25 +
    this(false, results)
26 +
  }
11 27
12 28
  /** All errors encountered during case class validations of each annotation */
13 -
  val errors: Seq[ValidationResult.Invalid] =
14 -
    results.asInstanceOf[Seq[ValidationResult.Invalid]].sortBy(_.message)
29 +
  val errors: Seq[ValidationResult.Invalid] = {
30 +
    if (includeFieldNames) {
31 +
      results
32 +
        .asInstanceOf[Set[ValidationResult.Invalid]].toSeq
33 +
        .map(e => e.copy(message = s"${e.path.toString}: ${e.message}"))
34 +
        .sortBy(_.message)
35 +
    } else {
36 +
      results.asInstanceOf[Set[ValidationResult.Invalid]].toSeq.sortBy(_.message)
37 +
    }
38 +
  }
15 39
16 40
  override def getMessage: String = {
17 41
    "\nValidation Errors:\t\t" + errors.map(_.message).mkString(", ") + "\n\n"

@@ -13,7 +13,17 @@
Loading
13 13
private[validation] object PatternConstraintValidator {
14 14
15 15
  def errorMessage(resolver: MessageResolver, value: Any, regex: String): String =
16 -
    resolver.resolve(classOf[Pattern], value, regex)
16 +
    resolver.resolve[Pattern](toErrorValue(value), regex)
17 +
18 +
  private def toErrorValue(value: Any): String =
19 +
    value match {
20 +
      case arrayValue: Array[_] =>
21 +
        arrayValue.mkString(",")
22 +
      case traversableValue: Iterable[_] =>
23 +
        traversableValue.mkString(",")
24 +
      case anyValue =>
25 +
        anyValue.toString
26 +
    }
17 27
}
18 28
19 29
/**
@@ -21,8 +31,9 @@
Loading
21 31
 *
22 32
 * Validates whether given [[CharSequence]] value matches with the specified regular expression.
23 33
 *
24 -
 * @example {{{
25 -
 *            case class ExampleRequest(@Pattern(regexp= "exampleRegex") exampleValue : String)
34 +
 * @example
35 +
 * {{{
36 +
 *   case class ExampleRequest(@Pattern(regexp= "exampleRegex") exampleValue : String)
26 37
 * }}}
27 38
 */
28 39
private[validation] class PatternConstraintValidator(messageResolver: MessageResolver)
@@ -40,13 +51,13 @@
Loading
40 51
      value match {
41 52
        case arrayValue: Array[_] =>
42 53
          validationResult(arrayValue, regexp, regex)
43 -
        case traversableValue: Traversable[_] =>
54 +
        case traversableValue: Iterable[_] =>
44 55
          validationResult(traversableValue, regexp, regex)
45 56
        case stringValue: String =>
46 57
          validationResult(stringValue, regexp, regex)
47 58
        case _ =>
48 59
          throw new IllegalArgumentException(
49 -
            s"Class [${value.getClass}}] is not supported by ${this.getClass}")
60 +
            s"Class [${value.getClass.getName}] is not supported by ${this.getClass.getName}")
50 61
      }
51 62
    } else validateRegexResult
52 63
  }
@@ -54,17 +65,17 @@
Loading
54 65
  /* Private */
55 66
56 67
  private[this] def validationResult(
57 -
    value: Traversable[_],
68 +
    value: Iterable[_],
58 69
    regexp: String,
59 70
    regex: Try[Regex]
60 71
  ): ValidationResult =
61 72
    ValidationResult.validate(
62 -
      value.forall(x => validateValue(x.toString, regex)),
63 -
      errorMessage(messageResolver, value, regexp),
73 +
      value.isEmpty || value.forall(x => validateValue(x.toString, regex)),
74 +
      errorMessage(messageResolver, value.mkString(","), regexp),
64 75
      errorCode(value, regexp)
65 76
    )
66 77
67 -
  private[this] def errorCode(value: Traversable[_], regex: String): ErrorCode =
78 +
  private[this] def errorCode(value: Iterable[_], regex: String): ErrorCode =
68 79
    ErrorCode.PatternNotMatched(value.mkString(","), regex)
69 80
70 81
  private[this] def validationResult(

@@ -1,7 +1,6 @@
Loading
1 1
package com.twitter.finatra.validation
2 2
3 -
case class InvalidCaseClassException(clazz: Class[_])
4 -
    extends ValidationException(Seq.empty[ValidationResult.Invalid]) {
3 +
case class InvalidCaseClassException(clazz: Class[_]) extends ValidationException(Set.empty) {
5 4
6 5
  override def getMessage: String = s"Class [$clazz] is not a valid case class."
7 6
}

@@ -0,0 +1,26 @@
Loading
1 +
package com.twitter.finatra.validation.constraints
2 +
3 +
import com.twitter.finatra.validation.{
4 +
  ConstraintValidator,
5 +
  ErrorCode,
6 +
  MessageResolver,
7 +
  ValidationResult
8 +
}
9 +
10 +
private[validation] object AssertTrueConstraintValidator {
11 +
  def errorMessage(resolver: MessageResolver, value: Boolean): String =
12 +
    resolver.resolve[AssertTrue](value)
13 +
}
14 +
15 +
private[validation] class AssertTrueConstraintValidator(messageResolver: MessageResolver)
16 +
    extends ConstraintValidator[AssertTrue, Boolean](messageResolver) {
17 +
18 +
  override def isValid(
19 +
    annotation: AssertTrue,
20 +
    value: Boolean
21 +
  ): ValidationResult = ValidationResult.validate(
22 +
    value,
23 +
    AssertTrueConstraintValidator.errorMessage(messageResolver, value),
24 +
    ErrorCode.InvalidBooleanValue(value)
25 +
  )
26 +
}

@@ -11,7 +11,7 @@
Loading
11 11
private[validation] object FutureTimeConstraintValidator {
12 12
13 13
  def errorMessage(resolver: MessageResolver, value: DateTime): String =
14 -
    resolver.resolve(classOf[FutureTime], value)
14 +
    resolver.resolve[FutureTime](value)
15 15
}
16 16
17 17
/**

@@ -10,8 +10,7 @@
Loading
10 10
private[validation] object OneOfConstraintValidator {
11 11
12 12
  def errorMessage(resolver: MessageResolver, oneOfValues: Set[String], value: Any): String =
13 -
    resolver.resolve(
14 -
      classOf[OneOf],
13 +
    resolver.resolve[OneOf](
15 14
      toCommaSeparatedValue(value),
16 15
      toCommaSeparatedValue(oneOfValues)
17 16
    )
@@ -19,9 +18,9 @@
Loading
19 18
  private def toCommaSeparatedValue(value: Any): String =
20 19
    value match {
21 20
      case arrayValue: Array[_] =>
22 -
        arrayValue mkString (",")
21 +
        arrayValue.mkString(",")
23 22
      case traversableValue: Traversable[_] =>
24 -
        traversableValue mkString (",")
23 +
        traversableValue.mkString(",")
25 24
      case anyValue =>
26 25
        anyValue.toString
27 26
    }
@@ -45,7 +44,7 @@
Loading
45 44
    value match {
46 45
      case arrayValue: Array[_] =>
47 46
        validationResult(arrayValue, oneOfValues)
48 -
      case traversableValue: Traversable[_] =>
47 +
      case traversableValue: Iterable[_] =>
49 48
        validationResult(traversableValue, oneOfValues)
50 49
      case anyValue =>
51 50
        validationResult(Seq(anyValue.toString), oneOfValues)
@@ -55,7 +54,7 @@
Loading
55 54
  /* Private */
56 55
57 56
  private[this] def validationResult(
58 -
    value: Traversable[_],
57 +
    value: Iterable[_],
59 58
    oneOfValues: Set[String]
60 59
  ): ValidationResult = {
61 60
    val invalidValues = findInvalidValues(value, oneOfValues)
@@ -67,10 +66,10 @@
Loading
67 66
  }
68 67
69 68
  private[this] def findInvalidValues(
70 -
    value: Traversable[_],
69 +
    value: Iterable[_],
71 70
    oneOfValues: Set[String]
72 71
  ): Set[String] = {
73 72
    val valueAsStrings = value.map(_.toString).toSet
74 -
    valueAsStrings diff oneOfValues
73 +
    valueAsStrings.diff(oneOfValues)
75 74
  }
76 75
}

@@ -10,7 +10,7 @@
Loading
10 10
private[validation] object MaxConstraintValidator {
11 11
12 12
  def errorMessage(resolver: MessageResolver, value: Any, maxValue: Long): String =
13 -
    resolver.resolve(classOf[Max], value, maxValue)
13 +
    resolver.resolve[Max](value, maxValue)
14 14
}
15 15
16 16
/**
@@ -30,7 +30,9 @@
Loading
30 30
    value match {
31 31
      case arrayValue: Array[_] =>
32 32
        validationResult(arrayValue, maxValue)
33 -
      case traversableValue: Traversable[_] =>
33 +
      case mapValue: Map[_, _] =>
34 +
        validationResult(mapValue, maxValue)
35 +
      case traversableValue: Iterable[_] =>
34 36
        validationResult(traversableValue, maxValue)
35 37
      case bigDecimalValue: BigDecimal =>
36 38
        validationResult(bigDecimalValue, maxValue)
@@ -40,13 +42,13 @@
Loading
40 42
        validationResult(numberValue, maxValue)
41 43
      case _ =>
42 44
        throw new IllegalArgumentException(
43 -
          s"Class [${value.getClass}] is not supported by ${this.getClass}")
45 +
          s"Class [${value.getClass.getName}] is not supported by ${this.getClass.getName}")
44 46
    }
45 47
  }
46 48
47 49
  /* Private */
48 50
49 -
  private[this] def validationResult(value: Traversable[_], maxValue: Long) =
51 +
  private[this] def validationResult(value: Iterable[_], maxValue: Long) =
50 52
    ValidationResult.validate(
51 53
      value.size <= maxValue,
52 54
      errorMessage(Integer.valueOf(value.size), maxValue),

@@ -0,0 +1,26 @@
Loading
1 +
package com.twitter.finatra.validation.constraints
2 +
3 +
import com.twitter.finatra.validation.{
4 +
  ConstraintValidator,
5 +
  ErrorCode,
6 +
  MessageResolver,
7 +
  ValidationResult
8 +
}
9 +
10 +
private[validation] object AssertFalseConstraintValidator {
11 +
  def errorMessage(resolver: MessageResolver, value: Boolean): String =
12 +
    resolver.resolve[AssertFalse](value)
13 +
}
14 +
15 +
private[validation] class AssertFalseConstraintValidator(messageResolver: MessageResolver)
16 +
    extends ConstraintValidator[AssertFalse, Boolean](messageResolver) {
17 +
18 +
  override def isValid(
19 +
    annotation: AssertFalse,
20 +
    value: Boolean
21 +
  ): ValidationResult = ValidationResult.validate(
22 +
    !value,
23 +
    AssertFalseConstraintValidator.errorMessage(messageResolver, value),
24 +
    ErrorCode.InvalidBooleanValue(value)
25 +
  )
26 +
}

@@ -2,23 +2,22 @@
Loading
2 2
3 3
import com.github.benmanes.caffeine.cache.{Cache, Caffeine}
4 4
import com.twitter.finatra.utils.ClassUtils
5 -
import com.twitter.finatra.validation.ValidationResult.{Invalid, Valid}
6 -
import com.twitter.finatra.validation.internal.{
7 -
  AnnotatedClass,
8 -
  AnnotatedField,
9 -
  AnnotatedMethod,
10 -
  FieldValidator
11 -
}
5 +
import com.twitter.finatra.validation.internal._
6 +
import com.twitter.inject.TypeUtils
12 7
import com.twitter.inject.conversions.map._
13 8
import com.twitter.inject.utils.AnnotationUtils
14 -
import com.twitter.util.Try
9 +
import com.twitter.util.logging.Logger
10 +
import com.twitter.util.{Return, Try}
15 11
import java.lang.annotation.Annotation
12 +
import java.lang.reflect.{Field, Parameter}
16 13
import java.util.concurrent.ConcurrentHashMap
17 14
import java.util.function.Function
18 -
import org.json4s.reflect.{ClassDescriptor, ConstructorDescriptor, Reflector}
15 +
import org.json4s.reflect.{ClassDescriptor, ConstructorDescriptor, Reflector, ScalaType}
19 16
import scala.collection.{Map, mutable}
17 +
import scala.util.control.NonFatal
20 18
21 19
object Validator {
20 +
  private[this] val logger = Logger.getLogger(Validator.getClass)
22 21
23 22
  /** The size of the caffeine cache that is used to store reflection data on a validated case class. */
24 23
  private val DefaultCacheSize: Long = 128
@@ -26,6 +25,231 @@
Loading
26 25
  /** The Finatra default [[MessageResolver]] which is used to generate error messages when validations fail. */
27 26
  private val DefaultMessageResolver: MessageResolver = new MessageResolver
28 27
28 +
  /* Exposed for finatra/jackson */
29 +
  private[finatra] def parseResult(
30 +
    result: ValidationResult,
31 +
    path: Path,
32 +
    annotation: Annotation
33 +
  ): ValidationResult = {
34 +
    result match {
35 +
      case invalid @ ValidationResult.Invalid(_, _, _, _) =>
36 +
        invalid.copy(path = path, annotation = Some(annotation))
37 +
      case _ => result // no need to copy as we are only concerned with the invalid results
38 +
    }
39 +
  }
40 +
41 +
  /* Exposed for finatra/jackson */
42 +
  private[finatra] def isMethodValidationAnnotation(annotation: Annotation): Boolean =
43 +
    AnnotationUtils.annotationEquals[MethodValidation](annotation)
44 +
45 +
  /* Exposed for finatra/jackson */
46 +
  private[finatra] def isConstraintAnnotation(annotation: Annotation): Boolean =
47 +
    AnnotationUtils.isAnnotationPresent[Constraint](annotation)
48 +
49 +
  /* Exposed for finatra/jackson */
50 +
  private[finatra] def extractFieldsFromMethodValidation(
51 +
    annotation: Option[Annotation]
52 +
  ): Iterable[String] = {
53 +
    annotation match {
54 +
      case Some(methodValidation: MethodValidation) =>
55 +
        methodValidation.fields.toIterable.filter(_.nonEmpty)
56 +
      case _ =>
57 +
        Iterable.empty[String]
58 +
    }
59 +
  }
60 +
61 +
  // note: Prefer while loop over Scala for loop for better performance. The scala for loop
62 +
  // performance is optimized in 2.13.0 if we enable scalac: https://github.com/scala/bug/issues/1338
63 +
  /* Exposed for finatra/jackson */
64 +
  private[finatra] def validateMethods(
65 +
    obj: Any,
66 +
    methods: Array[AnnotatedMethod]
67 +
  )(
68 +
    invalidResultFn: (Path, ValidationResult.Invalid) => Unit
69 +
  ): Unit = {
70 +
    if (methods.nonEmpty) {
71 +
      var index = 0
72 +
      while (index < methods.length) {
73 +
        val AnnotatedMethod(_, path, method, annotation) = methods(index)
74 +
        // if we're unable to invoke the method, we want this propagate
75 +
        val result = method.invoke(obj).asInstanceOf[ValidationResult]
76 +
        val resultWithAnnotation = parseResult(result, path, annotation)
77 +
        if (!resultWithAnnotation.isValid) {
78 +
          invalidResultFn(path, resultWithAnnotation.asInstanceOf[ValidationResult.Invalid])
79 +
        }
80 +
        index += 1
81 +
      }
82 +
    }
83 +
  }
84 +
85 +
  /* Exposed for finatra/jackson */
86 +
  private[finatra] def getMethodValidations(clazz: Class[_]): Array[AnnotatedMethod] =
87 +
    getMethodValidations(clazz, None)
88 +
89 +
  // method validations with an optional parent Path
90 +
  private def getMethodValidations(
91 +
    clazz: Class[_],
92 +
    parentPath: Option[Path]
93 +
  ): Array[AnnotatedMethod] = {
94 +
    for {
95 +
      method <- clazz.getMethods
96 +
      annotation = Try(method.getAnnotation(classOf[MethodValidation]))
97 +
      if annotation.isReturn && annotation.get() != null
98 +
    } yield {
99 +
      AnnotatedMethod(
100 +
        name = Some(method.getName),
101 +
        path = parentPath match {
102 +
          case Some(p) =>
103 +
            p.append(method.getName)
104 +
          case _ =>
105 +
            Path(method.getName)
106 +
        },
107 +
        method = method,
108 +
        annotation = annotation.get()
109 +
      )
110 +
    }
111 +
  }
112 +
113 +
  /** If the given class is marked for cascaded validation or not. True if the class is a case class and has the `@Valid` annotation */
114 +
  private def isCascadedValidation(erasure: Class[_], annotations: Array[Annotation]): Boolean =
115 +
    ClassUtils.isCaseClass(erasure) && AnnotationUtils.findAnnotation[Valid](annotations).isDefined
116 +
117 +
  /** Each Constraint annotation defines its ConstraintValidator in a `validatedBy` field */
118 +
  private def getValidatedBy[A <: Annotation, V](
119 +
    annotationClass: Class[A]
120 +
  ): Class[ConstraintValidator[A, V]] = {
121 +
    val validationAnnotation = annotationClass.getAnnotation(classOf[Constraint])
122 +
    if (validationAnnotation == null)
123 +
      throw new IllegalArgumentException("Missing annotation: " + classOf[Constraint])
124 +
    else
125 +
      validationAnnotation.validatedBy().asInstanceOf[Class[ConstraintValidator[A, V]]]
126 +
  }
127 +
128 +
  private def isValidOption[V](
129 +
    value: Option[V],
130 +
    annotation: Annotation,
131 +
    validator: ConstraintValidator[Annotation, V]
132 +
  ): ValidationResult =
133 +
    value match {
134 +
      case Some(actualVal) =>
135 +
        validator.isValid(annotation, actualVal)
136 +
      case _ =>
137 +
        ValidationResult.Valid()
138 +
    }
139 +
140 +
  // this currently does not work properly for constraints which support Options
141 +
  private def isValid[V](
142 +
    value: V,
143 +
    annotation: Annotation,
144 +
    validator: ConstraintValidator[Annotation, V]
145 +
  ): ValidationResult =
146 +
    value match {
147 +
      case _: Option[_] =>
148 +
        isValidOption(value.asInstanceOf[Option[V]], annotation, validator)
149 +
      case _ =>
150 +
        validator.isValid(annotation, value)
151 +
    }
152 +
153 +
  // note: Prefer while loop over Scala for loop for better performance. The scala for loop
154 +
  // performance is optimized in 2.13.0 if we enable scalac: https://github.com/scala/bug/issues/1338
155 +
  private def validateField[T](
156 +
    fieldValue: T,
157 +
    path: Path,
158 +
    fieldValidators: Array[FieldValidator]
159 +
  ): mutable.ListBuffer[ValidationResult] = {
160 +
    if (fieldValidators.nonEmpty) {
161 +
      val results = new mutable.ListBuffer[ValidationResult]()
162 +
      var index = 0
163 +
      while (index < fieldValidators.length) {
164 +
        val FieldValidator(constraintValidator, annotation) = fieldValidators(index)
165 +
        val result =
166 +
          try {
167 +
            isValid(
168 +
              fieldValue,
169 +
              annotation,
170 +
              constraintValidator.asInstanceOf[ConstraintValidator[Annotation, T]])
171 +
          } catch {
172 +
            case NonFatal(e) =>
173 +
              logger.error("Unexpected exception validating field: " + path.toString, e)
174 +
              throw e
175 +
          }
176 +
        val resultWithAnnotation = parseResult(result, path, annotation)
177 +
        if (!resultWithAnnotation.isValid) results.append(resultWithAnnotation)
178 +
        index += 1
179 +
      }
180 +
      results
181 +
    } else mutable.ListBuffer.empty[ValidationResult]
182 +
  }
183 +
184 +
  // find the constructor with parameters that are all declared class fields
185 +
  // we use json4s here because in Scala 2.11 constructor params do not carry their
186 +
  // actual names (fixed in 2.12+) and we have logic based on the name of the constructor param
187 +
  private def defaultConstructor(clazzDescriptor: ClassDescriptor): ConstructorDescriptor = {
188 +
    def isDefaultConstructorDescriptor(constructorDescriptor: ConstructorDescriptor): Boolean = {
189 +
      constructorDescriptor.params.forall { param =>
190 +
        Try(clazzDescriptor.erasure.erasure.getDeclaredField(param.name)).isReturn
191 +
      }
192 +
    }
193 +
194 +
    clazzDescriptor.constructors
195 +
      .find(isDefaultConstructorDescriptor)
196 +
      .orElse(clazzDescriptor.constructors.find(_.params.isEmpty))
197 +
      .getOrElse(throw new IllegalArgumentException(
198 +
        s"Unable to parse case class for validation: ${clazzDescriptor.erasure.fullName}"))
199 +
  }
200 +
201 +
  /** Returns a Parameter name to AnnotatedConstructorParamDescriptor map */
202 +
  private def getAnnotatedConstructorParamDescriptors(
203 +
    clazz: Class[_],
204 +
    clazzDescriptor: ClassDescriptor
205 +
  ): scala.collection.Map[String, AnnotatedConstructorParamDescriptor] = {
206 +
    // find the default constructor descriptor via json4s. this is used to obtain parameter names
207 +
    // which are not properly carried in Scala 2.11 reflection
208 +
    val constructorDescriptor: ConstructorDescriptor = defaultConstructor(clazzDescriptor)
209 +
    // attempt to locate the class constructor with the same param args
210 +
    val constructor = Try(
211 +
      clazz.getConstructor(constructorDescriptor.params.map(_.argType.erasure): _*))
212 +
    // if we can find a constructor get the reflection Parameters which carry the annotations
213 +
    val constructorParameters: Array[Parameter] = constructor match {
214 +
      case Return(cons) =>
215 +
        cons.getParameters
216 +
      case _ =>
217 +
        Array.empty
218 +
    }
219 +
220 +
    // find all inherited annotations for every constructor param
221 +
    val allFieldAnnotations = AnnotationUtils.findAnnotations(
222 +
      clazz,
223 +
      constructorDescriptor.params.map { param =>
224 +
        param.name -> constructorParameters(param.argIndex).getAnnotations
225 +
      }.toMap)
226 +
227 +
    val result: mutable.HashMap[String, AnnotatedConstructorParamDescriptor] =
228 +
      new mutable.HashMap[String, AnnotatedConstructorParamDescriptor]()
229 +
    var index = 0
230 +
    while (index < constructorParameters.length) {
231 +
      val parameter = constructorParameters(index)
232 +
      val descriptor = constructorDescriptor.params(index)
233 +
      val filteredAnnotations = allFieldAnnotations(descriptor.name).filter { ann =>
234 +
        AnnotationUtils.isAnnotationPresent[Constraint](ann) ||
235 +
        AnnotationUtils.annotationEquals[Valid](ann)
236 +
      }
237 +
238 +
      result.put(
239 +
        descriptor.name,
240 +
        AnnotatedConstructorParamDescriptor(
241 +
          descriptor,
242 +
          TypeUtils.parameterizedTypeNames(parameter.getParameterizedType),
243 +
          filteredAnnotations
244 +
        )
245 +
      )
246 +
247 +
      index += 1
248 +
    }
249 +
250 +
    result
251 +
  }
252 +
29 253
  /**
30 254
   * Return a [[Builder]] of a [[Validator]].
31 255
   *
@@ -88,13 +312,14 @@
Loading
88 312
}
89 313
90 314
class Validator private[finatra] (cacheSize: Long, messageResolver: MessageResolver) {
315 +
  import Validator._
91 316
92 317
  /** Memoized mapping of [[ConstraintValidator]] to its associated [[Annotation]] class. */
93 318
  private[this] val constraintValidatorMap =
94 319
    new ConcurrentHashMap[Class[_ <: Annotation], ConstraintValidator[_, _]]()
95 320
96 -
  // A caffeine cache to store the expensive reflection call result by calling AnnotationUtils.findAnnotations
97 -
  // on the same object. Caffeine cache uses the `Window TinyLfu` policy to remove evicted keys.
321 +
  // A caffeine cache to store the expensive reflection calls on the same object. Caffeine cache
322 +
  // uses the `Window TinyLfu` policy to remove evicted keys.
98 323
  // For more information, check out: https://github.com/ben-manes/caffeine/wiki/Efficiency
99 324
  private[this] val reflectionCache: Cache[Class[_], AnnotatedClass] =
100 325
    Caffeine
@@ -102,239 +327,280 @@
Loading
102 327
      .maximumSize(cacheSize)
103 328
      .build[Class[_], AnnotatedClass]()
104 329
330 +
  /* Public */
331 +
105 332
  /**
106 -
   * Validate all field and method annotations for a case class.
333 +
   * Validates all constraints on given object which is expected to be an
334 +
   * instance of a Scala [[https://docs.scala-lang.org/tour/case-classes.html case class]].
107 335
   *
108 336
   * @throws ValidationException with all invalid validation results.
109 337
   * @param obj The case class to validate.
338 +
   *
339 +
   * @note the [[ValidationException]] formats included [[ValidationResult.Invalid]] results to
340 +
   *       include the [[ValidationResult.Invalid.path]] as a [[ValidationResult.Invalid.message]]
341 +
   *       prefix for ease of error reporting from the exception as the results are used as the basis
342 +
   *       of the [[ValidationException.getMessage]] string.
110 343
   */
111 344
  @throws[ValidationException]
112 -
  def validate(obj: Any): Unit = {
113 -
    val clazz: Class[_] = obj.getClass
114 -
    if (ClassUtils.notCaseClass(clazz)) throw InvalidCaseClassException(clazz)
115 -
116 -
    val AnnotatedClass(_, fields, methods) = getAnnotatedClass(clazz)
117 -
118 -
    // validate field
119 -
    val fieldValidations: Iterable[ValidationResult] =
120 -
      fields.flatMap {
121 -
        case (_, AnnotatedField(Some(field), fieldValidators)) =>
122 -
          field.setAccessible(true)
123 -
          validateField(field.get(obj), fieldValidators)
124 -
      }
125 -
126 -
    val invalidResult = fieldValidations.toSeq ++ validateMethods(obj, methods) // validate methods
127 -
    if (invalidResult.nonEmpty) throw new ValidationException(invalidResult)
345 +
  def verify(obj: Any): Unit = {
346 +
    // validate fields
347 +
    val invalidResult = validate(obj)
348 +
    if (invalidResult.nonEmpty)
349 +
      throw new ValidationException(includeFieldNames = true, invalidResult)
128 350
  }
129 351
130 352
  /**
131 -
   * Validate a field's value according to the field's constraint annotations.
353 +
   * Validates all constraints on given object which is expected to be an
354 +
   * instance of a Scala [[https://docs.scala-lang.org/tour/case-classes.html case class]].
132 355
   *
133 -
   * @return A list of all the failed [[ValidationResult]].
356 +
   * @param obj The case class to validate.
357 +
   * @return a sequence of [[ValidationResult]] which represent all found constraint violations.
358 +
   *
359 +
   * @note [[ValidationResult.Invalid]] results returned from this method will not have the
360 +
   *       field name already included in the message. That translations happens in the [[ValidationException]].
134 361
   */
135 -
  // note: Prefer while loop over Scala for loop for better performance. The scala for loop
136 -
  // performance is optimized in 2.13.0 if we enable scalac: https://github.com/scala/bug/issues/1338
362 +
  def validate(obj: Any): Set[ValidationResult] = {
363 +
    val results = validate(obj, None)
364 +
    if (results.nonEmpty) results.toSet
365 +
    else Set.empty[ValidationResult]
366 +
  }
367 +
368 +
  /* Private */
369 +
370 +
  /* Exposed for finatra/jackson */
137 371
  private[finatra] def validateField[T](
138 372
    fieldValue: T,
373 +
    fieldName: String,
139 374
    fieldValidators: Array[FieldValidator]
140 -
  ): scala.collection.Seq[ValidationResult] = {
141 -
    if (fieldValidators.nonEmpty) {
142 -
      val results = new mutable.ArrayBuffer[ValidationResult](fieldValidators.length)
143 -
      var index = 0
144 -
      while (index < fieldValidators.length) {
145 -
        val FieldValidator(constraintValidator, annotation) = fieldValidators(index)
146 -
        val result = isValid(
147 -
          fieldValue,
148 -
          annotation,
149 -
          constraintValidator.asInstanceOf[ConstraintValidator[Annotation, T]])
150 -
        val resultWithAnnotation = parseResult(result, annotation)
151 -
        if (!resultWithAnnotation.isValid) results.append(resultWithAnnotation)
152 -
        index += 1
153 -
      }
154 -
      results
155 -
    } else Seq.empty[ValidationResult]
156 -
  }
375 +
  ): mutable.ListBuffer[ValidationResult] =
376 +
    Validator.validateField(fieldValue, Path(fieldName), fieldValidators)
377 +
378 +
  // recursively validate
379 +
  private[this] def validate(
380 +
    value: Any,
381 +
    annotatedMember: Option[AnnotatedMember],
382 +
    shouldFindFieldValue: Boolean = true
383 +
  ): Iterable[ValidationResult] =
384 +
    annotatedMember match {
385 +
      case Some(member) =>
386 +
        member match {
387 +
          case AnnotatedField(_, path, _, fieldValidators) =>
388 +
            Validator.validateField(value, path, fieldValidators)
389 +
          case AnnotatedClass(_, _, _, _, members, methods) =>
390 +
            val fieldValidationResults = members.flatMap {
391 +
              case collection: AnnotatedClass
392 +
                  if collection.scalaType.isDefined && collection.scalaType.get.isCollection =>
393 +
                validateCollection(
394 +
                  findFieldValue(value, value.getClass, collection.name),
395 +
                  collection)
396 +
              case option: AnnotatedClass
397 +
                  if option.scalaType.isDefined && option.scalaType.get.isOption =>
398 +
                validateOption(findFieldValue(value, value.getClass, option.name), option)
399 +
              case annotatedClass: AnnotatedClass =>
400 +
                if (shouldFindFieldValue)
401 +
                  validate(
402 +
                    findFieldValue(value, value.getClass, annotatedClass.name),
403 +
                    Some(annotatedClass))
404 +
                else
405 +
                  validate(value, Some(annotatedClass))
406 +
              case annotatedField: AnnotatedField =>
407 +
                // otherwise need to find the correct obj
408 +
                validate(
409 +
                  findFieldValue(value, value.getClass, annotatedField.name),
410 +
                  Some(annotatedField))
411 +
            }
412 +
            fieldValidationResults ++ validateMethods(value, methods)
413 +
          case _ => // make exhaustive
414 +
            Iterable.empty[ValidationResult]
415 +
        }
416 +
      case _ =>
417 +
        val clazz: Class[_] = value.getClass
418 +
        if (ClassUtils.notCaseClass(clazz)) throw InvalidCaseClassException(clazz)
419 +
        val annotatedClazz: AnnotatedClass = findAnnotatedClass(clazz)
420 +
        validate(value, Some(annotatedClazz))
421 +
    }
157 422
158 -
  /**
159 -
   * Validate all methods annotated with [[MethodValidation]] in a case class.
160 -
   *
161 -
   * @return A list of all the failed [[ValidationResult]].
162 -
   */
163 -
  // note: Prefer while loop over Scala for loop for better performance. The scala for loop
164 -
  // performance is optimized in 2.13.0 if we enable scalac: https://github.com/scala/bug/issues/1338
165 -
  private[finatra] def validateMethods(
423 +
  private[this] def findFieldValue[T](
166 424
    obj: Any,
167 -
    methods: Array[AnnotatedMethod]
168 -
  ): scala.collection.Seq[ValidationResult] = {
169 -
    if (methods.nonEmpty) {
170 -
      val results = new mutable.ArrayBuffer[ValidationResult](methods.length)
171 -
      var index = 0
172 -
      while (index < methods.length) {
173 -
        val AnnotatedMethod(method, annotation) = methods(index)
174 -
        val result = method.invoke(obj).asInstanceOf[ValidationResult]
175 -
        val resultWithAnnotation = parseResult(result, annotation)
176 -
        if (!resultWithAnnotation.isValid) results.append(resultWithAnnotation)
177 -
        index += 1
178 -
      }
179 -
      results
180 -
    } else Seq.empty[ValidationResult]
181 -
  }
425 +
    clazz: Class[_],
426 +
    fieldName: Option[String]
427 +
  ): T =
428 +
    fieldName
429 +
      .map { name =>
430 +
        val field = clazz.getDeclaredField(name)
431 +
        field.setAccessible(true)
432 +
        field.get(obj).asInstanceOf[T]
433 +
      }.getOrElse(obj.asInstanceOf[T])
434 +
435 +
  private[this] def validateCollection(
436 +
    obj: Iterable[_],
437 +
    annotatedClass: AnnotatedClass
438 +
  ): Iterable[ValidationResult] = {
439 +
    // apply the parentPropertyPath to the contained members for error reporting
440 +
    def indexAnnotatedMembers(
441 +
      parentPropertyPath: Path,
442 +
      annotatedMember: AnnotatedMember
443 +
    ): Array[AnnotatedMember] = annotatedMember match {
444 +
      case annotatedClass: AnnotatedClass =>
445 +
        annotatedClass.members.map {
446 +
          case f: AnnotatedField =>
447 +
            f.copy(path = Path(parentPropertyPath.names :+ f.path.last))
448 +
          case m: AnnotatedMethod =>
449 +
            m.copy(path = Path(parentPropertyPath.names :+ m.path.last))
450 +
          case c: AnnotatedClass =>
451 +
            val newParentPath = Path(parentPropertyPath.names :+ c.path.last)
452 +
            c.copy(
453 +
              path = newParentPath,
454 +
              members = indexAnnotatedMembers(newParentPath, c),
455 +
              methods = c.methods.map { m =>
456 +
                m.copy(path = Path(parentPropertyPath.names :+ m.path.last))
457 +
              }
458 +
            )
459 +
        }
460 +
      case _ =>
461 +
        throw new IllegalArgumentException(
462 +
          s"Unrecognized AnnotatedMember type: ${annotatedMember.getClass}")
463 +
    }
182 464
183 -
  @deprecated(
184 -
    "The endpoint is not supposed to be use on its own, use validate(Any) instead",
185 -
    "2020-02-12")
186 -
  def validateMethods(obj: Any): scala.collection.Seq[ValidationResult] = {
187 -
    val methodValidations: Array[AnnotatedMethod] = getMethodValidations(obj.getClass)
188 -
    validateMethods(obj, methodValidations)
465 +
    (for ((instance, index) <- obj.zipWithIndex) yield {
466 +
      // apply the index to the parent path, then use this to recompute paths of members and methods
467 +
      val parentPropertyPath = annotatedClass.path.append(index.toString)
468 +
      validate(
469 +
        instance,
470 +
        Some(
471 +
          annotatedClass
472 +
            .copy(
473 +
              scalaType = annotatedClass.scalaType.map(_.typeArgs.head),
474 +
              path = parentPropertyPath,
475 +
              members = indexAnnotatedMembers(parentPropertyPath, annotatedClass),
476 +
              methods = annotatedClass.methods.map { m =>
477 +
                m.copy(path = Path(parentPropertyPath.names :+ m.path.last))
478 +
              }
479 +
            )
480 +
        ),
481 +
        // if the type to validate is a case class we need to use the zipped instance here and
482 +
        // find the field value for the case class members otherwise use the already calculated
483 +
        // given instance. thus shouldFindFieldValue is true if the given annotatedClass has
484 +
        // a case class type argument
485 +
        shouldFindFieldValue =
486 +
          annotatedClass.scalaType.exists(t => ClassUtils.isCaseClass(t.typeArgs.head.erasure))
487 +
      )
488 +
    }).flatten
189 489
  }
190 490
191 -
  /* --------------------------------------------------------------------------*
192 -
   *                                   Utils
193 -
   * --------------------------------------------------------------------------*/
194 -
195 -
  // Exposed for finatra/jackson
196 -
  // Look for the ConstraintValidator for a given annotation
197 -
  private[finatra] def findFieldValidator[T](annotation: Annotation): FieldValidator = {
198 -
    val constraintValidator =
199 -
      constraintValidatorMap
200 -
        .atomicGetOrElseUpdate(annotation.getClass, getConstraintValidator(annotation))
201 -
        .asInstanceOf[ConstraintValidator[_ <: Annotation, T]]
202 -
    FieldValidator(constraintValidator, annotation)
491 +
  // performs validation based on the first typeArg of the argType of the given AnnotatedClass
492 +
  private[this] def validateOption(
493 +
    obj: Option[_],
494 +
    annotatedClass: AnnotatedClass
495 +
  ): Iterable[ValidationResult] = {
496 +
    obj match {
497 +
      case Some(instance) =>
498 +
        validate(
499 +
          instance,
500 +
          Some(
501 +
            annotatedClass
502 +
              .copy(scalaType = annotatedClass.scalaType.map(_.typeArgs.head))),
503 +
          shouldFindFieldValue = false // we've already calculated it here
504 +
        )
505 +
      case _ => Iterable.empty
506 +
    }
203 507
  }
204 508
205 -
  // Exposed for finatra/jackson
206 -
  private[finatra] def isMethodValidationAnnotation(annotation: Annotation): Boolean =
207 -
    AnnotationUtils.annotationEquals[MethodValidation](annotation)
208 -
209 -
  // Exposed for finatra/jackson
210 -
  private[finatra] def isConstraintAnnotation(annotation: Annotation): Boolean =
211 -
    AnnotationUtils.isAnnotationPresent[Constraint](annotation)
509 +
  /* Exposed for testing */
510 +
  private[validation] def validateMethods(
511 +
    obj: Any,
512 +
    methods: Array[AnnotatedMethod]
513 +
  ): mutable.HashSet[ValidationResult] = {
514 +
    val results = new mutable.HashSet[ValidationResult]()
515 +
    Validator.validateMethods(obj, methods) {
516 +
      case (path, invalid) =>
517 +
        val caseClassFields = extractFieldsFromMethodValidation(invalid.annotation)
518 +
        // in the Jackson case, method validations are repeated per-field listed in the
519 +
        // @MethodValidation annotation. here we recreate the behavior for parity
520 +
        // we want to create a new path per field reported in the MethodValidation
521 +
        val failures = caseClassFields
522 +
        // per field, create a new Path with the given field as the leaf
523 +
          .map(fieldName => path.copy(names = path.init :+ fieldName))
524 +
          .map(p =>
525 +
            invalid.copy(path = p)) // create a new ValidationResult.Invalid with the given Path
526 +
        if (failures.nonEmpty) failures.map(results.add)
527 +
        else results.add(invalid)
528 +
    }
529 +
    results
530 +
  }
212 531
213 -
  // Exposed for finatra/jackson and testing
214 -
  private[finatra] def getMethodValidations(clazz: Class[_]): Array[AnnotatedMethod] =
215 -
    for {
216 -
      method <- clazz.getMethods
217 -
      annotation = Try(method.getAnnotation(classOf[MethodValidation]))
218 -
      if annotation.isReturn && annotation.get() != null
219 -
    } yield AnnotatedMethod(method, annotation.get())
532 +
  /* Derive AnnotatedClass for Validation */
220 533
221 -
  // Exposed in testing in finatra/http integration test
222 -
  // Return all field constraint annotations and their matching ConstraintValidators
223 -
  // and all method annotations.
224 -
  private[finatra] def getAnnotatedClass(clazz: Class[_]): AnnotatedClass = {
534 +
  /* Exposed for testing */
535 +
  private[validation] def findAnnotatedClass(clazz: Class[_]): AnnotatedClass =
225 536
    reflectionCache.get(
226 537
      clazz,
227 538
      new Function[Class[_], AnnotatedClass] {
228 539
        def apply(v1: Class[_]): AnnotatedClass = {
229 -
          val clazzDescriptor: ClassDescriptor =
230 -
            Reflector.describe(clazz).asInstanceOf[ClassDescriptor]
231 -
          // We must use the constructor parameters here (and not getDeclaredFields),
232 -
          // because getDeclaredFields will return other non-constructor fields within
233 -
          // the case class. We use `head` here, because if this case class doesn't have a
234 -
          // constructor at all, we have larger issues.
235 -
          // Note: we do not use clazz.constructors.head.getParameters because access to parameter
236 -
          // names via Java reflection is not supported in Scala 2.11
237 -
          // see: https://github.com/scala/bug/issues/9437
238 -
          val constructor: ConstructorDescriptor = clazzDescriptor.constructors.head
239 -
          createAnnotatedClass(
240 -
            clazz,
241 -
            AnnotationUtils.findAnnotations(
242 -
              clazz,
243 -
              constructor.params.map(_.argType.erasure),
244 -
              constructor.params.map(_.name).toArray))
540 +
          getAnnotatedClass(None, Path.Empty, clazz, None, Seq.empty[Class[_]])
245 541
        }
246 542
      }
247 543
    )
248 -
  }
249 544
250 -
  private[this] def parseResult(
251 -
    result: ValidationResult,
252 -
    annotation: Annotation
253 -
  ): ValidationResult = {
254 -
    result match {
255 -
      case invalid @ Invalid(_, _, _) =>
256 -
        invalid.copy(annotation = Some(annotation))
257 -
      case valid @ Valid(_) =>
258 -
        valid // no need to copy as we are only concerned with the invalid results
259 -
    }
260 -
  }
261 -
262 -
  // Exposed in finatra/jackson
263 -
  private[finatra] def createAnnotatedClass(
545 +
  // recursively describe a class
546 +
  private def getAnnotatedClass(
547 +
    name: Option[String],
548 +
    path: Path,
264 549
    clazz: Class[_],
265 -
    annotationsMap: Map[String, Array[Annotation]]
550 +
    scalaType: Option[ScalaType],
551 +
    observed: Seq[Class[_]]
266 552
  ): AnnotatedClass = {
267 -
    val fieldsMap = new mutable.HashMap[String, AnnotatedField]()
268 -
    // get AnnotatedField
269 -
    for ((name, annotations) <- annotationsMap) yield {
270 -
      for (i <- annotations.indices) {
271 -
        val annotation = annotations(i)
272 -
        if (isConstraintAnnotation(annotation)) {
273 -
          val fieldValidators = fieldsMap.get(name) match {
274 -
            case Some(validators) =>
275 -
              validators.fieldValidators :+ findFieldValidator(annotation)
276 -
            case _ =>
277 -
              Array(findFieldValidator(annotation))
278 -
          }
279 -
          fieldsMap.put(
280 -
            name,
281 -
            AnnotatedField(
282 -
              // the annotation may be from a static or secondary constructor for which we have no field information
283 -
              Try(clazz.getDeclaredField(name)).toOption,
284 -
              fieldValidators
285 -
            )
286 -
          )
553 +
    // for case classes, annotations for params only appear on the constructor
554 +
    val clazzDescriptor: ClassDescriptor = Reflector.describe(clazz).asInstanceOf[ClassDescriptor]
555 +
    // map of parameter name to a class with all annotations for the parameter (including inherited)
556 +
    val annotatedConstructorParams: Map[String, AnnotatedConstructorParamDescriptor] =
557 +
      getAnnotatedConstructorParamDescriptors(clazz, clazzDescriptor)
558 +
559 +
    val members = new mutable.ArrayBuffer[AnnotatedMember]()
560 +
    // we validate only declared fields of the class
561 +
    val declaredFields: Array[Field] = clazz.getDeclaredFields
562 +
    var index = 0
563 +
    while (index < declaredFields.length) {
564 +
      val field = declaredFields(index)
565 +
      val (scalaType, fieldClazz, annotations) =
566 +
        annotatedConstructorParams.get(field.getName) match {
567 +
          case Some(annotatedParam) =>
568 +
            // we already have information for the field as it is a constructor parameter
569 +
            (
570 +
              Some(annotatedParam.param.argType),
571 +
              annotatedParam.param.argType.erasure,
572 +
              annotatedParam.annotations)
573 +
          case _ =>
574 +
            // we don't have information, use the field information which should carry annotation information
575 +
            (None, field.getType, field.getAnnotations)
287 576
        }
288 -
      }
289 -
    }
290 -
    AnnotatedClass(clazz, fieldsMap.toMap, getMethodValidations(clazz))
291 -
  }
292 577
293 -
  private[this] def isValid[V](
294 -
    value: V,
295 -
    annotation: Annotation,
296 -
    validator: ConstraintValidator[Annotation, V]
297 -
  ): ValidationResult =
298 -
    value match {
299 -
      case _: Option[_] =>
300 -
        isValidOption(value.asInstanceOf[Option[V]], annotation, validator)
301 -
      case _ =>
302 -
        validator.isValid(annotation, value)
303 -
    }
578 +
      // create an annotated field
579 +
      getAnnotatedField(field.getName, path.append(field.getName), Some(field), annotations)
580 +
        .foreach { annotatedField =>
581 +
          members.append(annotatedField)
582 +
        }
583 +
      // create an annotated class
584 +
      getAnnotatedClass(field.getName, path, fieldClazz, scalaType, annotations, observed :+ clazz)
585 +
        .foreach { annotatedClazz =>
586 +
          members.append(annotatedClazz)
587 +
        }
304 588
305 -
  private[this] def isValidOption[V](
306 -
    value: Option[V],
307 -
    annotation: Annotation,
308 -
    validator: ConstraintValidator[Annotation, V]
309 -
  ): ValidationResult =
310 -
    value match {
311 -
      case Some(actualVal) =>
312 -
        validator.isValid(annotation, actualVal)
313 -
      case _ =>
314 -
        Valid()
589 +
      index += 1
315 590
    }
316 591
317 -
  // Each Constraint annotation defines its ConstraintValidator in a `validatedBy` field
318 -
  private[this] def getValidatedBy[A <: Annotation, T](
319 -
    annotationClass: Class[A]
320 -
  ): Class[ConstraintValidator[A, T]] = {
321 -
    val validationAnnotation = annotationClass.getAnnotation(classOf[Constraint])
322 -
    if (validationAnnotation == null)
323 -
      throw new IllegalArgumentException("Missing annotation: " + classOf[Constraint])
324 -
    else
325 -
      validationAnnotation.validatedBy().asInstanceOf[Class[ConstraintValidator[A, T]]]
326 -
  }
327 -
328 -
  private[this] def getConstraintValidator[A <: Annotation, T](
329 -
    annotation: A
330 -
  ): ConstraintValidator[A, T] = {
331 -
    val validatorClass = getValidatedBy[A, T](annotation.annotationType().asInstanceOf[Class[A]])
332 -
    createConstraintValidator(validatorClass, annotation)
592 +
    AnnotatedClass(
593 +
      name,
594 +
      path,
595 +
      clazz,
596 +
      scalaType.orElse(Some(clazzDescriptor.erasure)),
597 +
      members.toArray,
598 +
      getMethodValidations(clazz, Some(path))
599 +
    )
333 600
  }
334 601
335 602
  private[this] def createConstraintValidator[A <: Annotation, T](
336 -
    validatorClass: Class[ConstraintValidator[A, T]],
337 -
    annotation: A
603 +
    validatorClass: Class[ConstraintValidator[A, T]]
338 604
  ): ConstraintValidator[A, T] = {
339 605
    try {
340 606
      validatorClass
@@ -348,4 +614,102 @@
Loading
348 614
        )
349 615
    }
350 616
  }
617 +
618 +
  private[this] def getConstraintValidator[A <: Annotation, V](
619 +
    annotation: A
620 +
  ): ConstraintValidator[A, V] = {
621 +
    val validatorClass =
622 +
      getValidatedBy[A, V](annotation.annotationType().asInstanceOf[Class[A]])
623 +
    createConstraintValidator(validatorClass)
624 +
  }
625 +
626 +
  /* Exposed for finatra/jackson */
627 +
  private[finatra] def findFieldValidator[V](annotation: Annotation): FieldValidator = {
628 +
    val constraintValidator =
629 +
      constraintValidatorMap
630 +
        .atomicGetOrElseUpdate(annotation.getClass, getConstraintValidator(annotation))
631 +
        .asInstanceOf[ConstraintValidator[_ <: Annotation, V]]
632 +
    FieldValidator(constraintValidator, annotation)
633 +
  }
634 +
635 +
  /** Optionally create an AnnotatedField is the field is annotated with any Constraints */
636 +
  private[this] def getAnnotatedField(
637 +
    name: String,
638 +
    path: Path,
639 +
    field: Option[Field],
640 +
    annotations: Array[Annotation]
641 +
  ): Option[AnnotatedField] = {
642 +
643 +
    val fieldValidators = new mutable.ArrayBuffer[FieldValidator](annotations.length)
644 +
    var index = 0
645 +
    while (index < annotations.length) {
646 +
      val annotation = annotations(index)
647 +
      if (isConstraintAnnotation(annotation)) {
648 +
        fieldValidators.append(findFieldValidator(annotation))
649 +
      }
650 +
      index += 1
651 +
    }
652 +
    if (fieldValidators.nonEmpty) {
653 +
      Some(
654 +
        AnnotatedField(
655 +
          name = Some(name),
656 +
          path = path,
657 +
          field = field,
658 +
          fieldValidators = fieldValidators.toArray
659 +
        )
660 +
      )
661 +
    } else None
662 +
  }
663 +
664 +
  /** Optionally create an AnnotatedClass if the class is a Scala case class and is annotated with `@Valid` */
665 +
  private def getAnnotatedClass(
666 +
    name: String,
667 +
    path: Path,
668 +
    clazz: Class[_],
669 +
    scalaType: Option[ScalaType],
670 +
    annotations: Array[Annotation],
671 +
    observed: Seq[Class[_]]
672 +
  ): Option[AnnotatedClass] = {
673 +
    // note we do not handle validation of container types with multiple
674 +
    // case class type args, e.g., Map, Either, Tuple2, etc.
675 +
    val argTypeOption = scalaType.orElse {
676 +
      Reflector.describe(clazz) match {
677 +
        case desc: ClassDescriptor =>
678 +
          Some(desc.erasure)
679 +
        case _ => None
680 +
      }
681 +
    }
682 +
    argTypeOption match {
683 +
      case Some(argType) if isCascadedValidation(argType.erasure, annotations) =>
684 +
        if (observed.contains(argType.erasure))
685 +
          throw new IllegalArgumentException(s"Cycle detected at ${argType.erasure}.")
686 +
        Some(
687 +
          getAnnotatedClass(
688 +
            Some(name),
689 +
            path.append(name),
690 +
            argType.erasure,
691 +
            Some(argType),
692 +
            observed :+ argType.erasure))
693 +
      case Some(argType) if (argType.isMap || argType.isMutableMap) =>
694 +
        // Maps not supported
695 +
        None
696 +
      case Some(argType)
697 +
          if (argType.isCollection || argType.isOption) &&
698 +
            isCascadedValidation(argType.typeArgs.head.erasure, annotations) =>
699 +
        // handles Option and Iterable collections
700 +
        if (observed.contains(argType.typeArgs.head.erasure))
701 +
          throw new IllegalArgumentException(s"Cycle detected at ${argType.typeArgs.head.erasure}.")
702 +
        Some(
703 +
          getAnnotatedClass(
704 +
            Some(name),
705 +
            path
706 +
              .append(name)
707 +
              .append(ClassUtils.simpleName(argType.typeArgs.head.erasure).toLowerCase),
708 +
            argType.typeArgs.head.erasure,
709 +
            Some(argType),
710 +
            observed :+ argType.typeArgs.head.erasure
711 +
          ))
712 +
      case _ => None
713 +
    }
714 +
  }
351 715
}

@@ -0,0 +1,39 @@
Loading
1 +
package com.twitter.finatra.validation
2 +
3 +
object Path {
4 +
  private val FieldSeparator = "."
5 +
6 +
  /** An empty path (no names) */
7 +
  val Empty: Path = Path(Seq.empty[String])
8 +
9 +
  /** Creates a new path from the given name */
10 +
  def apply(name: String): Path = new Path(Seq(name))
11 +
}
12 +
13 +
/**
14 +
 * Represents the navigation path from an object to another in an object graph.
15 +
 */
16 +
case class Path(names: Seq[String]) {
17 +
18 +
  /** Prepend the given name to this Path */
19 +
  def prepend(name: String): Path = copy(name +: names)
20 +
21 +
  /** Append the given name to this Path */
22 +
  def append(name: String): Path = copy(names :+ name)
23 +
24 +
  def isEmpty: Boolean = names.isEmpty
25 +
26 +
  /**
27 +
   * Selects all of the underlying path names except for the last (leaf),
28 +
   * if the underlying names is not empty. If empty, returns an empty Seq.
29 +
   */
30 +
  def init: Seq[String] = if (!isEmpty) names.init else Seq.empty[String]
31 +
32 +
  /**
33 +
   * Returns the last underlying path name, if the underlying name is
34 +
   * not empty. Otherwise, if empty returns an empty String.
35 +
   */
36 +
  def last: String = if (!isEmpty) names.last else ""
37 +
38 +
  override def toString: String = names.mkString(Path.FieldSeparator)
39 +
}

@@ -10,7 +10,7 @@
Loading
10 10
private[validation] object MinConstraintValidator {
11 11
12 12
  def errorMessage(resolver: MessageResolver, value: Any, minValue: Long): String =
13 -
    resolver.resolve(classOf[Min], value, minValue)
13 +
    resolver.resolve[Min](value, minValue)
14 14
}
15 15
16 16
/**
@@ -30,7 +30,9 @@
Loading
30 30
    value match {
31 31
      case arrayValue: Array[_] =>
32 32
        validationResult(arrayValue, minValue)
33 -
      case traversableValue: Traversable[_] =>
33 +
      case mapValue: Map[_, _] =>
34 +
        validationResult(mapValue, minValue)
35 +
      case traversableValue: Iterable[_] =>
34 36
        validationResult(traversableValue, minValue)
35 37
      case bigDecimalValue: BigDecimal =>
36 38
        validationResult(bigDecimalValue, minValue)
@@ -40,13 +42,13 @@
Loading
40 42
        validationResult(numberValue, minValue)
41 43
      case _ =>
42 44
        throw new IllegalArgumentException(
43 -
          s"Class [${value.getClass}] is not supported by ${this.getClass}")
45 +
          s"Class [${value.getClass.getName}] is not supported by ${this.getClass.getName}")
44 46
    }
45 47
  }
46 48
47 49
  /* Private */
48 50
49 -
  private[this] def validationResult(value: Traversable[_], minValue: Long) = {
51 +
  private[this] def validationResult(value: Iterable[_], minValue: Long) = {
50 52
    val size = value.size
51 53
    ValidationResult.validate(
52 54
      minValue <= size,

@@ -197,6 +197,19 @@
Loading
197 197
    )
198 198
  }
199 199
200 +
  private[twitter] def findAnnotations(
201 +
    clazz: Class[_],
202 +
    fieldAnnotations: scala.collection.Map[String, Array[Annotation]]
203 +
  ): scala.collection.Map[String, Array[Annotation]] = {
204 +
    val collectorMap = new scala.collection.mutable.HashMap[String, Array[Annotation]]()
205 +
    collectorMap ++= fieldAnnotations
206 +
    // find inherited annotations
207 +
    findDeclaredMethodAnnotations(
208 +
      clazz,
209 +
      collectorMap
210 +
    )
211 +
  }
212 +
200 213
  /**
201 214
   * Attempts to return the `value()` of an [[Annotation]] annotated by an [[Annotation]] of
202 215
   * type [[A]].

@@ -1,6 +1,5 @@
Loading
1 1
package com.twitter.finatra.validation
2 2
3 -
import com.twitter.finatra.validation.ValidationResult.{Invalid, Valid}
4 3
import com.twitter.inject.conversions.time._
5 4
import org.joda.time.DateTime
6 5
@@ -19,11 +18,11 @@
Loading
19 18
    if (rangeDefined)
20 19
      validateTimeRange(startTime.get, endTime.get, startTimeProperty, endTimeProperty)
21 20
    else if (partialRange)
22 -
      Invalid(
21 +
      ValidationResult.Invalid(
23 22
        "both %s and %s are required for a valid range".format(startTimeProperty, endTimeProperty)
24 23
      )
25 24
    else
26 -
      Valid()
25 +
      ValidationResult.Valid()
27 26
  }
28 27
29 28
  def validateTimeRange(

@@ -1,13 +1,63 @@
Loading
1 1
package com.twitter.finatra.validation.internal
2 2
3 +
import com.twitter.finatra.validation.Path
4 +
import java.lang.annotation.Annotation
5 +
import org.json4s.reflect.ScalaType
6 +
3 7
/**
4 -
 * A Finatra internal class that carries all field and method annotations of the given case class
8 +
 * A Finatra internal class that carries all field and method annotations of the given case class.
5 9
 *
6 -
 * @param clazz
7 -
 * @param fields  carries all field Constraint annotations
8 -
 * @param methods carries all method annotations
10 +
 * [[scalaType]] represents the class or its binding, e.g. `T` or `Option[T]`
9 11
 */
10 -
private[finatra] case class AnnotatedClass(
12 +
private[validation] case class AnnotatedClass(
13 +
  name: Option[String],
14 +
  path: Path,
11 15
  clazz: Class[_],
12 -
  fields: Map[String, AnnotatedField],
16 +
  scalaType: Option[ScalaType],
17 +
  members: Array[AnnotatedMember],
13 18
  methods: Array[AnnotatedMethod])
19 +
    extends AnnotatedMember {
20 +
21 +
  // finds members by the given name. this may return
22 +
  // multiple as there may be an AnnotatedField and
23 +
  // and an AnnotatedClass for the same field name
24 +
  def findAnnotatedMembersByName(
25 +
    name: String,
26 +
    acc: scala.collection.mutable.ArrayBuffer[AnnotatedMember] =
27 +
      scala.collection.mutable.ArrayBuffer.empty
28 +
  ): Array[AnnotatedMember] = {
29 +
    val collectedAnnotatedMembers = members ++ methods
30 +
    var i = 0
31 +
    while (i < collectedAnnotatedMembers.length) {
32 +
      val member = collectedAnnotatedMembers(i)
33 +
      member match {
34 +
        case field: AnnotatedField =>
35 +
          if (field.name.contains(name)) acc.append(field)
36 +
        case method: AnnotatedMethod =>
37 +
          if (method.name.contains(name)) acc.append(method)
38 +
        case clazz: AnnotatedClass =>
39 +
          if (clazz.name.contains(name)) acc.append(clazz)
40 +
          clazz.findAnnotatedMembersByName(name, acc)
41 +
      }
42 +
      i += 1
43 +
    }
44 +
45 +
    acc.toArray
46 +
  }
47 +
48 +
  def getAnnotationsForAnnotatedMember(name: String): Array[Annotation] = {
49 +
    findAnnotatedMembersByName(name).flatMap(getAnnotationForAnnotatedMember)
50 +
  }
51 +
52 +
  private[this] def getAnnotationForAnnotatedMember(
53 +
    annotatedMember: AnnotatedMember
54 +
  ): Array[Annotation] =
55 +
    annotatedMember match {
56 +
      case f: AnnotatedField =>
57 +
        f.fieldValidators.map(_.annotation)
58 +
      case c: AnnotatedClass =>
59 +
        c.members.flatMap(getAnnotationForAnnotatedMember)
60 +
      case m: AnnotatedMethod =>
61 +
        Array(m.annotation)
62 +
    }
63 +
}

@@ -73,4 +73,18 @@
Loading
73 73
    }
74 74
    TypeTag[T](clazzMirror, typeCreator)
75 75
  }
76 +
77 +
  /**
78 +
   * If the given [[java.lang.reflect.Type]] is parameterized, return an Array of the
79 +
   * type parameter names. E.g., `Map<T, U>` returns, `Array("T", "U")`.
80 +
   */
81 +
  private[twitter] def parameterizedTypeNames(`type`: java.lang.reflect.Type): Array[String] =
82 +
    `type` match {
83 +
      case pt: java.lang.reflect.ParameterizedType =>
84 +
        pt.getActualTypeArguments.map(_.getTypeName)
85 +
      case tv: java.lang.reflect.TypeVariable[_] =>
86 +
        Array(tv.getTypeName)
87 +
      case _ =>
88 +
        Array.empty
89 +
    }
76 90
}

@@ -11,7 +11,7 @@
Loading
11 11
private[validation] object PastTimeConstraintValidator {
12 12
13 13
  def errorMessage(resolver: MessageResolver, value: DateTime): String =
14 -
    resolver.resolve(classOf[PastTime], value)
14 +
    resolver.resolve[PastTime](value)
15 15
}
16 16
17 17
/**

@@ -15,7 +15,10 @@
Loading
15 15
import javax.annotation.Nullable
16 16
import javax.inject.Singleton
17 17
18 -
object ScalaObjectMapperModule extends ScalaObjectMapperModule
18 +
object ScalaObjectMapperModule extends ScalaObjectMapperModule {
19 +
  // java-friendly access to singleton
20 +
  def get(): this.type = this
21 +
}
19 22
20 23
/**
21 24
 * [[TwitterModule]] to configure Jackson object mappers. Extend this module to override defaults

@@ -10,7 +10,7 @@
Loading
10 10
private[validation] object RangeConstraintValidator {
11 11
12 12
  def errorMessage(resolver: MessageResolver, value: Any, minValue: Long, maxValue: Long): String =
13 -
    resolver.resolve(classOf[Range], value, minValue, maxValue)
13 +
    resolver.resolve[Range](value, minValue, maxValue)
14 14
}
15 15
16 16
/**
@@ -29,6 +29,7 @@
Loading
29 29
    val rangeAnnotation = annotation.asInstanceOf[Range]
30 30
    val minValue = rangeAnnotation.min()
31 31
    val maxValue = rangeAnnotation.max()
32 +
    assertValidRange(minValue, maxValue)
32 33
    value match {
33 34
      case bigDecimalValue: BigDecimal =>
34 35
        validationResult(bigDecimalValue, minValue, maxValue)
@@ -38,7 +39,7 @@
Loading
38 39
        validationResult(numberValue, minValue, maxValue)
39 40
      case _ =>
40 41
        throw new IllegalArgumentException(
41 -
          s"Class [${value.getClass}] is not supported by ${this.getClass}")
42 +
          s"Class [${value.getClass.getName}] is not supported by ${this.getClass.getName}")
42 43
    }
43 44
  }
44 45
@@ -72,4 +73,9 @@
Loading
72 73
      errorCode(value, minValue, maxValue)
73 74
    )
74 75
  }
76 +
77 +
  /** Asserts that the start is less than the end */
78 +
  private[this] def assertValidRange(startIndex: Long, endIndex: Long): Unit =
79 +
    if (startIndex > endIndex)
80 +
      throw new IllegalArgumentException(s"invalid range: $startIndex > $endIndex")
75 81
}

@@ -1,47 +1,93 @@
Loading
1 1
package com.twitter.finatra.validation
2 2
3 +
import com.twitter.util.logging.Logger
4 +
import com.twitter.util.{Return, Throw, Try}
3 5
import java.lang.annotation.Annotation
4 6
5 7
sealed trait ValidationResult {
8 +
  def path: Path
6 9
  def isValid: Boolean
7 10
  def annotation: Option[Annotation]
8 11
}
9 12
10 13
object ValidationResult {
14 +
  private[this] lazy val logger = Logger(ValidationResult.getClass)
11 15
12 16
  case class Valid(
13 17
    override val annotation: Option[Annotation] = None)
14 18
      extends ValidationResult {
15 19
    override val isValid: Boolean = true
20 +
    override val path: Path = Path.Empty
16 21
  }
17 22
18 23
  case class Invalid(
19 24
    message: String,
20 25
    code: ErrorCode = ErrorCode.Unknown,
26 +
    path: Path = Path.Empty,
21 27
    override val annotation: Option[Annotation] = None)
22 28
      extends ValidationResult {
23 29
    override val isValid = false
24 30
  }
25 31
26 -
  @deprecated(
27 -
    "Use ValidationResult.validate() to test a condition that must be true, validateNot() otherwise",
28 -
    "2015-11-04"
29 -
  )
30 -
  def apply(
31 -
    isValid: Boolean,
32 -
    message: => String,
33 -
    code: ErrorCode = ErrorCode.Unknown
34 -
  ): ValidationResult = validate(isValid, message, code)
35 -
32 +
  /**
33 +
   * Utility for evaluating a condition in order to return a [[ValidationResult]]. Returns
34 +
   * [[ValidationResult.Valid]] when the condition is `true`, otherwise if the condition
35 +
   * evaluates to `false` or throws an exception a [[ValidationResult.Invalid]] will be returned.
36 +
   * In the case of an exception, the `exception.getMessage` is used in place of the given message.
37 +
   *
38 +
   * @note This will not allow a non-fatal exception to escape. Instead a [[ValidationResult.Invalid]]
39 +
   *       will be returned when a non-fatal exception is encountered when evaluating `condition`. As
40 +
   *       this equates failure to execute the condition function to a return of `false`.
41 +
   *
42 +
   * @param condition function to evaluate for validation.
43 +
   * @param message function to evaluate for a message when the given condition is `false`.
44 +
   * @param code [[ErrorCode]] to use for when the given condition is `false`.
45 +
   *
46 +
   * @return a [[ValidationResult.Valid]] when the condition is `true` otherwise a [[ValidationResult.Invalid]].
47 +
   */
36 48
  def validate(
37 -
    condition: Boolean,
49 +
    condition: => Boolean,
38 50
    message: => String,
39 51
    code: ErrorCode = ErrorCode.Unknown
40 -
  ): ValidationResult = if (condition) Valid() else Invalid(message, code)
52 +
  ): ValidationResult = Try(condition) match {
53 +
    case Return(result) if result =>
54 +
      Valid()
55 +
    case Return(result) if !result =>
56 +
      Invalid(message, code)
57 +
    case Throw(e) =>
58 +
      logger.warn(e.getMessage, e)
59 +
      Invalid(e.getMessage, code)
60 +
  }
41 61
62 +
  /**
63 +
   * Utility for evaluating the negation of a condition in order to return a [[ValidationResult]].
64 +
   * Returns [[ValidationResult.Valid]] when the condition is `false`, otherwise if the condition
65 +
   * evaluates to `true` or throws an exception a [[ValidationResult.Invalid]] will be returned.
66 +
   * In the case of an exception, the `exception.getMessage` is used in place of the given message.
67 +
   *
68 +
   * @note This will not allow a non-fatal exception to escape. Instead a [[ValidationResult.Valid]]
69 +
   *       will be returned when a non-fatal exception is encountered when evaluating `condition`. As
70 +
   *       this equates failure to execute the condition to a return of `false`.
71 +
   *
72 +
   * @param condition function to evaluate for validation.
73 +
   * @param message function to evaluate for a message when the given condition is `true`.
74 +
   * @param code [[ErrorCode]] to use for when the given condition is `true`.
75 +
   *
76 +
   * @return a [[ValidationResult.Valid]] when the condition is `false` or when the condition evaluation
77 +
   *         throws a NonFatal exception otherwise a [[ValidationResult.Invalid]].
78 +
   */
42 79
  def validateNot(
43 -
    condition: Boolean,
80 +
    condition: => Boolean,
44 81
    message: => String,
45 82
    code: ErrorCode = ErrorCode.Unknown
46 -
  ): ValidationResult = validate(!condition, message, code)
83 +
  ): ValidationResult =
84 +
    Try(condition) match {
85 +
      case Return(result) if !result =>
86 +
        Valid()
87 +
      case Return(result) if result =>
88 +
        Invalid(message, code)
89 +
      case Throw(e) =>
90 +
        logger.warn(e.getMessage, e)
91 +
        Valid()
92 +
    }
47 93
}

@@ -12,7 +12,7 @@
Loading
12 12
private[validation] object UUIDConstraintValidator {
13 13
14 14
  def errorMessage(resolver: MessageResolver, value: String): String =
15 -
    resolver.resolve(classOf[UUID], value)
15 +
    resolver.resolve[UUID](value)
16 16
17 17
  def isValid(value: String): Boolean = Try(JUUID.fromString(value)).isReturn
18 18
}

@@ -10,18 +10,21 @@
Loading
10 10
private[validation] object SizeConstraintValidator {
11 11
12 12
  def errorMessage(resolver: MessageResolver, value: Any, minValue: Long, maxValue: Long): String =
13 -
    resolver.resolve(classOf[Size], toErrorValue(value), minValue, maxValue)
13 +
    resolver.resolve[Size](toErrorValue(value), minValue, maxValue)
14 14
15 15
  private def toErrorValue(value: Any): Int =
16 16
    value match {
17 17
      case arrayValue: Array[_] =>
18 18
        arrayValue.length
19 -
      case traversableValue: Traversable[_] =>
19 +
      case mapValue: Map[_, _] =>
20 +
        mapValue.size
21 +
      case traversableValue: Iterable[_] =>
20 22
        traversableValue.size
21 23
      case str: String =>
22 24
        str.length
23 25
      case _ =>
24 -
        throw new IllegalArgumentException(s"Class [${value.getClass}] is not supported")
26 +
        throw new IllegalArgumentException(
27 +
          s"Class [${value.getClass.getName}] is not supported by ${this.getClass.getName}")
25 28
    }
26 29
}
27 30
@@ -43,10 +46,12 @@
Loading
43 46
    val maxValue: Long = sizeAnnotation.max()
44 47
    val size = value match {
45 48
      case arrayValue: Array[_] => arrayValue.length
46 -
      case traversableValue: Traversable[_] => traversableValue.size
49 +
      case mapValue: Map[_, _] => mapValue.size
50 +
      case traversableValue: Iterable[_] => traversableValue.size
47 51
      case str: String => str.length
48 52
      case _ =>
49 -
        throw new IllegalArgumentException(s"Class [${value.getClass}] is not supported")
53 +
        throw new IllegalArgumentException(
54 +
          s"Class [${value.getClass.getName}] is not supported by ${this.getClass.getName}")
50 55
    }
51 56
52 57
    ValidationResult.validate(

@@ -37,7 +37,7 @@
Loading
37 37
    clazz: Class[_],
38 38
    constructor: Executable,
39 39
    clazzDescriptor: ClassDescriptor,
40 -
    propertyDefinitions: Array[PropertyDefinition],
40 +
    propertyDefinitions: Array[CaseClassDeserializer.PropertyDefinition],
41 41
    fieldAnnotations: scala.collection.Map[String, Array[Annotation]],
42 42
    namingStrategy: PropertyNamingStrategy,
43 43
    typeFactory: TypeFactory,

@@ -2,6 +2,7 @@
Loading
2 2
3 3
import java.lang.annotation.Annotation
4 4
import java.util.Properties
5 +
import scala.reflect.ClassTag
5 6
6 7
/**
7 8
 * To resolve error messages for the type of validation failure. May be pattern-matched
@@ -11,9 +12,9 @@
Loading
11 12
 */
12 13
class MessageResolver {
13 14
14 -
  val validationProperties: Properties = load
15 +
  private[validation] val validationProperties: Properties = load
15 16
16 -
  //TODO: Use [T <: Annotation : Manifest] instead of clazz
17 +
  @deprecated("Use resolve[Ann <: Annotation](values: Any*)", "2020-10-09")
17 18
  def resolve(clazz: Class[_ <: Annotation], values: Any*): String = {
18 19
    val unresolvedMessage = validationProperties.getProperty(clazz.getName)
19 20
    if (unresolvedMessage == null)
@@ -22,19 +23,36 @@
Loading
22 23
      unresolvedMessage.format(values: _*)
23 24
  }
24 25
25 -
  private def load: Properties = {
26 +
  /**
27 +
   * Resolve the passed object reference for a given Constraint [[Annotation]] type.
28 +
   * @param values object references to resolve using the reference Constraint [[Annotation]].
29 +
   * @param clazzTag implicit [[ClassTag]] for the given [[Annotation]] type param.
30 +
   * @tparam Ann the Constraint [[Annotation]] to use for message resolution.
31 +
   * @return resolved [[String]] from the inputs.
32 +
   */
33 +
  def resolve[Ann <: Annotation](values: Any*)(implicit clazzTag: ClassTag[Ann]): String = {
34 +
    // Note: the method signature is equivalent to `def resolve[Ann <: Annotation: ClassTag](..): String`
35 +
    val clazz = clazzTag.runtimeClass
36 +
    val unresolvedMessage = validationProperties.getProperty(clazz.getName)
37 +
    if (unresolvedMessage == null)
38 +
      "unable to resolve error message due to unknown validation annotation: " + clazz
39 +
    else
40 +
      unresolvedMessage.format(values: _*)
41 +
  }
42 +
43 +
  private[this] def load: Properties = {
26 44
    val properties = new Properties()
27 45
    loadBaseProperties(properties)
28 46
    loadPropertiesFromClasspath(properties)
29 47
    properties
30 48
  }
31 49
32 -
  private def loadBaseProperties(properties: Properties): Unit = {
50 +
  private[this] def loadBaseProperties(properties: Properties): Unit = {
33 51
    properties.load(
34 52
      getClass.getResourceAsStream("/com/twitter/finatra/validation/validation.properties"))
35 53
  }
36 54
37 -
  private def loadPropertiesFromClasspath(properties: Properties): Unit = {
55 +
  private[this] def loadPropertiesFromClasspath(properties: Properties): Unit = {
38 56
    val validationPropertiesUrl = getClass.getResource("/validation.properties")
39 57
    if (validationPropertiesUrl != null) {
40 58
      properties.load(validationPropertiesUrl.openStream())

@@ -29,19 +29,20 @@
Loading
29 29
import com.twitter.finatra.validation.ValidationResult.Invalid
30 30
import com.twitter.finatra.validation.internal.{
31 31
  FieldValidator,
32 -
  AnnotatedClass => ValidationAnnotatedClass,
33 32
  AnnotatedField => ValidationAnnotatedField,
33 +
  AnnotatedMember => ValidationAnnotatedMember,
34 34
  AnnotatedMethod => ValidationAnnotatedMethod
35 35
}
36 36
import com.twitter.finatra.validation.{
37 -
  MethodValidation,
37 +
  Path,
38 38
  ValidationResult,
39 39
  Validator,
40 40
  ErrorCode => ValidationErrorCode
41 41
}
42 -
import com.twitter.inject.Logging
42 +
import com.twitter.inject.{Logging, TypeUtils}
43 43
import com.twitter.inject.domain.WrappedValue
44 44
import com.twitter.inject.utils.AnnotationUtils
45 +
import com.twitter.util.Try
45 46
import java.lang.annotation.Annotation
46 47
import java.lang.reflect.{
47 48
  Constructor,
@@ -56,26 +57,36 @@
Loading
56 57
import javax.annotation.concurrent.ThreadSafe
57 58
import org.json4s.reflect.{ClassDescriptor, ConstructorDescriptor, Reflector, ScalaType}
58 59
import scala.collection.JavaConverters._
59 -
import scala.collection.mutable
60 +
import scala.collection.{Map, mutable}
60 61
import scala.util.control.NonFatal
61 62
62 -
/* For supporting JsonCreator */
63 -
private case class CaseClassCreator(
64 -
  executable: Executable,
65 -
  propertyDefinitions: Array[PropertyDefinition])
66 -
67 -
/* Holder for a fully specified JavaType with generics and a Jackson BeanPropertyDefinition */
68 -
private case class PropertyDefinition(
69 -
  javaType: JavaType,
70 -
  scalaType: ScalaType,
71 -
  beanPropertyDefinition: BeanPropertyDefinition)
72 -
73 -
/* Holder of constructor arg to ScalaType */
74 -
private case class ConstructorParam(name: String, scalaType: ScalaType)
75 -
76 63
private object CaseClassDeserializer {
77 64
  // For reporting an InvalidDefinitionException
78 65
  val EmptyJsonParser: JsonParser = new JsonFactory().createParser("")
66 +
67 +
  /* For supporting JsonCreator */
68 +
  case class CaseClassCreator(
69 +
    executable: Executable,
70 +
    propertyDefinitions: Array[PropertyDefinition])
71 +
72 +
  /* Holder for a fully specified JavaType with generics and a Jackson BeanPropertyDefinition */
73 +
  case class PropertyDefinition(
74 +
    javaType: JavaType,
75 +
    scalaType: ScalaType,
76 +
    beanPropertyDefinition: BeanPropertyDefinition)
77 +
78 +
  /* Holder of constructor arg to ScalaType */
79 +
  case class ConstructorParam(name: String, scalaType: ScalaType)
80 +
81 +
  case class ValidationDescriptor(
82 +
    clazz: Class[_],
83 +
    path: Path,
84 +
    fields: Map[String, ValidationAnnotatedField],
85 +
    methods: Array[ValidationAnnotatedMethod])
86 +
      extends ValidationAnnotatedMember {
87 +
    // the name is tracked by the deserializer
88 +
    override final val name: Option[String] = None
89 +
  }
79 90
}
80 91
81 92
/**
@@ -123,6 +134,7 @@
Loading
123 134
  validator: Option[Validator])
124 135
    extends JsonDeserializer[AnyRef]
125 136
    with Logging {
137 +
  import CaseClassDeserializer._
126 138
127 139
  private[this] val clazz: Class[_] = javaType.getRawClass
128 140
  // we explicitly do not read a mix-in for a primitive type
@@ -249,14 +261,45 @@
Loading
249 261
    collectedFieldAnnotations
250 262
  }
251 263
252 -
  private[this] val annotatedValidationClazz: Option[ValidationAnnotatedClass] =
264 +
  private[this] val annotatedValidationClazz: Option[ValidationDescriptor] =
253 265
    validator match {
254 266
      case Some(v) =>
255 -
        Some(v.createAnnotatedClass(clazz, fieldAnnotations))
267 +
        Some(createConstructionValidationClass(v, clazz, fieldAnnotations))
256 268
      case _ =>
257 269
        None
258 270
    }
259 271
272 +
  private[this] def createConstructionValidationClass(
273 +
    validator: Validator,
274 +
    clazz: Class[_],
275 +
    annotationsMap: Map[String, Array[Annotation]]
276 +
  ): ValidationDescriptor = {
277 +
    val annotatedFieldsMap = for {
278 +
      (name, annotations) <- annotationsMap
279 +
    } yield {
280 +
      val fieldValidators = for {
281 +
        annotation <- annotations if Validator.isConstraintAnnotation(annotation)
282 +
      } yield validator.findFieldValidator(annotation)
283 +
284 +
      (
285 +
        name,
286 +
        ValidationAnnotatedField(
287 +
          Some(name),
288 +
          Path(name),
289 +
          // the annotation may be from a static or secondary constructor
290 +
          // for which we have no field information
291 +
          Try(clazz.getDeclaredField(name)).toOption,
292 +
          fieldValidators
293 +
        )
294 +
      )
295 +
    }
296 +
    ValidationDescriptor(
297 +
      clazz,
298 +
      Path.Empty,
299 +
      annotatedFieldsMap,
300 +
      Validator.getMethodValidations(clazz))
301 +
  }
302 +
260 303
  /* exposed for testing */
261 304
  private[jackson] val fields: Array[CaseClassField] =
262 305
    CaseClassField.createFields(
@@ -438,7 +481,8 @@
Loading
438 481
439 482
        annotatedValidationClazz match {
440 483
          case Some(annotatedClazz) if annotatedClazz.fields.nonEmpty =>
441 -
            val fieldValidationErrors = executeFieldValidations(value, field, annotatedClazz.fields)
484 +
            val fieldValidationErrors =
485 +
              executeFieldValidations(value, field, annotatedClazz.fields)
442 486
            append(errors, fieldValidationErrors)
443 487
          case _ => // do nothing
444 488
        }
@@ -619,9 +663,10 @@
Loading
619 663
    validator match {
620 664
      case Some(v) =>
621 665
        for {
622 -
          invalid @ Invalid(_, _, _) <- v.validateField(
666 +
          invalid @ Invalid(_, _, _, _) <- v.validateField(
623 667
            value,
624 -
            fields.get(field.beanPropertyDefinition.getInternalName) match {
668 +
            field.beanPropertyDefinition.getInternalName,
669 +
            fieldValidators = fields.get(field.beanPropertyDefinition.getInternalName) match {
625 670
              case Some(annotatedField) => annotatedField.fieldValidators
626 671
              case _ => Array.empty[FieldValidator]
627 672
            }
@@ -648,41 +693,35 @@
Loading
648 693
    obj: Any,
649 694
    methods: Array[ValidationAnnotatedMethod]
650 695
  ): Unit = {
651 -
    validator match {
652 -
      case Some(v) =>
653 -
        def extractFieldsFromMethodValidation(annotation: Option[Annotation]): Iterable[String] = {
654 -
          annotation match {
655 -
            case Some(methodValidation) if methodValidation.isInstanceOf[MethodValidation] =>
656 -
              methodValidation.asInstanceOf[MethodValidation].fields.toIterable.filter(_.nonEmpty)
657 -
            case _ =>
658 -
              Iterable.empty[String]
659 -
          }
660 -
        }
661 -
662 -
        val results = v.validateMethods(obj, methods)
663 -
        if (results.nonEmpty) {
664 -
          val methodValidationErrors: Seq[Iterable[CaseClassFieldMappingException]] = for {
665 -
            result <- results if !result.isValid
666 -
            invalid = result.asInstanceOf[Invalid]
667 -
            caseClassFields = extractFieldsFromMethodValidation(invalid.annotation)
668 -
            propertyPaths = caseClassFields.map(CaseClassFieldMappingException.PropertyPath.leaf)
669 -
            exceptions = propertyPaths.map(CaseClassFieldMappingException(_, invalid))
670 -
          } yield {
671 -
            if (exceptions.isEmpty) {
696 +
    if (validator.isDefined) {
697 +
      // only run method validations if we have a configured Validator
698 +
      val methodValidationErrors =
699 +
        new mutable.ArrayBuffer[Iterable[CaseClassFieldMappingException]]()
700 +
      Validator.validateMethods(obj, methods) {
701 +
        case (_, invalid) =>
702 +
          val caseClassFields = Validator.extractFieldsFromMethodValidation(invalid.annotation)
703 +
          val exceptions = caseClassFields
704 +
            .map(
705 +
              CaseClassFieldMappingException.PropertyPath.leaf
706 +
            ) // per field, create a new PropertyPath with the given field as the leaf
707 +
            .map(
708 +
              CaseClassFieldMappingException(_, invalid)
709 +
            ) // create a new CaseClassFieldMappingException
710 +
          // with the given PropertyPath and the ValidationResult.Invalid
711 +
          if (exceptions.isEmpty) {
712 +
            methodValidationErrors.append(
672 713
              Seq(
673 714
                CaseClassFieldMappingException(
674 715
                  CaseClassFieldMappingException.PropertyPath.Empty,
675 -
                  invalid))
676 -
            } else {
677 -
              exceptions
678 -
            }
716 +
                  invalid)))
717 +
          } else {
718 +
            methodValidationErrors.append(exceptions)
679 719
          }
720 +
      }
680 721
681 -
          if (methodValidationErrors.nonEmpty) {
682 -
            throw CaseClassMappingException(fieldErrors.toSet ++ methodValidationErrors.flatten)
683 -
          }
684 -
        }
685 -
      case _ => // do nothing
722 +
      if (methodValidationErrors.nonEmpty) {
723 +
        throw CaseClassMappingException(fieldErrors.toSet ++ methodValidationErrors.flatten)
724 +
      }
686 725
    }
687 726
  }
688 727
@@ -753,7 +792,8 @@
Loading
753 792
          shouldFullyDefineParameterizedType(scalaType, parameter)) {
754 793
          // what types are bound to the generic case class parameters
755 794
          val boundTypeParameters: Array[JavaType] =
756 -
            parameterizedTypeNames(parameter.getParameterizedType)
795 +
            TypeUtils
796 +
              .parameterizedTypeNames(parameter.getParameterizedType)
757 797
              .map(javaType.getBindings.findBoundType)
758 798
          Types
759 799
            .javaType(
@@ -814,16 +854,6 @@
Loading
814 854
    None
815 855
  }
816 856
817 -
  // for use in mapping the case class type parameters to the current type binding
818 -
  private[this] def parameterizedTypeNames(`type`: Type): Array[String] = `type` match {
819 -
    case pt: ParameterizedType =>
820 -
      pt.getActualTypeArguments.map(_.getTypeName)
821 -
    case tv: TypeVariable[_] =>
822 -
      Array(tv.getTypeName)
823 -
    case _ =>
824 -
      Array.empty
825 -
  }
826 -
827 857
  // if we need to attempt to fully specify the JavaType because it is generically types
828 858
  private[this] def shouldFullyDefineParameterizedType(
829 859
    scalaType: ScalaType,

@@ -13,7 +13,7 @@
Loading
13 13
private[validation] object TimeGranularityConstraintValidator {
14 14
15 15
  def errorMessage(resolver: MessageResolver, timeGranularity: TimeUnit, value: DateTime): String =
16 -
    resolver.resolve(classOf[TimeGranularity], value, singularize(timeGranularity))
16 +
    resolver.resolve[TimeGranularity](value, singularize(timeGranularity))
17 17
18 18
  private def singularize(timeUnit: TimeUnit): String = {
19 19
    val timeUnitStr = timeUnit.toString.toLowerCase
2292.2
TRAVIS_OS_NAME=linux
openjdk8=
2292.1
TRAVIS_OS_NAME=linux
openjdk8=
2292.4
openjdk11=
TRAVIS_OS_NAME=linux
2292.3
openjdk11=
TRAVIS_OS_NAME=linux

No yaml found.

Create your codecov.yml to customize your Codecov experience

Sunburst
The inner-most circle is the entire project, moving away from the center are folders then, finally, a single file. The size and color of each slice is representing the number of statements and the coverage, respectively.
Icicle
The top section represents the entire project. Proceeding with folders and finally individual files. The size and color of each slice is representing the number of statements and the coverage, respectively.