Al probar peticiones HTTP con cuerpo en el servidor de RealWorld, el problema apareció en un sitio inesperado. El Worker podía leer el cuerpo desde la conexión TCP, pero el traspaso de los Bytes al Actor que gestionaba la aplicación destapó un fallo en el runtime de Protos. Corregirlo formó parte de construir el servidor. Ese era el tipo de problema que quería encontrar al llevar el lenguaje más allá de los ejemplos pequeños.
Protos es un lenguaje experimental basado en prototipos que estoy desarrollando sobre GraalVM y Truffle. Para probarlo elegí RealWorld, también conocido como Conduit, una aplicación de publicación de artículos con una API común que permite combinar distintos frontends y backends. Mi implementación cubre las 19 rutas de la API, desde el registro y el inicio de sesión hasta los perfiles, los artículos, el feed, los comentarios, los seguimientos y los favoritos. Proyecto RealWorld · Código del backend en Protos
Qué tuve que implementar en Protos
Empecé por levantar un servicio TCP, atender las primeras rutas públicas y comprobar las respuestas desde un cliente HTTP. A partir de ahí, cada paso de la aplicación puso a prueba una parte distinta del lenguaje y de su biblioteca.
- Servicios y red. Incorporé al lanzador el modo servicio y la concesión explícita de acceso a la red. El servidor tenía que poder seguir escuchando mientras dos Workers aceptaban conexiones y atendían peticiones, y cerrar sus recursos al terminar.
- Transferencia entre Actors. Al pasar de consultas públicas a peticiones con cuerpo, tuve que corregir la transferencia de
Bytesentre Actors. La aplicación también necesitó definir qué datos de una petición podían cruzar ese límite y quién seguía siendo responsable de la conexión. - Entropía y reloj. Incorporé capacidades explícitas para obtener aleatoriedad segura y consultar el reloj de pared. La aplicación usa la primera para generar identificadores y la segunda para calcular y comprobar los tiempos de los JWT.
- Biblioteca criptográfica. Añadí a la biblioteca estándar Argon2id para contraseñas y las piezas necesarias para los JWT con HS256, entre ellas HMAC-SHA256 y Base64url. El backend consume esas APIs. En la aplicación quedan las decisiones sobre la identidad, la autorización y la revocación de tokens. Repositorio de Protos
También hubo trabajo propio del backend. Implementé el enrutado, el modelo de datos y la validación del JSON antes de modificar el estado. Por ejemplo, en una actualización parcial importa distinguir un campo ausente de uno enviado como null. El servidor también rechaza claves JSON duplicadas y valida parámetros como limit en los listados.
Una petición HTTP, paso a paso en Protos
En estos fragmentos, nombre: valor declara una variable local o un campo de objeto, según el contexto. nombre = valor actualiza una variable. La forma (argumentos) => { ... } crea una función, que devuelve el resultado de su última expresión. Los extractos proceden del repositorio y omiten algunos comentarios para facilitar la lectura.
1. Arrancar un Worker con la autoridad justa
worker: Actor.spawn(
"./HttpWorker.protos",
"start",
listener.acceptCapability(),
application,
index
)
Actor.spawn carga HttpWorker.protos en un Actor nuevo y ejecuta su función start con los tres argumentos siguientes. listener.acceptCapability() le permite aceptar conexiones del listener existente. application es una referencia de comunicación, de tipo GroupRef, con la que puede pedir respuestas al RootActor. index identifica al Worker. El listener completo, el almacén mutable y la clave de firma quedan en el RootActor.
2. Leer el cuerpo y cruzar el límite entre Actors
handleRequest: (request) => {
content: Encoding.Latin1.encode("")
chunk: request.body.read(8192).value()
while(chunk !== null) {
for(octet in chunk) {
content.add(octet)
}
chunk = request.body.read(8192).value()
}
application.request("handle", {
method: request.method
target: request.target
version: request.version
headers: request.headers
body: content
}).value()
}
request.body es un flujo ligado a la sesión HTTP. El Worker lo lee en bloques de 8 KiB hasta recibir null, que marca el final del cuerpo. La biblioteca HTTP aplica un límite de 1 MiB al cuerpo decodificado. El objeto enviado en request("handle", ...) contiene los datos que necesita el RootActor. El destino conserva sus bytes originales para que el enrutador se encargue de interpretarlos, y las cabeceras mantienen su orden y sus posibles duplicados para las comprobaciones de seguridad.
Tanto la lectura del flujo como la petición al otro Actor devuelven un Future. .value() obtiene el resultado y, si todavía está pendiente, suspende la ejecución actual. El Worker puede entonces atender mensajes de control como drain y abort. Cuando llega la respuesta del RootActor, el Worker sigue siendo responsable de escribirla en su conexión.
3. Validar y crear un artículo
{ method: "POST"
path: "/api/articles"
handle: route(true, (request, match, principal) => {
input: RequestJson.createArticle(request.body)
response: null
if(!input.valid) { response = Responses.validation(input) } else {
fields: input.fields
time: auth.timestamp()
slug: Slug.allocate(store, fields["title"].value)
result: store.createArticle(principal.id, slug, fields["title"].value,
fields["description"].value, fields["body"].value,
RequestJson.tagsOr(fields, []), time)
if(result.ok) {
response = Responses.created(Articles.singleFor(result.value, principal.id, store))
} else { response = Responses.domainError(result) }
}
response
}) },
route(true, ...) exige un usuario autenticado antes de ejecutar el cuerpo de la ruta. Una vez dentro, RequestJson.createArticle valida el cuerpo de la petición. Si es válido, el servidor obtiene la hora, asigna un slug y llama al almacén con principal.id como autor. Ese identificador sale de la autenticación, de modo que un campo del JSON no puede elegir quién firma el artículo. Responses.created prepara la respuesta HTTP 201.
La misma separación entre validación y escritura permite dar sentido a las actualizaciones parciales. Por ejemplo, para un campo opcional como bio, el siguiente código conserva el valor anterior si el campo se omite, lo borra si llega como null y lo sustituye si llega como texto.
textOr: (fields, name, previous) => {
value: previous
if(fields.containsKey(name)) {
node: fields[name]
value = null
if(node.kind == "string") { value = node.value }
}
value
}
El cierre también forma parte del protocolo del servidor. Con drain, los Workers terminan el trabajo que ya habían admitido. Con abort, cancelan el trabajo pendiente. El comando quit de la consola solicita el cierre ordenado.
El cambio de contraseña que hizo fallar Hurl
El backend usa Argon2id para las contraseñas y emite JWT firmados con HS256. El sujeto del token es un identificador estable de la cuenta. Al cambiar la contraseña, la aplicación incrementa la generación de las credenciales y devuelve un token nuevo. En las siguientes peticiones compara la generación del token con la de la cuenta. Así, un token anterior queda invalidado aunque su firma sea válida y todavía esté dentro de su periodo de vigencia.
Qué puede cambiar mientras se calcula el hash
El cambio de contraseña tiene un detalle propio del modelo de concurrencia de Protos. Calcular el hash con Argon2id devuelve un Future, así que el turno del RootActor puede suspenderse mientras espera. Durante ese intervalo, otro turno puede actualizar las credenciales de la misma cuenta. Por eso updateAccount comprueba la generación al entrar y vuelve a leer el registro después de la espera.
if(checked.valid && (record !== null) && (record.generation == expectedGeneration)) {
verifier: null
if(fields.containsKey("password")) {
verifier = Password.hash(fields["password"].value, entropy).value()
}
record = byId(id)
if(record.generation == expectedGeneration) {
current: identityStore.identityById(id)
username: Input.textOr(fields, "username", current.username)
email: Input.textOr(fields, "email", current.email)
result = conflict(id, username, email)
if(result === null) {
result = identityStore.replaceIdentity(id, username, email,
Input.textOr(fields, "bio", current.bio),
Input.textOr(fields, "image", current.image))
if(result.ok && (verifier !== null)) { replace(record, verifier) }
}
}
}
La primera comprobación compara la generación del usuario autenticado con la vigente. La segunda impide que una petición que empezó con credenciales antiguas sobrescriba un cambio hecho durante la espera. Si la actualización se aplica, el nuevo verificador incrementa la generación. Cuando llega otra petición, JWT.verify comprueba el token según la política de firma y vigencia, y la aplicación coteja la generación del token con la de la cuenta. Ambas comprobaciones deben pasar para obtener un usuario autenticado. Auth.protos
Uno de los escenarios de la suite Hurl de RealWorld seguía enviando el token anterior después de cambiar la contraseña. Ese escenario falló. Dejé en el repositorio un parche que captura el nuevo token de la respuesta y lo usa en las peticiones siguientes. El objetivo make test-hurl aplica el parche sobre una copia temporal de la suite. Parche de renovación del token
| Comprobación | Resultado con Protos 0.3.459-SNAPSHOT |
|---|---|
make test |
12 grupos de pruebas de la aplicación superados |
make test-http |
Pruebas HTTP autenticadas sobre conexiones TCP superadas |
make test-http-lifecycle |
Pruebas de concurrencia, límites de petición, cierre ordenado y cancelación superadas |
| Suite Hurl oficial sin modificaciones | 12 de 13 archivos superados |
| Suite Hurl con el parche de renovación | 13 de 13 archivos y 157 peticiones superados |
El resultado de compatibilidad con la suite oficial es 12 de 13. La segunda ejecución comprueba el recorrido completo con la política de revocación de este backend. La revisión exacta de la suite externa utilizada en estas ejecuciones no quedó anotada junto a los resultados.
Probarlo en local
Con una compilación compatible de Protos instalada, se puede arrancar el servidor desde el repositorio.
git clone https://github.com/guillermomolina/protos-realworld-example-app.git
cd protos-realworld-example-app
make serve
make serve inicia el servicio en 127.0.0.1:3000 y concede explícitamente las capacidades de red, entropía y reloj mediante --allow-network, --allow-entropy y --allow-clock. Desde otra terminal se pueden consultar las primeras rutas públicas.
curl -i http://127.0.0.1:3000/api/tags
curl -i http://127.0.0.1:3000/api/articles
Persistencia, el siguiente paso
Las cuentas, las credenciales, los artículos, los comentarios, los seguimientos y los favoritos viven en MemoryStore. Al reiniciar el proceso se pierde ese estado. Conservar la clave de firma por sí sola tampoco permite recuperar una sesión, porque la cuenta y la generación de sus credenciales desaparecen con el almacén.
El siguiente reto que quiero abordar es el acceso a almacenamiento duradero. SQLite es el primer candidato para desarrollar una capa de acceso reutilizable desde Protos. RealWorld ya plantea casos concretos con los que probarla, desde la unicidad de los nombres de usuario hasta las relaciones entre artículos, autores y favoritos.