La Password Validation Kata consiste en escribir un programa para comprobar que una contraseña cumple con ciertas reglas de validación. La kata está dividida en 4 problemas que deben ser resueltos de manera individual, y siguiendo el orden en que están planteados los mismos. Cada uno de los problemas introduce retos nuevos, y por eso es importante utilizar solamente la información disponible en cada momento. Por ejemplo, para resolver el problema de la segunda iteración se debería partir de la solución de la primera iteración, sin tener en cuenta los requisitos de las iteraciones posteriores. Se debe aplicar TDD para solucionar la kata.

Además de lo anterior, la kata propone aplicar principios de programación orientada a objetos, patrones de diseño como Builder, Factory y Strategy, y también se pueden aplicar limitaciones adicionales como object calisthenics. En su lugar, yo voy a utilizar programación funcional.

El código completo de mi solución está en este repositorio de GitHub.

Primera iteración: Validación básica#

La primera iteración consiste en comprobar si una contraseña tiene más de 8 caracteres, entre los cuales se encuentran: una letra mayúscula, una letra minúscula, un número cualquiera y un guión bajo.

Además de las restricciones de la contraseña, se nos plantean unos requisitos técnicos adicionales: para la solución se debe escribir un método que devuelva un valor de tipo Boolean, indicando solamente si la contraseña es válida o no, pero no las razones por las que una contraseña es inválida.

Este comportamiento se puede expresar de la siguiente forma:

sealed trait ValidatePassword:
  def isValid(password: Password): Boolean

Para la implementación voy a usar el data type Validated de la librería cats. Validated es muy parecida a Either porque toma dos posibles valores: errores en el lado izquierdo y valores válidos en el lado derecho.

La ventaja principal de Validated es que permite reportar todos los posibles errores de validación de forma simultánea. Además de la documentación de cats, hay varios recursos que explican cómo usar Validated para acumular errores en Scala.

La implementación de ValidatePassword es la siguiente:

object ValidatePassword:
  def impl: ValidatePassword = new ValidatePassword:
    override def isValid(password: Password): Boolean =
      (
        hasMinimumLength(9)(password),
        containsAtLeastOneUppercaseLetter(password),
        containsAtLeastOneLowercaseLetter(password),
        containsAnyNumber(password),
        containsAnUnderscore(password),
      ).mapN((_, _, _, _, _) => password).isValid

Cada uno de los elementos de la tupla es una función que toma como argumento un Password y da como resultado un ValidatedNec[PasswordValidationError, Password]. Por ejemplo, la definición de hasMinimumLength es la siguiente:

  private[this] def hasMinimumLength(
      minimumLength: Int
  ): ValidatedNec[PasswordValidationError, Password] =
    (password: Password) =>
      if password.value.length >= minimumLength then password.validNec
      else PasswordHasMinimumLength(minimumLength).invalidNec

Esta es una definición es muy sencilla, con la mayoría de los detalles expresados en los tipos:

  1. ValidatedNec[PasswordValidationError, Password] es un alias de Validated[NonEmptyChain[PasswordValidationError], Password]. He usado este tipo para acumular los posibles errores en una estructura de tipo NonEmptyChain, una Chain que contiene al menos 1 elemento. Chain es una estructura de datos que permite agregar un elemento con coste constante a una lista, tanto al principio como al final de la misma.

  2. El tipo PasswordValidationError define los tipos de error con los que puede fallar la validación:

    @SuppressWarnings(Array("org.wartremover.warts.Null"))
    sealed abstract class PasswordValidationError(
        message: String,
        cause: Option[Throwable] = Option.empty[Throwable],
    ) extends NoStackTrace:
      import scala.language.unsafeNulls
      override def getCause: Throwable = cause.orNull
      override def getMessage: String = message
    

    Según la documentación de java.lang.Throwable, el método getCause: “returns the cause of this throwable or null if the cause is nonexistent or unknown”. Por esta razón, el tipo PasswordValidationError utiliza las nuevas características explicit nulls de Scala 3 para hacer explícito este comportamiento, además de excluir este código de la comprobación que hace el linter de WartRemover.

    Las implementaciones de PasswordValidationError son las siguientes:

    object PasswordValidationError:
      final case class PasswordHasMinimumLength(minimumLength: Int)
          extends PasswordValidationError(s"Has at least $minimumLength characters")
    
      case object PasswordContainsAtLeastOneUpperCaseLetter
          extends PasswordValidationError("Contains a capital letter")
    
      case object PasswordContainsAtLeastOneLowerCaseLetter
          extends PasswordValidationError("Contains a lowercase letter")
    
      case object PasswordContainsAnyNumber 
          extends PasswordValidationError("Contains a number")
    
      case object PasswordContainsAnUnderscore 
          extends PasswordValidationError("Contains an underscore")
    
  3. El tipo Password no es más que un opaque type alias con valor String:

