¿Qué tiene de malo este código?

def validateHash(password: String, hash: String): Boolean

En mi opinión, este código no tiene nada de malo: es un código que podría haber funcionado en los años 1980 y que todavía sigue funcionando en la actualidad.

Lo que pasa es que en 40 años hemos acumulado experiencia suficiente sobre los modos de fallo de esta forma de programar, y sobre todo sabemos cómo envejecen las aplicaciones desarrolladas de esta manera.

Sería una pena no aprovechar esa experiencia para mejorar la forma en que desarrollamos aplicaciones nuevas.

En un post anterior usé el mismo ejemplo para explicar newtype (opaque types en Scala 3). En este post voy a introducir patrones nuevos que complementan el post anterior.

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

Phantom Type Parameters

Tanto el password como el hash contienen la misma información: la contraseña de un usuario, y la diferencia está en el formato en que esta está representada. En el password la contraseña está en texto plano, mientras que el hash el es resultado de aplicar una función hash para ofuscar la contraseña y protegerla de posibles filtraciones.

Teniendo en cuenta que se trata de la misma infomación, cabría esperar que ambos valores (password y hash) estuviesen representados con el mismo tipo. Sin embargo, al tener la función dos argumentos del mismo tipo se podrían dar los problemas que expliqué el post anterior.

Repasando lo que he aprendido hasta ahora: quisiera un tipo que le dé contexto a los datos, y que no tenga problemas de sobrecarga en tiempo de ejecución.

Hay un mini patrón de programación funcional que se llama: phantom type parameters, y que consiste en añadir un parámetro al tipo con la particularidad de que este parámetro extra solo va a aportar información al contexto.

Con la ayuda de este patrón, el tipo Password se podría representar como sigue:

object password {
  @newtype case class Password[A <: Password.Format](unPassword: String)

  object Password {
    sealed trait Format
    sealed trait ClearText extends Format
    sealed trait CipherText extends Format
  }
}

El parámetro Format ayuda al compilador a diferenciar los puntos donde la aplicación espera una contraseña en texto plano, de aquellos donde lo esperado es una contraseña ofuscada con una función de hash. Aplicando este cambio, la función validateHash se podría reescribir así:

def validateHash(
  password: Password[ClearText],
  hash: Password[CipherText]
): Boolean

Este nuevo diseño mejora al anterior porque aporta contexto a los datos.

Make Illegal States Unrepresentable

Una de las mejoras que quiero hacer ahora es evitar que se pueda crear una contraseña vacía en la aplicación, y para ello tengo que hacer dos cambios: sustituir el tipo base String por un nuevo tipo que le indique esta restricción al compilador, y evitar que se pueda crear un Password que no cumpla la restricción.

Precisamente, la librería refined proporciona predicados que sirven para restringir los valores que puede tomar un tipo dado. Voy a sustituir String con NonEmptyString para indicarle al compilador que los valores de Password están restringidos a cadenas no vacías. Además, voy a usar el predicado NonEmpty de la misma librería para evitar que se puedan crear valores de tipo Password a partir de una cadena vacía.

Antes de continuar, quiero comentar que una de las cosas que no me terminaban de gustar de los refined types es que para usarlos hay que asegurarse de aplicar el predicado con refineV. No fue hasta que leí el post Domain-Driven Design with FP in Scala que me di cuenta de que se puede ocultar el uso de refineV dentro de un smart constructor. Otra de las ventajas de este enfoque es que se pueden sustituir los errores de validación que provienen de refined con errores adaptados al dominio de la aplicación.

Aplicando estos cambios al tipo Password, el código queda como sigue:

object password {
  @newtype class Password[A <: Password.Format](val unPassword: NonEmptyString)

  object Password {
    sealed trait Format
    sealed trait ClearText extends Format
    sealed trait CipherText extends Format

    def fromString[A <: Password.Format](
      str: String
    ): Either[InvalidPassword, Password[A]] =
      refineV[NonEmpty](str) match {
        case Left(_) => InvalidPassword("Password cannot be empty").asLeft
        case Right(refinedStr) => refinedStr.coerce[Password[A]].asRight
      }
  }
}

Definiendo el tipo Password con una class, en lugar de una case class como se había hecho antes, se evita que newtype genere un método apply (los detalles en este enlace). En su lugar, he definido el smart constructor fromString en el companion object para crear valores de tipo Password a partir de un String, comprobando que el argumento del smart constructor no sea una cadena vacía.

Evidence

Haciendo un resumen de los cambios: combinando opaque types con phantom type parameters he conseguido representar los datos con un tipo que proporciona contexto, y lo hace sin crear problemas de sobrecarga en tiempo de ejecución. Además, el nuevo tipo garantiza que los estados ilegales no puedan ser representados.

Sin embargo, al volver a repasar el código de la función validateHash me di cuenta de que todo este contexto se pierde al devolver un tipo Boolean:

def validateHash(
  password: Password[ClearText],
  hash: Password[CipherText]
): Boolean

