Validation Framework

Finatra provides a simple validation framework inspired by JSR-303 and JSR-380.

Constraints

Constraints are Java interfaces that can be used to annotate a case class field or case class method with specific validation criteria. Similar to JSR-380 specification, the validation framework supports the following built in constraints (for adding new Constraints, see Defining Additional Constraints):

Using a Validator

The validation framework can be used without any dependencies. You can validate a case class’s fields and methods that are annotated with any built in or additional constraints by simply instantiating a Validator and call validate() with the case class.

The call will return a Unit when all validations pass. Otherwise, it will throw a ValidationException, where the errors field contains a list of invalid ValidationResult.

For instance, assuming we have the following case class defined:

case class Things(@Size(min = 1, max = 2) names: Seq[String])

We can now validate that an instance conforms to the constraints, e.g.,

val myThings = Things(Seq.empty[String])
val validator = injector.instance[Validator]
try {
  validator.validate(myThings)
} catch {
  case e: ValidationException =>
    error(e, e.getMessage)
}

Here the ValidationException would get thrown since the instance does not conform to the constraints. You can iterate over the contained ValidationException#errors to see all the errors which triggered a failed validation. Whereas the following would pass:

val myThings = Things(Seq(“Bob Vila”))
val validator = injector.instance[Validator]
try {
  validator.validate(myThings)
} catch {
  case e: ValidationException =>
    error(e, e.getMessage)
}

Warning

Case classes with multiple constructors or multiple constructor lists with non-public constructor args are not-supported.

Instantiate a Validator

There are 2 ways to obtain a Validator instance:

  • Through dependency injection. Finatra HttpServer injects a default Validator, you can override it by providing a customized Validator in a TwitterModule, and add that module to your server definition. Please checkout Injecting a customized Validator for instructions.
  • Create a Validator instance in the air when you need it. You can call Validator() to obtain a validator instance with default MessageResolver and default cache size, or create a validator instance with customized messageResolver by calling Validator(messageResolver). You can also leverage Validator.Builder to customize more attributes of the Validator including cacheSize. Please see the example on how to use Builder.

Note

Instantiating a Validator through dependency injection is the recommended way to use the framework. Please avoid creating a Validator in the air if you have already injected it in the object graph.

Define additional constraints

To define new constraints, you can extend the Constraint interface, and define a matching ConstraintValidator that is referred to in the validatedBy field of the new constraint definition.

Define an additional Constraint:

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

import com.twitter.finatra.validation.Constraint;

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = StateConstraintValidator.class)
public @interface StateConstraint {}

Define an additional ConstraintValidator:

import com.twitter.finatra.validation.{ConstraintValidator, MessageResolver, ValidationResult}

class StateConstraintValidator(messageResolver: MessageResolver)
    extends ConstraintValidator[StateConstraint, String](messageResolver) {

  override def isValid(annotation: StateConstraint, value: String): ValidationResult =
    ValidationResult.validate(
      value.equalsIgnoreCase("CA"),
      "Please register with state CA"
    )
}

The validation framework will locate the new StateConstraint and perform the validation logic defined in its matching StateConstraintValidator automatically at run time.

Injecting a customized Validator

You can switch to use another MessageResolver or change the cacheSize of the default Validator.

Provide a customized Validator in a TwitterModule:

import com.twitter.finatra.validation.{Validator, ValidatorModule}
import com.twitter.inject.Injector

object CustomizedValidatorModule extends ValidatorModule {
  override def configureValidator(injector: Injector, builder: Validator.Builder): Validator.Builder =
    builder
      .withCacheSize(512)
      .withMessageResolver(new CustomizedMessageResolver())
}

Override validatorModule in your server definition:

ValidationServer extends HttpServer {
  override val name: String = "validation-server"
  override def validatorModule: TwitterModule = CustomizedValidatorModule

  override protected def configureHttp(router: HttpRouter): Unit = {
    router
      .filter[CommonFilters]
      .add[ValidationController]
      ...
  }
}

Note

By overriding the default validatorModule, you are also replacing the default Validator in the jacksonModule. The newly defined Validator will be used to apply the validation logic in the ScalaObjectMapper and during HTTP request parsing.

See Integration with Finatra Jackson Support for more information about how validations works in Finatra Jackson Framework.

Integration with Finatra Jackson Support

The validation framework integrates with Finatra’s custom case class deserializer to efficiently apply per field and method validations as request parsing is performed.

Assume you have the following HTTP request case class defined:

case class ValidateUserRequest(
  @NotEmpty @Pattern(regexp = "[a-z]+") userName: String,
  @Max(value = 9999) id: Long,
  title: String
)

And in your controller, you define a Post endpoint as:

post("/validate_user") { _: ValidateUserRequest =>
  ...
}

When you perform a call to the POST /validate_user/, Finatra will deserialize the JSON you passed to the call to a ValidationUserRequest case class, and perform validations of the annotated fields. If any validation fails, the case class will not be created and a CaseClassMappingException will be thrown.

For more information, please refer to JSON Validation Framework.

Method Validations

A method validation is a case class method annotated with @MethodValidation which is intended to be used for validating fields of the cases class. Reasons to use a method validation include:

  • For non-generic validations. @MethodValidation can be used instead of defining a reusable annotation and validator.
  • Cross-field validations (e.g. startDate before endDate)

For an example see the User test case class.

The @MethodValidation annotation also supports specifying an optional fields parameter to state which fields are being evaluated in the validation. If the evaluation fails the resulting exception will contain details about each of the fields specified in the annotation.

Best Practices, Guidance & Limitations

Case classes are generally expected to be defined like simple POJOs, or JavaBeans (but without the need to implement java.io.Serializable). Constraints are expected to be defined on case class constructor fields.

  • When defining constraints, case classes MUST be defined with a single constructor. If multiple constructors are defined, there is currently no way to signal to the Validation framework which constructor should be used for finding constraint annotations.

  • When defining constraints, case classes MUST be defined with all constructors args publicly visible. E.g., a constructor defined like so:

    case class DoublePerson(@NotEmpty name: String)(@NotEmpty otherName: String)
    

    will not work properly as second parameter list fields by default are not publicly visible. If you find you need to do this case, you will need to explicitly make the fields in the second parameter list visible:

    case class DoublePerson(@NotEmpty name: String)(@NotEmpty val otherName: String)
    
  • Nested case classes are currently not supported. That is, validation will not occur for fields which are case classes themselves with constructor field annotated with constraints. E.g.,

    case class Bar(@Max(1000) id: Int, @NonEmpty name: String)
    
    case Foo(@Min(1) id: Int, bar: Bar)
    

    Bar validations will not be performed. We hope to support the case of validating nested case classes in a future version of the API.

    Important

    However, note when used within the Finatra Jackson Integration, nested case class validations will be performed due to the library’s integration with Jackson’s deserializers.