opaque type Password = String

object Password:
  extension (password: Password) def value: String = password

Los siguientes componentes de cats son necesarios para esta aplicación:

import cats.data.{Validated, ValidatedNec}
import cats.syntax.all.*

Segunda iteración: Nuevas reglas de validación#

Además de las reglas iniciales, en la segunda iteración nuestra aplicación tiene que soportar 2 nuevas formas de validación.

Antes de hacer este cambio, voy a introducir nuevos alias para mejorar la ergonomía de mi código:

  type AllErrorsOr[A] = ValidatedNec[PasswordValidationError, A]

  type ConstraintIn[A] = A => AllErrorsOr[A]

Usando estos alias he escrito un nuevo método validateWith que comprueba si una contraseña cumple con un conjunto de reglas de validación que se pasan como parámetros del mismo:

  private[this] def validateWith(
      password: Password,
      constraints: NonEmptyList[ConstraintIn[Password]],
  ): Boolean =
    implicit val semigroupPassword
        : Semigroup[Password] = (x: Password, y: Password) =>
      assert(x == y)
      x

    @tailrec
    def combineAll(
        constraints: List[ConstraintIn[Password]],
        accumulated: AllErrorsOr[Password],
    ): AllErrorsOr[Password] =
      constraints match
        case Nil => accumulated
        case ::(head, next) =>
          combineAll(next, accumulated.combine(head(password)))

    combineAll(constraints.tail, constraints.head(password)).isValid

La principal diferencia con el paso anterior es que dejé de usar la sintáxis de Applicative (a, b, c, ...).mapN(...) para acumular los posibles errores de Password en una NonEmptyChain, y en su lugar usé combine de Semigroup. Aprovechando que cats proporciona una implementación de Semigroup para NonEmptyChain, solamente tuve que definir una forma de combinar Password. En este caso particular, como voy a pasar la misma contraseña por diferentes reglas de validación, lo que me interesa es comprobar que los valores que estoy combinando coinciden y quedarme con cualquiera de ellos.

Esta generalización me permite definir varias implementaciones de ValidatePassword, una por cada una de las formas de validación:

  def withFirstRuleSet: ValidatePassword = new ValidatePassword:
    override isValid(password: Password): Boolean =
      validateWith(
        password,
        NonEmptyList.of(
          hasMinimumLength(9),
          containsAtLeastOneUppercaseLetter,
          containsAtLeastOneLowercaseLetter,
          containsAnyNumber,
          containsAnUnderscore,
        ),
      )

  def withSecondRuleSet: ValidatePassword = new ValidatePassword:
    override isValid(password: Password): Boolean =
      validateWith(
        password,
        NonEmptyList.of(
          hasMinimumLength(7),
          containsAtLeastOneUppercaseLetter,
          containsAtLeastOneLowercaseLetter,
          containsAnyNumber,
        ),
      )

  def withThirdRuleSet: ValidatePassword = new ValidatePassword:
    override isValid(password: Password): Boolean =
      validateWith(
        password,
        NonEmptyList.of(
          hasMinimumLength(17),
          containsAtLeastOneUppercaseLetter,
          containsAtLeastOneLowercaseLetter,
          containsAnUnderscore,
        ),
      )

Tercera iteración: Reportar todos los errores#

Hasta ahora nos habían pedido comprobar si la contraseña era válida o no. En la tercera iteración nos piden reportar todos los errores que han podido ocurrir durante la validación.