Un programa se puede entender como un flujo de datos, donde en cada uno de los pasos del flujo se realizan tareas que aumentan la información que tenemos del contexto en el que se ejecuta el programa. Vista desde esta perspectiva, la función validateHash recibe como argumentos unos valores enriquecidos con información del contexto, pero corta el flujo al devolver un tipo Boolean. A este patrón donde una función desecha toda la información de contexto para devolver un tipo Boolean se le conoce como boolean blindness.

Uno de los problemas, para mí, está en que cuando más adelante me encuentre con un valor de tipo Boolean no sabré de qué se trata, y necesitaré mirar el nombre de la variable. En Scala hay muchos casos en los que el nombre de variable no estará disponible: por ejemplo, el guion bajo (_) es un syntactic sugar que puede sustituir un nombre de variable dentro de una expresión lambda (más ejemplos en este enlace).

Sin embargo, el problema principal es que con un valor de tipo Boolean no tendré la seguridad que me dan los tipos para componer módulos sabiendo que los estados ilegales no pueden ser representados.

Parse, don’t validate

Para encontrar una alternativa al boolean blindness tuve que dar un paso atrás y preguntarme: ¿para qué necesito la función validateHash?, ¿cuál es el uso que le voy a dar dentro de mi programa?

Lo que quiero realmente es validar un acceso mediante el uso de un nombre de usuario y una contraseña. Una vez que entendí esa necesidad, vi el diseño con más claridad.

Alexis King explicaba en un post suyo que una manera de propagar el contexto en un programa consiste en sustituir validación por parsing. Una validación funciona como una puerta lógica que solo conoce de dos estados: el estado válido cuando se cumplen las condiciones de validación, o inválido cuando se incumplen. Este diseño no facilita la propagación del contexto. En cambio, un parser va incorporando información progresivamente, a medida que esta va estando disponible, para emitir un nuevo estado.

Siguiendo este razonamiento, un diseño más adecuado podría ser definido de la siguiente forma:

trait ValidateAccess[F[_]] {
  def grantAccessIdentifiedWith(
    userName: UserName,
    password: Password[ClearText]
  ): F[AccessDecision]
}

La función grantAccessIdentifiedWith recibe el nombre de usuario con la contraseña, y los utiliza para permitir o prohibir el acceso. La salida de la función contiene el nombre de usuario y el acceso, que puede ser Granted o Forbidden. Una vez validada la contraseña, deja de tener valor, y por eso no la estoy propagando.

La definición de AccessDecision es como sigue:

object access {
  final case class AccessDecision(userName: UserName, access: Access)

  sealed trait Access extends EnumEntry with Product with Serializable

  object Access extends Enum[Access] with CatsEnum[Access] {
    case object Granted extends Access
    case object Forbidden extends Access

    override def values: IndexedSeq[Access] = findValues
  }
}

¿Y dónde queda la validación de la contraseña? En el nuevo diseño la validación de la contraseña es un detalle del intérprete del álgebra ValidateAccess. La decisión se sigue tomando al comparar la contraseña con un hash, que se obtiene de un repositorio que se inyecta como argumento del smart constructor. De forma similar, la función de hash también es un argumento del intérprete. Este diseño permite que tanto el intérprete, el repositorio de contraseñas ofuscadas y la función de hash puedan ser probados de manera independiente.

El código del intérprete del álgebra ValidateAccess queda como sigue:

object ValidateAccess {
  def impl[F[_]: Sync](
    vault: Vault[F],
    hashFn: Password[ClearText] => Either[InvalidPassword, Password[CipherText]]
  ): ValidateAccess[F] =
    (userName: UserName, password: Password[ClearText]) => {
      val F = Sync[F]
      for {
        cipherText <- F.fromEither(hashFn(password))
        maybeHash <- vault.passwordFor(userName)
        access = maybeHash match {
          case Some(expectedHash) => 
            if (expectedHash === cipherText) Granted else Forbidden
          case None => Forbidden
        }
      } yield AccessDecision(userName, access)
    }
}

Los efectos están envueltos en un parámetro de tipo cats.effect.Sync para hacerlos independientes del programa. Adicionalmente, la función grantAccessIdentifiedWith considera un caso adicional en el que no existe un hash (contraseña ofuscada) asociado al nombre de usuario, y emite un Forbidden indistinguible del caso en que la contraseña no coincida con el hash almacenado para el usuario.

Finalmente

Cada vez me siento más cómodo con este estilo de programación que hace hincapié en los tipos, casi más que en cualquier otro aspecto del código.

Antes de empezar a aprender programación funcional, me fijaba mucho en la separación de responsabilidades, no duplicar conocimiento y no mezclar niveles de abstracción diferentes. Son cosas que siguen teniendo valor para mí, y que aún sigo haciendo.

Sin embargo, hay otras cosas que he dejado de hacer para sustituirlas por nuevas. Ahora que sé que los nombres de las variables no se propagan bien, ya no me fijo tanto en estos. En cambio, ahora dedico más tiempo a pensar en los datos y en sus tipos.

Agradecimientos

Al equipo de Kowainik porque su libro Haskell mini-patterns handbook, que además de haberme enseñado muchas cosas nuevas, me ha servido de inspiración para escribir algunos ejemplos y explicaciones de este post.

Kowainik