Este fue un cambio fácil porque sólamente tuve que añadir el nuevo método validate que devuelve el resultado de la validación, y mover la lógica que comprueba si la contraseña es válida o no al método isValid:

sealed trait ValidatePassword:
  def validate(password: Password): AllErrorsOr[Password]

  def isValid(password: Password): Boolean = validate(password).isValid

Cuarta iteración: Validaciones más flexibles#

En la última iteración nos piden dar por válidas las contraseñas que incumplan con 1 de las reglas de validación. Esta vez añadí el nuevo método isValidRelaxed para extender el comportamiento:

sealed trait ValidatePassword:
  def validate(password: Password): AllErrorsOr[Password]

  def isValid(password: Password): Boolean = 
    validate(password).isValid

  def isValidRelaxed(password: Password): Boolean = 
    validate(password).fold(_.length == 1, _ => true)

Además, nos piden soportar un nuevo conjunto de reglas:

  def withFourthRuleSet: ValidatePassword = new ValidatePassword:
    override def validate(password: Password): AllErrorsOr[Password] =
      validateWith(
        password,
        NonEmptyList.of(
          hasMinimumLength(9),
          containsAtLeastOneUppercaseLetter,
          containsAnyNumber,
          containsAnUnderscore,
        ),
      )

Tests#

Una de las razones por las que elegí esta kata es porque me pareció interesante para practicar property-based testing. Como en las dos últimas ocasiones, volví a utilizar MUnit y ScalaCheck, sobre todo por lo fácil que me resulta expresar mis casos de prueba con generadores.

El esquema general de cada uno de los tests es el siguiente:

final class ValidatePasswordSuite extends ScalaCheckSuite:

  property("it should validate a password with the first rule set") {
    forAll(firstRuleSetTestCaseGen) { testCase =>
      checkWith(ValidatePassword.withFirstRuleSet, testCase)
    }
  }

  private[this] def checkWith(
      validatePassword: ValidatePassword, testCase: TestCase
  ) = validatePassword.validate(testCase.password) == testCase.expectedValidation 
    && validatePassword.isValid(testCase.password) == testCase.expectedIsValid 
    && validatePassword.isValidRelaxed(testCase.password) == testCase.expectedIsValidRelaxed

Utilicé la misma definición de caso de prueba para todos mis tests, que incluye lo siguiente:

  1. Una contraseña generada de forma automática a partir de las reglas de validación de cada iteración.
  2. El resultado esperado, ya sean errores encontrados durante la validación o la contraseña en caso de que la contraseña cumpla con todas las reglas de validación.
  3. ¿La contraseña es válida?
  4. ¿La contraseña contiene uno o ningún errores?

Para cada conjunto de reglas generé varios casos de prueba aleatorios. Por ejemplo, firstRuleSetTestCaseGen genera casos de prueba que incluyen los siguientes escenarios:

  1. Contraseñas que no tienen el tamaño mínimo de caracteres.
  2. Contraseñas que no contienen una letra mayúscula.
  3. Contraseñas que no contienen una letra minúscula.
  4. Contraseñas que no contienen algún número.
  5. Contraseñas que no contienen un guión bajo.
  6. Contraseñas que inclumplen con todaslas reglas anteriones a la vez.
  7. Contraseñas válidas.

También me apoyé en algunos generadores que fui mejorando durante la kata a medida que iba añadiendo más reglas de validación. Por ejemplo, passwordGen genera contraseñas válidas a partir de generadores de caracteres válidos y el tamaño mínimo de la contraseña.

object ValidatePasswordSuite:

  private[this] val random = scala.util.Random

  private[this] def passwordGen(
      mandatoryCharsGens: List[Gen[Char]],
      minimumLength: Int,
  ): Gen[Password] = for
    mandatoryChars <- Gen.sequence(mandatoryCharsGens).map(_.asScala.toList)
    additionalCharsN <- Gen.choose(minimumLength - mandatoryChars.length, minimumLength + 10)
    additionalChars <- Gen.containerOfN[List, Char](additionalCharsN, Gen.asciiPrintableChar)
  yield
    val randomizedString = random.shuffle(mandatoryChars ++ additionalChars).mkString
    Password.unsafeFrom(randomizedString)

  final private case class TestCase(
      password: Password,
      expectedValidation: AllErrorsOr[Password],
      expectedIsValid: Boolean,
      expectedIsValidRelaxed: Boolean,
  )

  private object TestCase:
    def from(password: Password, expectedValidation: AllErrorsOr[Password]): TestCase =
      TestCase(
        password,
        expectedValidation,
        expectedValidation match
          case Validated.Valid(_) => true
          case Invalid(_) => false
        ,
        expectedValidation match
          case Validated.Valid(_) => true
          case Validated.Invalid(errors) => errors.length == 1,
      )

  private val firstRuleSetTestCaseGen: Gen[TestCase] =
    val minimumLength = 9
    val mandatoryCharsGens =
      List(Gen.alphaLowerChar, Gen.alphaUpperChar, Gen.numChar, Gen.const('_'))
    val validPasswordGen = passwordGen(mandatoryCharsGens, minimumLength)
    for (password, expectedValidation) <- Gen.frequency(
        1 -> noMinimumLengthPasswordGen(mandatoryCharsGens, minimumLength).map(
          (_, PasswordHasMinimumLength(minimumLength).invalidNec),
        ),
        1 -> noUpperCasePasswordGen(validPasswordGen).map(
          (_, PasswordContainsAtLeastOneUpperCaseLetter.invalidNec),
        ),
        1 -> noLowerCasePasswordGen(validPasswordGen).map(
          (_, PasswordContainsAtLeastOneLowerCaseLetter.invalidNec),
        ),
        1 -> noNumberPasswordGen(validPasswordGen)
          .map((_, PasswordContainsAnyNumber.invalidNec)),
        1 -> noUnderscorePasswordGen(validPasswordGen).map(
          (_, PasswordContainsAnUnderscore.invalidNec),
        ),
        1 -> allErrorsGen(minimumLength).map(
          (
            _,
            Invalid(
              NonEmptyChain(
                PasswordHasMinimumLength(minimumLength),
                PasswordContainsAtLeastOneUpperCaseLetter,
                PasswordContainsAtLeastOneLowerCaseLetter,
                PasswordContainsAnyNumber,
                PasswordContainsAnUnderscore,
              ),
            ),
          ),
        ),
        1 -> validPasswordGen.map(x => (x, x.validNec)),
      )
    yield TestCase.from(password, expectedValidation)

Final refactor#

En un post anterior y en mi presentación “5 tips about using functional programming patterns you might want to know” de la Software Crafters Barcelona comenté las desventajas que tiene este diseño, y también presenté una solución alternativa utilizando patrones de programación funcional. Siguiendo esta idea, quiero terminar esta kata haciendo un refactor para mover la validación de la contraseña dentro de un smart constructor de Password. Además, los métodos isValid y isValidRelaxed descartan los errores de validación para devolver un tipo Boolean, así que los voy a mover a una type class nueva donde crearé una implementación específica para Validated, y de esa forma me aseguraré de que siempre se utilizan de manera explícita.

Después de mover la validación a un smart constructor de Password el código queda como sigue:

object Password:
  def unsafeFrom(value: String | Null): Password = value.nn

  def withFirstRuleSetFrom(value: String): AllErrorsOr[Password] = validateWith(
    value,
    NonEmptyList.of(
      hasMinimumLength(9),
      containsAtLeastOneUppercaseLetter,
      containsAtLeastOneLowercaseLetter,
      containsAnyNumber,
      containsAnUnderscore,
    ),
  )

Por último, he creado la type class Verifiable donde además he movido el método isValidRelaxed:

trait Verifiable[A]:
  extension (a: A) def isValidRelaxed: Boolean

object Verifiable:
  given Verifiable[AllErrorsOr[Password]] with
    extension (a: AllErrorsOr[Password])
      def isValidRelaxed: Boolean = a.fold(_.length == 1, _ => true)

No me ha hecho falta mover isValid porque la sintáxis de Validated ya proporciona esta lógica.

Cats