Bringing Class-Based Views to Fastify (Inspired by Django)
user
¿Hay algún framework o librería para Node.js que implemente Class Based Views de una forma similar a Django? Idealmente para Fastify, pero también me puede valer para Express o Koa. Si no están disponibles para estos frameworks, indícame para otros, o alguna otra alternativa
ChatGPT
Sí — aunque no hay (o no conozco) una librería “oficial” que implemente *exactamente* las Class Based Views como en Django para Fastify, sí hay enfoques, librerías y patrones que te acercan bastante, sobre todo usando TypeScript y decoradores. A continuación te muestro opciones, pros/contras y sugerencias para cada framework o enfoque.
---
## 1. `routing-controllers` (Express / Koa / otros)
Esta es probablemente la opción más “tradicional” y madura para hacer controladores basados en clases en Node. Permite usar decoradores tipo `@Controller`, `@Get()`, `@Post()`, etc., de forma muy parecida a Django / Flask / ASP.NET. citeturn1search0
**Ventajas**:
- Integración con Express o Koa.
- Decoradores para rutas, parámetros (`@Param`, `@Body`, etc.), middleware, interceptores, etc. citeturn1search0
- Compatibilidad con validación y transformación de clases (`class-validator`, `class-transformer`), lo que da un estilo “DTO + controlador” muy limpio. citeturn1search0
- Puedes inyectar dependencias si usas un contenedor de inyección de dependencias (DI). citeturn1search0
**Desventajas / limitaciones**:
- No está pensado “out-of-the-box” para Fastify; tendrías que ver cómo adaptarlo o ver si alguien ha hecho un módulo puente.
- Si tu aplicación ya no usa decoradores o TypeScript, puede añadirse complejidad.
- Puede ser más pesado o menos “low-level” que usar rutas manuales en Express / Koa.
**Uso típico**:
```ts
import "reflect-metadata";
import { createExpressServer, Controller, Get, Param, Body, Post } from "routing-controllers";
@Controller("/users")
class UserController {
@Get("/")
getAll() {
return [ /* ... */ ];
}
@Get("/:id")
getOne(@Param("id") id: number) {
return { id, name: "Foo" };
}
@Post("/")
create(@Body() body: any) {
return { id: 1, ...body };
}
}
const app = createExpressServer({
controllers: [UserController]
});
app.listen(3000);
```
Así se parece bastante al estilo “class-based view” (aunque no exactamente igual a Django).
---
## 2. Adaptar / construir algo similar para Fastify
Aunque no encontré una librería reconocida que implemente exactamente Class Based Views para Fastify, tienes varios caminos para conseguir algo similar:
### 2.1. Usar Fastify con decoradores / metadata tú mismo
Fastify permite extender el servidor con **decorators** y plugins. citeturn0search9turn0search17turn0search5 Puedes “decorar” el servidor o las respuestas con métodos auxiliares, y escribir tu propio mapeo desde clases a rutas.
Por ejemplo:
- Definir una clase base `BaseController` con métodos como `registerRoutes(fastify)`, en la que recorres los métodos de la clase (usando reflexión o metadata) y los registras como rutas.
- Usar decoradores personalizados (con reflect-metadata) para marcar métodos con `@Get()`, `@Post()`, etc., e internamente en el bootstrap de la aplicación leer esos metadatos y hacer `fastify.route(...)`.
Este enfoque requiere algo de trabajo manual, pero te permite mantener la filosofía de clases + decoradores con Fastify.
### 2.2. Usar un puente o wrapper existente
Puede que haya paquetes más pequeños, menos populares, que permitan usar controladores con clases sobre Fastify. No encontré uno robusto en mis búsquedas recientes, pero podrías explorar en npm con palabras como *“fastify controllers class decorators”*, *“fastify routing-controllers”*, etc.
Otra idea: usar `@fastify/express` (el plugin de compatibilidad) para montar una parte Express dentro de Fastify, y luego usar `routing-controllers` dentro de ese segmento. Pero eso puede perder parte del rendimiento o elegancia de Fastify. citeturn0search7
---
## 3. Alternativas completas: frameworks que ya usan controladores basados en clases
Si estás abierto a probar otro framework que ya incluya esa abstracción, podrías mirar estas opciones:
### 3.1. NestJS
NestJS es un framework inspirado en Angular para backend en Node. Se basa casi completamente en clases, módulos y decoradores. Los controladores son clases con decoradores `@Controller()`, métodos con `@Get()`, `@Post()`, etc. citeturn1search10
Si te interesa una experiencia muy estructurada y opinada, Nest puede valer la pena. La desventaja es que es más “grande” como stack y tiene su propia filosofía y curva de aprendizaje.
### 3.2. Sails.js (MVC style)
Sails.js es un framework MVC para Node inspirado en Rails. Tiene un sistema de controladores, modelos y vistas integrados. citeturn1search18 No es exactamente como Django (sus ideas son algo distintas), pero sí refleja un estilo de “controladores + vistas” más estructurado.
### 3.3. Otros frameworks que usan controladores con decoradores
- **Ditsmod**: tienen un módulo de controladores que permite marcar clases con `@controller` y rutas con `@route`. citeturn1search8
- En general, muchos frameworks “de nueva generación” en el ecosistema TypeScript/Node adoptan el patrón de clases y decoradores.
---
## 4. ¿Qué opción te recomendaría (para Fastify o similar)?
Si yo estuviera en tu lugar y quisiera **mantener Fastify** pero con una abstracción tipo Class Based Views:
1. Evaluaría si es viable adaptar `routing-controllers` para Fastify (o encontrar algún fork / plugin que lo haga).
2. Si no encuentro una solución suficientemente madura, yo crearía un mini “wrapper de controladores de clase” para Fastify usando decoradores + reflexión + un bootstrap que recorra clases y registre rutas en Fastify.
3. Si no quiero reinventar mucho, me plantearía migrar la parte de controlador a Express + routing-controllers, dentro de Fastify con `@fastify/express`, si el rendimiento no es crítico.
4. Si estoy más enfocado en productividad / arquitectura que en micro-optimización, consideraría usar **NestJS** directamente, pues te da tablas y soporte “de fábrica” para el estilo de controladores en clase.
---
Si quieres, puedo hacer una pequeña plantilla o esqueleto de “Class Based Views para Fastify” (usando decoradores) para que veas cómo quedaría, y te lo mando. ¿Te lo preparo?
user
No me gusta la solución de los decoradores, creo que tendré que implementarlo yo mismo, pero es mucho trabajo y no sé cómo de popular sería, además que ya no estoy como para hacer proyectos por hobbie
ChatGPT
Totalmente válido no querer meterte en decoradores ni en un “side-project” grande. Si lo que te apetece es **un patrón minimalista, sin decoradores, fácil de portar y que puedas copiar-pegar hoy**, te dejo aquí un “CBV-lite”:
- **Clase base** con métodos `get/post/put/patch/delete`.
- Un **adaptador** por framework que registra rutas a partir de la clase.
- Hooks sencillos `before()`/`after()` opcionales.
- Posibilidad de pasar dependencias por constructor.
- Sin magia, sin TypeScript obligatorio (pero funciona bien con TS).
A continuación tienes implementaciones compactas para **Fastify**, **Express** y **Koa**. Puedes usar sólo la que necesites.
---
# Fastify (sin decoradores)
```js
// cbv-fastify.js
const HTTP_METHODS = ["GET","POST","PUT","PATCH","DELETE"];
class BaseView {
// Opcional: dependencias/contexto
constructor(ctx = {}) {
Object.assign(this, ctx);
}
// Hooks opcionales por request
async before(/* req, reply */) {}
async after(/* req, reply, result */) {}
}
/**
* Registra una "view" basada en clase como una o varias rutas Fastify.
* Convención:
* - Implementa métodos: get/post/put/patch/delete en tu clase.
* - Opcional: define propiedades estáticas o de instancia para schema, preHandler, etc.
* - estática: View.schemas = { get: {...}, post: {...} }
* - por método (instancia): get$preHandler, post$schema, etc.
*/
function registerView(fastify, path, ViewClass, { deps = {}, common = {} } = {}) {
const proto = ViewClass.prototype;
const available = HTTP_METHODS
.map(m => ({ m, fn: proto[m.toLowerCase()] }))
.filter(x => typeof x.fn === "function");
if (available.length === 0) {
throw new Error(`No handlers found on ${ViewClass.name} for ${path}`);
}
const schemas = (ViewClass.schemas && typeof ViewClass.schemas === "object") ? ViewClass.schemas : {};
for (const { m } of available) {
const method = m.toLowerCase();
fastify.route({
method: m,
url: path,
// Puedes pasar opciones comunes (schema de error, tags, etc.)
...common,
// Esquema por método (estático)
schema: { ...(common.schema || {}), ...(schemas[method] || {}) },
// Hooks por método (desde instancia con convención get$preHandler, etc.)
preHandler: async (req, reply) => {
const view = new ViewClass({ req, reply, fastify, ...deps });
const hookName = `${method}$preHandler`;
if (typeof view[hookName] === "function") {
return view[hookName](req, reply);
}
},
handler: async (req, reply) => {
const view = new ViewClass({ req, reply, fastify, ...deps });
if (typeof view.before === "function") {
await view.before(req, reply);
}
const result = await view[method](req, reply);
if (typeof view.after === "function") {
await view.after(req, reply, result);
}
// Permite que el handler maneje la respuesta manualmente (reply.send)
if (typeof result !== "undefined") {
return result;
}
}
});
}
}
module.exports = { BaseView, registerView };
```
**Ejemplo de uso (Fastify):**
```js
// app.js
const fastify = require("fastify")({ logger: true });
const { BaseView, registerView } = require("./cbv-fastify");
// Dependencias de ejemplo (repos, servicios, etc.)
const deps = {
usersRepo: {
list: async () => [{ id: 1, name: "Ada" }],
create: async (u) => ({ id: Date.now(), ...u }),
}
};
class UsersView extends BaseView {
static schemas = {
get: {
response: { 200: { type: "array", items: { type: "object", properties: { id: { type: "number" }, name: { type: "string" } } } } }
},
post: {
body: { type: "object", required: ["name"], properties: { name: { type: "string" } } },
response: { 201: { type: "object", properties: { id: { type: "number" }, name: { type: "string" } } } }
}
};
async before(req) {
// p.ej., auth simple
// if (!req.headers.authorization) throw fastify.httpErrors.unauthorized();
}
async get(req, reply) {
return this.usersRepo.list();
}
async post(req, reply) {
const created = await this.usersRepo.create(req.body);
reply.code(201);
return created;
}
async after(req, reply, result) {
// logging, métricas, etc.
}
}
registerView(fastify, "/users", UsersView, { deps });
fastify.listen({ port: 3000 });
```
---
# Express (sin decoradores)
```js
// cbv-express.js
const HTTP_METHODS = ["get","post","put","patch","delete"];
class BaseView {
constructor(ctx = {}) {
Object.assign(this, ctx);
}
async before(/* req, res, next */) {}
async after(/* req, res, result */) {}
}
function registerView(app, path, ViewClass, { deps = {} } = {}) {
const proto = ViewClass.prototype;
const available = HTTP_METHODS.filter(m => typeof proto[m] === "function");
if (available.length === 0) throw new Error(`No handlers on ${ViewClass.name}`);
for (const m of available) {
app[m](path, async (req, res, next) => {
const view = new ViewClass({ req, res, app, ...deps });
try {
if (view.before) await view.before(req, res, next);
const result = await view[m](req, res, next);
if (view.after) await view.after(req, res, result);
if (typeof result !== "undefined" && !res.headersSent) res.json(result);
} catch (e) {
next(e);
}
});
}
}
module.exports = { BaseView, registerView };
```
---
# Koa (sin decoradores)
```js
// cbv-koa.js
const Router = require("@koa/router");
const HTTP_METHODS = ["get","post","put","patch","delete"];
class BaseView {
constructor(ctxObj = {}) {
Object.assign(this, ctxObj);
}
async before(/* ctx, next */) {}
async after(/* ctx, result */) {}
}
function registerView(router, path, ViewClass, { deps = {} } = {}) {
const proto = ViewClass.prototype;
const available = HTTP_METHODS.filter(m => typeof proto[m] === "function");
if (available.length === 0) throw new Error(`No handlers on ${ViewClass.name}`);
for (const m of available) {
router[m](path, async (ctx, next) => {
const view = new ViewClass({ ctx, app: ctx.app, ...deps });
await view.before?.(ctx, next);
const result = await view[m](ctx, next);
await view.after?.(ctx, result);
if (typeof result !== "undefined") ctx.body = result;
});
}
}
module.exports = { BaseView, registerView, Router };
```
---
## Por qué este enfoque suele ser suficiente
- Mantiene **clases** y un **contrato claro** (`get/post/...`), parecido a Django CBV, pero sin metaprogramación pesada.
- Evita atarte a un framework concreto: el patrón es el mismo en Fastify/Express/Koa.
- Te permite crecer: si mañana quieres añadir `mixins` (por ejemplo `JsonResponseMixin`), o un `TemplateView` para SSR, es fácil.
## Variantes útiles (si te encajan)
- **Vista genérica CRUD**: una clase `ModelView` con `list/retrieve/create/update/destroy` y que, según el método HTTP, delegue. Así replicas `ListView`, `DetailView`, etc.
- **Convenciones por nombre**: por ejemplo `get$id(req)` se registra automáticamente en `/recurso/:id`. (Puedes ampliar el `registerView` para escanear nombres de métodos y crear rutas paramétricas.)
- **Schemas/validación**: ya viste arriba que en Fastify puedes colgar `schemas` estáticos; en Express/Koa mete Zod/Yup por método (`this.get$schema`) y valida en `before()`.
---
## ¿Y si no quieres ni siquiera mantener este “mini-wrapper”?
- **Sigue con rutas planas** pero **organiza por clase**: define la clase y en cada archivo exporta funciones sueltas que llaman a una instancia singleton:
```js
class UsersController { async get(req,res){...} async post(req,res){...} }
const users = new UsersController(deps);
app.get("/users", users.get.bind(users));
app.post("/users", users.post.bind(users));
```
No hay “auto-registro”, pero el código es trivial y explícito.
---
Si quieres, puedo adaptar el ejemplo a **TypeScript** con tipos para `deps`, `schemas` y “generic views” (`ListView<T>`, etc.), o añadir una **CRUD genérica** lista para pegar. Solo dime qué framework y cuánto “azúcar” quieres y te lo dejo montado.
user
El autoregistro no me gusta, el as_view() de Django me parece mejor alternativa, y se puede integrar fácil en los plugins de Fastify para acoplarlo a las distintas rutas y que cree la instancia de la vista automáticamente y bien integrado.
¿Cual es el origen del nombre de Django?
ChatGPT
Exacto: el patrón `as_view()` de Django es mucho más claro que andar con autoregistro. Lo bueno es que en Fastify encaja perfecto con su filosofía de **plugins** y **closures**: defines tu clase vista, y `as_view()` devuelve un handler que el plugin registra en cada ruta. Así no hay magia de escaneo, pero sí tienes la conveniencia de instanciación automática. 🚀
---
### Sobre el nombre de **Django**
El framework fue creado en 2003–2005 por **Adrian Holovaty** y **Simon Willison** en la redacción del *Lawrence Journal-World* (Kansas, EE.UU.), para ayudarles a desarrollar sitios de noticias rápido.
El nombre **“Django”** se lo pusieron en honor a **Django Reinhardt**, el famoso guitarrista de jazz manouche belga-francés (1910–1953). Reinhardt es muy admirado por su virtuosismo pese a haber perdido la movilidad de dos dedos tras un incendio, y por su velocidad y creatividad.
La idea era transmitir que el framework fuera **rápido, expresivo y elegante**, igual que el estilo musical de Django Reinhardt.
---
👉 ¿Quieres que te arme un ejemplo práctico de `as_view()` para Fastify (al estilo Django), para que veas cómo quedaría la integración en un plugin?
user
De acuerdo
ChatGPT
¡Perfecto! Aquí tienes un patrón **`as_view()` para Fastify**, sin decoradores ni autoregistro, inspirado en Django. Es **mínimo, explícito y fácil de portar**.
---
# 1) Núcleo CBV: `BaseView.as_view()`
```js
// cbv.js
class HTTPError extends Error {
constructor(statusCode, message, payload) {
super(message);
this.statusCode = statusCode;
this.payload = payload;
}
}
/**
* Clase base de la que heredan tus vistas.
* Implementa métodos opcionales: before(), after(), y por HTTP: get/post/put/patch/delete/head/options
*/
class BaseView {
constructor(ctx = {}) {
// ctx suele incluir: { req, reply, fastify, ...deps }
Object.assign(this, ctx);
}
// Hooks opcionales por request
async before(/* req, reply */) {}
async after(/* req, reply, result */) {}
/**
* Devuelve un handler Fastify que instancia la vista y despacha por método HTTP.
* opts:
* - deps: dependencias a inyectar en el constructor
* - only: array de métodos permitidos (en minúscula). Ej: ['get', 'post']
* - onError: (err, req, reply) => void | Manejo de errores custom
* - transformResult: (result, req, reply) => any | Para adaptar la respuesta
* - methodMap: mapeo opcional de método HTTP -> nombre de método en la clase (por si quieres convención distinta)
*/
static as_view(opts = {}) {
const {
deps = {},
only = null,
onError = defaultErrorHandler,
transformResult = defaultTransformResult,
methodMap = {},
} = opts;
return async function handler(req, reply) {
const method = (req.method || 'GET').toLowerCase();
if (only && !only.includes(method)) {
return methodNotAllowed(reply, only);
}
const name = methodMap[method] || method;
const view = new this({ req, reply, fastify: req.server, ...deps });
// Busca el método en la instancia (get/post/...)
const fn = typeof view[name] === 'function' ? view[name] : null;
if (!fn) {
return methodNotAllowed(reply, only || discoverAvailable(view));
}
try {
if (typeof view.before === 'function') {
await view.before(req, reply);
}
const result = await fn.call(view, req, reply);
if (typeof view.after === 'function') {
await view.after(req, reply, result);
}
// Si el handler no gestionó reply manualmente, enviamos result
if (!reply.sent && typeof result !== 'undefined') {
const out = await transformResult(result, req, reply);
if (!reply.sent) reply.send(out);
}
} catch (err) {
await onError(err, req, reply);
}
}.bind(this); // importante: mantener la clase como "this"
}
}
function methodNotAllowed(reply, allowed = []) {
reply.header('Allow', allowed.map(m => m.toUpperCase()).join(', ') || 'GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS');
reply.code(405).send({ error: 'Method Not Allowed' });
}
async function defaultErrorHandler(err, req, reply) {
if (err && typeof err.statusCode === 'number') {
reply.code(err.statusCode).send(err.payload || { error: err.message || 'Error' });
} else {
req.log.error(err);
reply.code(500).send({ error: 'Internal Server Error' });
}
}
async function defaultTransformResult(result /*, req, reply */) {
return result; // por defecto, identidad
}
function discoverAvailable(view) {
const candidates = ['get','post','put','patch','delete','head','options'];
return candidates.filter(m => typeof view[m] === 'function');
}
module.exports = { BaseView, HTTPError };
```
---
# 2) Ejemplo de vista: `UsersView`
```js
// users-view.js
const { BaseView, HTTPError } = require('./cbv');
class UsersView extends BaseView {
// Puedes definir dependencias en this.usersRepo (inyectadas por deps)
async get(req, reply) {
// Ej: paginación simple
const items = await this.usersRepo.list({
limit: Number(req.query.limit || 50),
offset: Number(req.query.offset || 0),
});
return items;
}
async post(req, reply) {
const { name } = req.body || {};
if (!name) throw new HTTPError(400, 'name is required');
const created = await this.usersRepo.create({ name });
reply.code(201);
return created;
}
// Hooks opcionales
async before(req) {
// p.ej., auth / rate-limit / validación previa
// if (!req.headers.authorization) throw new HTTPError(401, 'Unauthorized');
}
async after(req, reply, result) {
// p.ej., logging de métricas
// this.metrics?.record({ route: req.routerPath, method: req.method, status: reply.statusCode });
}
}
module.exports = { UsersView };
```
---
# 3) Plugin de Fastify que monta rutas usando `as_view()`
```js
// users-plugin.js
'use strict';
const fp = require('fastify-plugin');
const { UsersView } = require('./users-view');
module.exports = fp(async function usersPlugin(fastify, opts) {
// Dependencias reales (repos/servicios). Podrías tomarlas de opts o de fastify.decorate
const deps = {
usersRepo: {
async list({ limit, offset }) {
return [{ id: 1, name: 'Ada' }, { id: 2, name: 'Grace' }].slice(offset, offset + limit);
},
async create({ name }) {
return { id: Date.now(), name };
},
}
};
// Schemas opcionales (recomendado en Fastify)
const listSchema = {
querystring: {
type: 'object',
properties: {
limit: { type: 'integer', minimum: 1, maximum: 100 },
offset: { type: 'integer', minimum: 0 },
}
},
response: {
200: {
type: 'array',
items: {
type: 'object',
properties: { id: { type: 'number' }, name: { type: 'string' } }
}
}
}
};
const createSchema = {
body: {
type: 'object',
required: ['name'],
properties: { name: { type: 'string', minLength: 1 } }
},
response: {
201: {
type: 'object',
properties: { id: { type: 'number' }, name: { type: 'string' } }
}
}
};
// Registras cada ruta de forma explícita, pero con instanciación automática via as_view()
fastify.route({
method: 'GET',
url: '/users',
schema: listSchema,
handler: UsersView.as_view({
deps,
only: ['get'], // opcional, garantiza 405 si llega otro método a esta ruta
}),
});
fastify.route({
method: 'POST',
url: '/users',
schema: createSchema,
handler: UsersView.as_view({
deps,
only: ['post'],
// onError: (err, req, reply) => { ... } // si quieres manejo custom
// transformResult: (out) => out // para adaptar la respuesta
}),
});
});
```
**Servidor principal**
```js
// server.js
const fastify = require('fastify')({ logger: true });
fastify.register(require('./users-plugin'));
fastify.listen({ port: 3000 }).catch(err => {
fastify.log.error(err);
process.exit(1);
});
```
- Cada `fastify.route(...)` sigue siendo **explícito** (sin escaneos ni magia).
- `as_view()` se ocupa de **crear la instancia por request**, ejecutar `before/after`, despachar al método (`get/post/...`) y enviar la respuesta si el método devolvió un valor.
- Si el método maneja la respuesta manualmente (`reply.send(...)`), no pasa nada: `as_view()` detecta `reply.sent`.
---
## Extras opcionales
- **`methodMap`**: si prefieres nombres distintos (`retrieve` para GET, `create` para POST…), pásalo en `as_view({ methodMap: { get: 'retrieve', post: 'create' } })`.
- **`HTTPError`** ya te da una vía cómoda para lanzar errores con `statusCode`.
- **Per-route hooks** de Fastify (p. ej., `preHandler`) los sigues pudiendo usar normalmente en `fastify.route({ preHandler: [...] })`. `as_view()` no interfiere.
- **HEAD/OPTIONS**: añade métodos `head()`/`options()` en tu clase si quieres controlarlos igual que en Django.
---
## Variante TypeScript (firma mínima)
Si lo deseas en TS, basta con tipar el contexto y las dependencias:
```ts
// cbv.ts (esqueleto de tipos)
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
export class HTTPError extends Error {
statusCode: number;
payload?: unknown;
constructor(statusCode: number, message?: string, payload?: unknown);
}
export interface ViewCtx<D = unknown> {
req: FastifyRequest;
reply: FastifyReply;
fastify: FastifyInstance;
} & D
export type AsViewOpts<D = unknown> = {
deps?: D;
only?: Array<'get'|'post'|'put'|'patch'|'delete'|'head'|'options'>;
onError?: (err: unknown, req: FastifyRequest, reply: FastifyReply) => unknown|Promise<unknown>;
transformResult?: (result: unknown, req: FastifyRequest, reply: FastifyReply) => unknown|Promise<unknown>;
methodMap?: Partial<Record<string, string>>;
};
export class BaseView<D = unknown> {
constructor(ctx?: Partial<ViewCtx<D>>);
before?(req: FastifyRequest, reply: FastifyReply): unknown|Promise<unknown>;
after?(req: FastifyRequest, reply: FastifyReply, result: unknown): unknown|Promise<unknown>;
static as_view<D = unknown>(opts?: AsViewOpts<D>): (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
}
```
---
Si te encaja, puedo añadirte una **`ModelView` genérica estilo Django** (`ListView`, `DetailView`, `CreateView`, etc.) o una **CRUD compacta** que sólo necesita un repo con `list/get/create/update/del`. ¿Quieres que la deje lista para copiar/pegar?
user
Las ModelView me gustan. ¿Como podría usar/implementar mixins? No hay herencia múltiple en Typescript
ChatGPT
¡Total! En TypeScript puedes conseguir “mixins” al estilo *traits* usando **funciones que devuelven clases** (class expressions) y composición de tipos. Así evitas la herencia múltiple real, pero combinas capacidades.
Aquí tienes un patrón práctico para tus **ModelView** + Fastify:
---
# 1) Infra: tipos y helper
```ts
// mixins.ts
export type Constructor<T = {}> = abstract new (...args: any[]) => T;
/** Helper opcional si quieres copiar métodos estáticos, etc. */
export function applyMixins(derivedCtor: any, baseCtors: any[]) {
for (const baseCtor of baseCtors) {
for (const name of Object.getOwnPropertyNames(baseCtor.prototype)) {
if (name === "constructor") continue;
Object.defineProperty(
derivedCtor.prototype,
name,
Object.getOwnPropertyDescriptor(baseCtor.prototype, name) || Object.create(null)
);
}
}
}
```
---
# 2) BaseView + ModelView (CRUD genérica)
```ts
// cbv.ts
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
export class HTTPError extends Error {
constructor(public statusCode: number, message?: string, public payload?: unknown) {
super(message);
}
}
export interface Ctx<D = unknown> {
req: FastifyRequest;
reply: FastifyReply;
fastify: FastifyInstance;
} & D
export abstract class BaseView<D = any> {
constructor(public ctx: Partial<Ctx<D>> = {}) {}
get req() { return this.ctx.req!; }
get reply() { return this.ctx.reply!; }
get fastify(){ return this.ctx.fastify!; }
async before() {}
async after(_result: unknown) {}
static as_view<T extends typeof BaseView<any>>(this: T, opts: { deps?: any; only?: string[] } = {}) {
const Cls = this;
const { deps = {}, only } = opts;
return async function handler(req: FastifyRequest, reply: FastifyReply) {
const method = (req.method || "GET").toLowerCase();
if (only && !only.includes(method)) {
reply.code(405).header("Allow", only.map(m => m.toUpperCase()).join(", ")).send({ error: "Method Not Allowed" });
return;
}
const view = new (Cls as any)({ req, reply, fastify: req.server, ...deps });
const fn = (view as any)[method];
if (typeof fn !== "function") {
reply.code(405).send({ error: "Method Not Allowed" });
return;
}
await view.before();
const out = await fn.call(view);
await view.after(out);
if (!reply.sent && typeof out !== "undefined") reply.send(out);
};
}
}
export abstract class ModelView<D = any, T = any, Id = string|number> extends BaseView<D> {
// Tu “repo” o fuente de datos llega vía deps
repo!: {
list(params?: any): Promise<T[]>;
get(id: Id): Promise<T | null>;
create(data: Partial<T>): Promise<T>;
update(id: Id, data: Partial<T>): Promise<T>;
del(id: Id): Promise<void>;
};
// Métodos HTTP delegan a “acciones” por convención (estilo Django)
async get() {
// si hay :id => detail, si no => list
const id = (this.req.params as any)?.id;
return id ? this.retrieve(id) : this.list();
}
async post() { return this.create(this.req.body as any); }
async put() { const { id } = this.req.params as any; return this.update(id, this.req.body as any); }
async patch() { const { id } = this.req.params as any; return this.partialUpdate?.(id, this.req.body as any) ?? this.update(id, this.req.body as any); }
async delete(){ const { id } = this.req.params as any; await this.destroy(id); return this.reply.code(204).send(); }
// Acciones “genéricas”
async list() { return this.repo.list(); }
async retrieve(id: Id) {
const item = await this.repo.get(id);
if (!item) throw new HTTPError(404, "Not Found");
return item;
}
async create(data: Partial<T>) { return this.repo.create(data); }
async update(id: Id, data: Partial<T>) { return this.repo.update(id, data); }
async destroy(id: Id) { return this.repo.del(id); }
async partialUpdate?(id: Id, data: Partial<T>): Promise<T>;
}
```
---
# 3) Mixins como *class factories*
## 3.1. AuthRequiredMixin
```ts
// mixins-auth.ts
import { Constructor, } from "./mixins";
import { HTTPError } from "./cbv";
export function AuthRequiredMixin<TBase extends Constructor>(Base: TBase) {
return class extends Base {
async before() {
// encadena super.before()
if (super.before) await super.before();
const auth = this.req.headers["authorization"];
if (!auth) throw new HTTPError(401, "Unauthorized");
// p.ej. parsear token y colgar user en this.ctx
(this.ctx as any).user = { id: "u1" };
}
};
}
```
## 3.2. PaginationMixin (para list)
```ts
// mixins-pagination.ts
import { Constructor } from "./mixins";
export function PaginationMixin<TBase extends Constructor>(Base: TBase) {
return class extends Base {
async list() {
const q: any = this.req.query || {};
const limit = Math.min(Math.max(Number(q.limit ?? 20), 1), 100);
const offset = Math.max(Number(q.offset ?? 0), 0);
const result = await super.list?.({ limit, offset }) ?? [];
// opcional: formato estándar
return { items: result, limit, offset };
}
};
}
```
## 3.3. ETagMixin (after)
```ts
// mixins-etag.ts
import { createHash } from "crypto";
import { Constructor } from "./mixins";
export function ETagMixin<TBase extends Constructor>(Base: TBase) {
return class extends Base {
async after(result: unknown) {
if (super.after) await super.after(result);
// añade un ETag simple si es JSON “pequeño”
if (!this.reply.sent && result && typeof result === "object") {
const hash = createHash("sha1").update(JSON.stringify(result)).digest("hex");
this.reply.header("ETag", `"${hash}"`);
}
}
};
}
```
---
# 4) Componer mixins sobre ModelView
```ts
// users-view.ts
import { ModelView } from "./cbv";
import { AuthRequiredMixin } from "./mixins-auth";
import { PaginationMixin } from "./mixins-pagination";
import { ETagMixin } from "./mixins-etag";
// Orden importa: el último mixin envuelve a los anteriores.
// Llama a super.* para cooperar (evitar “romper” la cadena).
class _UsersView extends ModelView<
{ repo: any }, // deps
{ id: number; name: string }, // modelo
number // Id
> {}
export class UsersView extends ETagMixin(PaginationMixin(AuthRequiredMixin(_UsersView))) {
// Puedes sobreescribir acciones:
async create(data: Partial<{ name: string }>) {
if (!data.name) throw new Error("name required");
return this.repo.create(data);
}
}
```
---
# 5) Uso en Fastify (rutas explícitas + as_view)
```ts
// users-plugin.ts
import fp from "fastify-plugin";
import { UsersView } from "./users-view";
export default fp(async (fastify) => {
const deps = {
repo: {
async list({ limit = 20, offset = 0 } = {}) {
const all = [{ id:1, name:"Ada"}, { id:2, name:"Grace"}, { id:3, name:"Hedy"}];
return all.slice(offset, offset + limit);
},
async get(id: number) { return { id, name: "User "+id } as any; },
async create(d: any) { return { id: Date.now(), ...d }; },
async update(id: number, d: any) { return { id, ...d }; },
async del(_id: number) { /* noop */ },
}
};
fastify.route({
method: "GET",
url: "/users",
handler: UsersView.as_view({ deps, only: ["get"] }),
});
fastify.route({
method: "GET",
url: "/users/:id",
handler: UsersView.as_view({ deps, only: ["get"] }),
});
fastify.route({
method: "POST",
url: "/users",
handler: UsersView.as_view({ deps, only: ["post"] }),
});
});
```
---
## Consejos y gotchas
- **Orden de mixins**: `ETagMixin(PaginationMixin(AuthRequiredMixin(Base)))`. El *outer* se ejecuta “al final”; encadena `super.*`.
- **Conflictos de método**: si dos mixins implementan `before()` o `list()`, el que esté más afuera manda, pero debe llamar a `super.*` para cooperar.
- **Tipos**: si tus mixins aportan estado/métodos adicionales, puedes ampliar la interfaz con `declare` o usar genéricos en el `Constructor`.
- **Alternativa**: si no quieres métodos superpuestos, usa **composición**: helpers puros que llamas dentro de tu `ModelView` (más explícito, menos magia).
Si te va bien, te dejo una variante con **`SearchMixin`** (filtros dinámicos), **`OrderingMixin`**, y **`JSONSchemaMixin`** para acoplar esquemas Fastify por acción. ¿Te la preparo?
user
O sea, estás creando clases anónimas dinámicamente. Yo solo conocía el ejecutar funciones con this para aumentar objetos, entiendo que esto es mejor de cara al uso de memoria al usar la cadena de prototipos en vez de añadir métodos a la instancia
ChatGPT
Exacto 👌
Lo que conocías (funciones que modifican un objeto y le “cuelgan” métodos directamente) es un **patrón de *object augmentation***: al final lo que haces es añadir propiedades en cada instancia, como si fueran “copias” de funciones. Eso funciona, pero:
- Cada instancia carga con sus propias referencias de métodos (aunque sean la misma función), lo cual **duplica más memoria** y rompe optimizaciones de V8 si no eres cuidadoso.
- No tienes una jerarquía clara de `super` → se vuelve difícil de encadenar lógica entre mixins.
- El tipado en TypeScript suele ser más incómodo, porque estás mutando objetos dinámicamente.
En cambio, el patrón de **class factories** (funciones que devuelven una clase anónima extendiendo otra) aprovecha la **cadena de prototipos** de JavaScript:
- Los métodos viven **una sola vez en el prototipo**, y todas las instancias los comparten → más eficiente en memoria.
- Puedes usar `super.method()` para encadenar comportamientos (lo que hace posible patrones cooperativos como `before()` en mixins).
- Es más natural para TypeScript: el resultado de `MyMixin(Base)` es otra clase tipada que extiende `Base` con métodos nuevos.
- Se integra de forma transparente con `instanceof`, `Object.getPrototypeOf()`, y la herencia normal de clases.
Esto es exactamente lo que hacen muchos frameworks modernos en JS/TS (incluido React internamente en algunos HOCs, o librerías de mixins clásicas).
---
👉 En resumen:
- **Augmentation (añadir métodos a instancias):** rápido de escribir, pero más memoria, menos orden, peor tipado.
- **Mixins basados en class factories:** un pelín más de boilerplate, pero mucho más limpio en rendimiento, composición y mantenibilidad.
¿Quieres que te prepare un **diagrama de cómo queda la cadena de prototipos** con 2–3 mixins aplicados, para que se vea claro el salto de clase en clase y dónde se guardan los métodos?
user
Ok
ChatGPT
¡Genial! Aquí tienes un diagrama claro de la **cadena de prototipos** cuando aplicas varios mixins como *class factories* sobre una `BaseView`:
```
Instancia (obj) ──[[Prototype]]──► UsersView.prototype
(clase final)
UsersView.prototype ──[[Prototype]]──► ETagMixin(...).prototype
(mixin 3 - outer)
ETagMixin(...).prototype ──[[Prototype]]──► PaginationMixin(...).prototype
(mixin 2)
PaginationMixin(...).prototype ──[[Prototype]]──► AuthRequiredMixin(...).prototype
(mixin 1 - inner)
AuthRequiredMixin(...).prototype ──[[Prototype]]──► ModelView.prototype
(tu clase base de modelo)
ModelView.prototype ──[[Prototype]]──► BaseView.prototype
(clase base genérica)
BaseView.prototype ──[[Prototype]]──► Object.prototype
```
### Lectura del diagrama
- Cada **mixin** es una **clase anónima** que `extends` a la clase que recibe y **añade/overridea** métodos.
- Los **métodos** viven **una sola vez** en cada `.prototype`. Todas las instancias los comparten → **mejor uso de memoria** que “pegar” funciones en cada instancia.
- `super` funciona porque cada eslabón conoce a su padre inmediato en la cadena.
- El orden importa: `UsersView = ETagMixin(PaginationMixin(AuthRequiredMixin(ModelView)))`.
---
## Mini demo para inspeccionar la cadena en tiempo de ejecución
```js
function protoNames(obj) {
const names = [];
let cur = obj;
while (cur) {
const ctor = cur.constructor && cur.constructor.name || '(anon)';
names.push(ctor + '.prototype');
cur = Object.getPrototypeOf(cur);
}
return names;
}
// Suponiendo que ya definiste UsersView = ETagMixin(PaginationMixin(AuthRequiredMixin(ModelView)))
const v = new UsersView({ /* ctx/deps */ });
console.log(protoNames(Object.getPrototypeOf(v)).join(' -> '));
// UsersView.prototype -> (anon).prototype [ETagMixin] -> (anon).prototype [PaginationMixin]
// -> (anon).prototype [AuthRequiredMixin] -> ModelView.prototype -> BaseView.prototype -> Object.prototype
console.log(v instanceof UsersView); // true
console.log(v instanceof Object); // true
```
> Nota: los mixins aparecen como `(anon).prototype` si no les pones nombre a las clases retornadas; puedes nombrarlas para depurar mejor.
---
## ¿Qué pasa con la memoria?
- Con **class factories**:
- Un solo **objeto función por método** en cada `.prototype`.
- Cada instancia sólo guarda su **estado**; los métodos están compartidos → **GC** más simple y menos presión de heap.
- Con **object augmentation** (añadir métodos a la instancia):
- Cada instancia recibe **referencias de función** propias (o cierres distintos) → más objetos/fns a recolectar, **peor localización** de código y menos oportunidades de inline/optimizaciones de V8.
---
## Consejo práctico
Si dos mixins sobreescriben el mismo método (p. ej. `before()`), asegúrate de **encadenar**:
```ts
class WithBefore extends Base {
async before() {
if (super.before) await super.before();
// tu lógica…
}
}
```
Así todos cooperan en orden de envoltura (de **inner** a **outer** a través de `super`).
Si quieres, te genero una versión con nombres de clases en los mixins para que el `protoNames` muestre etiquetas claras, y un ejemplo con `super.before()` encadenado paso a paso.
user
No entiendo para que son before y after
ChatGPT
Muy buena observación 👌, porque **`before` y `after` no son obligatorios**: son un **patrón de hooks** que añadí a las vistas por analogía con frameworks como Django, Flask o DRF. Te cuento con calma:
---
## ¿Qué son `before` y `after`?
Son **métodos opcionales de ciclo de vida** que tu `BaseView.as_view()` llama **alrededor del método HTTP principal** (`get`, `post`, etc.):
```
request -> before() -> get()/post()/... -> after() -> response
```
- `before(req, reply)`:
Se ejecuta **justo antes** de tu handler.
Útil para:
- Autenticación / autorización.
- Validaciones comunes a todos los métodos de la vista.
- Inicializar dependencias (p. ej. cargar `this.user` desde un token).
- Medir tiempos de inicio.
- `after(req, reply, result)`:
Se ejecuta **justo después** de tu handler, con el resultado que devolvió.
Útil para:
- Logging o métricas (tiempos de respuesta, estado HTTP).
- Postprocesar el resultado antes de enviarlo (ej. añadir cabeceras, ETag, formatear JSON).
- Ejecutar side-effects (p. ej. emitir un evento al bus).
---
## Ejemplo práctico
```ts
class UsersView extends BaseView<{ usersRepo: any }> {
async before() {
// Chequear auth
if (!this.req.headers.authorization) {
throw new HTTPError(401, "Unauthorized");
}
// Guardar el usuario en el contexto
this.ctx.user = { id: "u1", name: "Alice" };
}
async get() {
// Aquí ya tienes this.ctx.user listo
return this.usersRepo.list();
}
async after(_result: any) {
// Añadir cabecera común a todas las respuestas de esta vista
this.reply.header("X-Powered-By", "CBV Fastify");
}
}
```
---
## ¿Es obligatorio usarlos?
No 🚫. Son **azúcar sintáctico** para no repetir código en cada `get/post/...`.
Si prefieres, puedes:
- Meter la lógica de auth como un `preHandler` de Fastify.
- Meter métricas con un plugin de observabilidad.
- O simplemente no definir `before/after`.
---
👉 Dicho de otra forma: **`before/after` son hooks por vista**, mientras que en Fastify los `preHandler/onSend` son **hooks por ruta o globales**.
La gracia de tenerlos en `BaseView` es que los puedes **sobre-escribir o heredar** fácilmente en mixins, como hicimos con `AuthRequiredMixin`.
---
¿Quieres que te enseñe cómo sería el mismo flujo **usando sólo hooks de Fastify** (sin `before/after`) para que compares si te compensa tenerlos en la clase?
user
O sea, que son como un middleware. Mejor dejar que Fastify se encargue de ello, y tener una implementación minima
ChatGPT
💯 Exacto, lo has pillado:
- `before` y `after` en mis ejemplos cumplen la misma función que **middleware/hooks** en Fastify.
- Como Fastify ya tiene un sistema de hooks muy potente (`preHandler`, `onSend`, `onResponse`, etc.), meterlos en la clase es redundante si quieres mantener la implementación **mínima y clara**.
---
## Entonces, implementación mínima de CBV
Básicamente solo necesitas:
- Una **clase base** con el método estático `as_view()`.
- Métodos de instancia `get`, `post`, `put`, etc., opcionales.
- Una forma de inyectar dependencias en el constructor.
### Ejemplo simplificado
```js
// cbv.js
class HTTPError extends Error {
constructor(statusCode, message, payload) {
super(message);
this.statusCode = statusCode;
this.payload = payload;
}
}
class BaseView {
constructor(ctx = {}) {
Object.assign(this, ctx); // { req, reply, fastify, ...deps }
}
static as_view({ deps = {}, only } = {}) {
const Cls = this;
return async function handler(req, reply) {
const method = (req.method || "GET").toLowerCase();
if (only && !only.includes(method)) {
reply.code(405).send({ error: "Method Not Allowed" });
return;
}
const view = new Cls({ req, reply, fastify: req.server, ...deps });
const fn = view[method];
if (typeof fn !== "function") {
reply.code(405).send({ error: "Method Not Allowed" });
return;
}
try {
const result = await fn.call(view, req, reply);
if (!reply.sent && typeof result !== "undefined") {
reply.send(result);
}
} catch (err) {
if (err instanceof HTTPError) {
reply.code(err.statusCode).send(err.payload || { error: err.message });
} else {
req.log.error(err);
reply.code(500).send({ error: "Internal Server Error" });
}
}
};
}
}
module.exports = { BaseView, HTTPError };
```
---
### Uso en Fastify
```js
// users-view.js
const { BaseView, HTTPError } = require("./cbv");
class UsersView extends BaseView {
async get(req) {
return [{ id: 1, name: "Ada" }];
}
async post(req, reply) {
const { name } = req.body || {};
if (!name) throw new HTTPError(400, "Name required");
reply.code(201);
return { id: Date.now(), name };
}
}
module.exports = { UsersView };
```
```js
// users-plugin.js
const fp = require("fastify-plugin");
const { UsersView } = require("./users-view");
module.exports = fp(async function usersPlugin(fastify) {
fastify.route({
method: "GET",
url: "/users",
handler: UsersView.as_view(),
});
fastify.route({
method: "POST",
url: "/users",
handler: UsersView.as_view(),
});
});
```
---
🔹 De esta forma, tu CBV es **mínimo**:
- Sin `before/after`.
- Solo lo necesario para que una clase con métodos HTTP sea registrable como handler en Fastify.
- Todo lo demás (auth, logging, métricas, etc.) lo gestionas con **hooks Fastify** o **plugins** → el lugar natural para ello.
---
¿Quieres que te deje también un **ejemplo de ModelView CRUD mínima** encima de esta base, al estilo Django `ListView`/`DetailView`/`CreateView`, pero igual de ligera?
user
req.server entiendo que es la instancia del closure de Fastify después de pasar por todos los plugins y routers previos, no la instancia del servidor original, ¿correcto? ¿Tiene sentido el only, o ya Fastify habría hecho el filtrado de métodos? ¿Acaso se puede llamar a .route() sin indicar el método y que pase todos? ¿Tal vez que as_view() genere un nuevo plugin y se encargue Fastify del filtrado cuando haya varios métodos en la vista, o no tiene sentido lo que digo?
ChatGPT
Buenísimas preguntas. Voy por partes y corto:
### `req.server`
- Sí: `req.server` es la **instancia de Fastify** a la que pertenece esa ruta, **con todas las decoraciones ya aplicadas por los plugins** registradas antes del `listen()`.
- No es “una copia” ni una instancia nueva por *closure* por cada request; es la **misma FastifyInstance** (con estado compartido de decoraciones/plugins), accesible desde el handler.
### ¿Tiene sentido `only`?
Depende de **cómo registres** la ruta:
1) **Registro explícito por método** (recomendado si quieres mínima magia):
```js
fastify.route({ method: 'GET', url: '/users', handler: UsersView.as_view({ deps }) });
fastify.route({ method: 'POST', url: '/users', handler: UsersView.as_view({ deps }) });
```
- Aquí **Fastify ya filtra por método**; `only` no aporta nada.
- Si llega un método no registrado (p. ej. `PUT /users`), será **404** (no 405), porque no hay coincidencia de ruta.
2) **Una sola ruta con varios métodos**:
```js
fastify.route({
method: ['GET','POST'],
url: '/users',
handler: UsersView.as_view({ deps }),
});
```
- Fastify sólo invocará el handler para esos métodos. Para otros, de nuevo **404**.
- Dentro de `as_view()` tú **despachas** a `get()` o `post()` según `req.method`.
- `only` sigue siendo innecesario salvo que tú registres un **superset** (p. ej. incluyes `'PUT'` también) y quieras que `as_view()` responda **405 + `Allow`** para los no soportados por la clase. Si no haces ese superset, **no hace falta**.
### ¿Se puede llamar a `.route()` sin `method`?
- **No.** Fastify exige `method` (string o **array de strings**). No existe “route para todos los métodos” sin declararlos.
### ¿Hacer que `as_view()` genere un plugin y que Fastify filtre?
- **Se puede**, pero normalmente **no compensa**. Dos alternativas más simples:
- **Explícito por método** (opción 1): lo más claro y minimalista.
- Un **helper liviano** que te devuelva un mapa método→handler y tú sigues registrando explícito:
```js
function handlers(View, opts) {
return {
GET: View.as_view(opts),
POST: View.as_view(opts),
// añade sólo los que uses
};
}
const h = handlers(UsersView, { deps });
fastify.route({ method: 'GET', url: '/users', handler: h.GET });
fastify.route({ method: 'POST', url: '/users', handler: h.POST });
```
Así evitas “autoregistro mágico” pero reduces repetición.
### Resumen práctico
- `req.server` → FastifyInstance **ya decorada** por plugins.
- Usa **rutas explícitas por método** y olvídate de `only`. Es lo más simple.
- `method` en `.route()` es **obligatorio** (puede ser array).
- `as_view()` no necesita convertirse en plugin salvo que quieras aislar config/escopo; en la mayoría de casos, **innecesario**.
Si quieres, te dejo una variante de `as_view()` que **opcionalmente** añade cabecera `Allow` y responde 405 cuando registres un **superset** de métodos (por si te interesa ese comportamiento estilo Django).
user
Me interesa
ChatGPT
¡Hecho! Aquí tienes una versión de `as_view()` que **responde 405 + `Allow`** cuando registras un **superset** de métodos en Fastify y el método HTTP no está implementado por la vista. Incluye manejo elegante de **HEAD** (fallback a GET) y **OPTIONS** (auto-204 con `Allow`) sin usar decoradores ni autoregistro.
---
### `cbv.js` (mínimo + 405/Allow + HEAD/OPTIONS)
```js
// cbv.js
class HTTPError extends Error {
constructor(statusCode, message, payload) {
super(message);
this.statusCode = statusCode;
this.payload = payload;
}
}
const KNOWN = ["get","post","put","patch","delete","head","options"];
function discoverImplemented(view) {
return KNOWN.filter(m => typeof view[m] === "function");
}
function send405(reply, allow) {
reply.header("Allow", allow.map(m => m.toUpperCase()).join(", "));
reply.code(405).send({ error: "Method Not Allowed" });
}
class BaseView {
constructor(ctx = {}) {
Object.assign(this, ctx); // { req, reply, fastify, ...deps }
}
/**
* opts:
* - deps: objeto con dependencias a inyectar en el constructor
* - allowed: (opcional) lista explícita de métodos soportados por ESTA vista ["get","post",...]
* - respond405: (bool) si true, enviará 405+Allow cuando el método no esté implementado; si false, 404 del router
* - headFallbackToGet: (bool) si true y no hay head() pero sí get(), usa GET y descarta body
* - optionsAutoAllow: (bool) si true y no hay options(), responde 204 + Allow
*/
static as_view({
deps = {},
allowed = null,
respond405 = true,
headFallbackToGet = true,
optionsAutoAllow = true,
} = {}) {
const Cls = this;
return async function handler(req, reply) {
const method = (req.method || "GET").toLowerCase();
const view = new Cls({ req, reply, fastify: req.server, ...deps });
// Determinar qué métodos soporta la vista (o los que nos dan por opts)
const implemented = allowed && allowed.length
? allowed.map(m => m.toLowerCase())
: discoverImplemented(view);
// OPTIONS: si no está implementado, auto-204 con Allow (si está activado)
if (method === "options" && optionsAutoAllow && !view.options) {
reply.header("Allow", implemented.map(m => m.toUpperCase()).join(", "));
reply.code(204).send();
return;
}
// HEAD: si no hay head() pero hay get() y el fallback está activo
if (method === "head" && headFallbackToGet && !view.head && view.get) {
try {
const result = await view.get(req, reply);
// no enviar body en HEAD si no se ha respondido
if (!reply.sent) reply.send(); // Fastify enviará sólo headers/status
} catch (err) {
handleErr(err, req, reply);
}
return;
}
const fn = view[method];
// Método no implementado en la vista
if (typeof fn !== "function") {
if (respond405) {
// Importante: si registraste un SUPEREST de métodos en fastify.route({ method: [...] })
// llegaremos aquí; devolvemos 405 + Allow (estilo Django/HTTP correcto)
return send405(reply, implemented);
} else {
// Deja que el router resuelva (normalmente terminaría en 404 si no hay ruta)
reply.callNotFound(); // delega al notFound handler de Fastify
return;
}
}
try {
const result = await fn.call(view, req, reply);
if (!reply.sent && typeof result !== "undefined") {
reply.send(result);
}
} catch (err) {
handleErr(err, req, reply);
}
};
}
}
function handleErr(err, req, reply) {
if (err && typeof err.statusCode === "number") {
reply.code(err.statusCode).send(err.payload || { error: err.message || "Error" });
} else {
req.log.error(err);
reply.code(500).send({ error: "Internal Server Error" });
}
}
module.exports = { BaseView, HTTPError };
```
---
### Ejemplo de vista
```js
// users-view.js
const { BaseView, HTTPError } = require("./cbv");
class UsersView extends BaseView {
async get() {
return [{ id: 1, name: "Ada" }];
}
async post() {
const { name } = this.req.body || {};
if (!name) throw new HTTPError(400, "name required");
this.reply.code(201);
return { id: Date.now(), name };
}
// (sin put/patch/delete/head/options en esta clase)
}
module.exports = { UsersView };
```
---
### Registro en Fastify con **superset de métodos**
> Aquí es donde `respond405` cobra sentido: **registras todos** los métodos en la ruta y dejas a `as_view()` decidir si la vista los soporta. Los no soportados devolverán **405** con cabecera **`Allow`** correcta.
```js
// users-plugin.js
'use strict';
const fp = require('fastify-plugin');
const { UsersView } = require('./users-view');
module.exports = fp(async function usersPlugin(fastify) {
fastify.route({
method: ['GET','POST','PUT','PATCH','DELETE','HEAD','OPTIONS'],
url: '/users',
handler: UsersView.as_view({
// allowed: ['get','post'], // opcional; si lo omites, se descubre por introspección
respond405: true,
headFallbackToGet: true, // HEAD usará GET y no enviará body si no defines head()
optionsAutoAllow: true, // OPTIONS 204 + Allow si no defines options()
}),
});
});
```
**Comportamiento resultante en `/users`:**
- `GET` → OK (200 + body).
- `POST` → OK (201 + body).
- `HEAD` (sin `head()` en la clase) → usa `GET` internamente, **no envía body**.
- `OPTIONS` (sin `options()` en la clase) → **204** + `Allow: GET, POST, HEAD, OPTIONS` (HEAD/OPTIONS se incluyen si corresponden).
- `PUT/PATCH/DELETE` → **405** + `Allow: GET, POST, HEAD, OPTIONS`.
> Si **prefieres 404** cuando no implementas un método, basta con **no registrar** ese método en `fastify.route({ method: [...] })` o poner `respond405: false` (y que el router decida).
---
## Cuándo usar cada modo
- **Minimalista/Explícito (sin 405):** registra solo los métodos que realmente usas; Fastify devolverá 404 para el resto.
- **Estilo “framework” (con 405 + Allow):** registra el superset y deja que la vista **publique su contrato** (405 + `Allow`). Suele ser más HTTP-correct y muy útil para clientes y pruebas.
Si quieres, te dejo una variante TypeScript con tipos para `allowed`, y tests de tabla (método → status esperado) para que puedas validar el comportamiento rápidamente.
user
Me gusta el estilo framework, pero no tener que registrar los métodos en dos sitios distintos, o tener que registrar la vista varias veces
ChatGPT
Perfecto: podemos tener **estilo framework** (un solo registro por URL) sin duplicar métodos ni registrar la vista varias veces, y **sin escanear** directorios. La idea es un **helper** que:
- Registra **una sola ruta** con el **superset** de métodos: `['GET','POST','PUT','PATCH','DELETE','HEAD','OPTIONS']`.
- Usa `as_view()` para:
- Despachar al método de la clase (`get/post/...`).
- Responder **405 + `Allow`** si el método no está implementado por la vista.
- Implementar `HEAD` (fallback a `GET`) y `OPTIONS` (204 + `Allow`) si no los defines.
Así solo declaras la URL una vez y punto.
---
### Helper “registrar una vista, una vez”
```js
// fastify-cbv.js
'use strict';
const KNOWN = ['GET','POST','PUT','PATCH','DELETE','HEAD','OPTIONS'];
/**
* Registra una vista basada en clase en una sola ruta con superset de métodos.
* No hay escaneo ni autoregistro; tú llamas explícitamente por cada URL.
*
* @param {import('fastify').FastifyInstance} fastify
* @param {string} url
* @param {typeof BaseView} ViewClass // tu clase que hereda de BaseView
* @param {object} [opts]
* - deps: dependencias para el constructor de la vista
* - allowed: ['get','post',...] // opcional; si no, se detecta por introspección
* - headFallbackToGet, optionsAutoAllow, respond405: igual que en as_view()
* - common: opciones comunes de fastify.route (preHandler, onRequest, config, etc.)
*/
function registerCBV(fastify, url, ViewClass, opts = {}) {
const {
deps,
allowed,
headFallbackToGet = true,
optionsAutoAllow = true,
respond405 = true,
common = {},
} = opts;
fastify.route({
method: KNOWN, // superset => as_view() decide qué está soportado
url,
...common, // aquí puedes poner preHandler, onRequest, config, etc.
handler: ViewClass.as_view({
deps,
allowed,
headFallbackToGet,
optionsAutoAllow,
respond405
}),
});
}
module.exports = { registerCBV };
```
Tu `BaseView.as_view()` es la versión que ya te pasé con 405/Allow + HEAD/OPTIONS. (Si no la tienes, dímelo y la pego de nuevo en TS/JS).
---
### Uso
```js
// users-view.js
const { BaseView, HTTPError } = require('./cbv');
class UsersView extends BaseView {
async get() { return [{ id: 1, name: 'Ada' }]; }
async post() {
const { name } = this.req.body || {};
if (!name) throw new HTTPError(400, 'name required');
this.reply.code(201);
return { id: Date.now(), name };
}
// put/patch/delete no implementados => 405 + Allow
}
module.exports = { UsersView };
```
```js
// users-plugin.js
'use strict';
const fp = require('fastify-plugin');
const { registerCBV } = require('./fastify-cbv');
const { UsersView } = require('./users-view');
module.exports = fp(async function usersPlugin(fastify) {
registerCBV(fastify, '/users', UsersView, {
deps: { /* repos, servicios… */ },
// allowed: ['get','post'], // opcional; si lo omites, se detecta
respond405: true,
headFallbackToGet: true,
optionsAutoAllow: true,
common: {
// Hooks o config por ruta (Fastify se encarga de “middleware”)
// preHandler: [authHook],
},
});
});
```
**Comportamiento en `/users`:**
- `GET` → 200 (siempre que implementes `get()`).
- `POST` → 201 (si implementas `post()`).
- `HEAD` → usa `GET` y no envía body si no defines `head()`.
- `OPTIONS` → 204 + `Allow` si no defines `options()`.
- `PUT/PATCH/DELETE` → **405** + `Allow` (porque no están implementados en la clase).
---
### Nota importante: esquemas por método
Con **una sola ruta** Fastify aplica **un único `schema`** a todos los métodos. Si necesitas **schemas distintos por método**, hay 3 caminos:
1) Registrar **rutas separadas** por método (lo más Fastify-idiomático).
2) Un `schema` “union” que acepte variantes (más tosco).
3) Compilar validaciones manuales dentro de tus handlers (menos idiomático).
Si quieres single-route + per-method schema “de lujo”, no es trivial porque Fastify resuelve el `schema` en **registro**, no en **runtime**.
---
¿Te preparo la versión en **TypeScript** del helper + `BaseView` con tipos (`allowed`, `deps`) y una `ModelView` CRUD mínima (list/retrieve/create/update/destroy) lista para pegar?
user
usa https://github.com/Eomm/fastify-405 para gestionar el 405, y haz que as_view() devuelva un plugin de Fastify con los distintos metodos HTTP, para ser usado con fastify.register(), usando options.prefix para indicar la ruta, creo que es como mejor se integraria. Tambien, cambia el nombre de as_view() de Django por as_plugin()
ChatGPT
¡Perfecto! Vamos a dejarlo “estilo framework”, con **un solo `register()` por URL** y sin duplicar nada:
- `as_plugin()` (nuevo nombre) **devuelve un plugin de Fastify**.
- Ese plugin:
- **Registra** `fastify-405` en su propio ámbito.
- **Crea una ruta por cada método realmente implementado** en tu clase (`get`, `post`, `put`, `patch`, `delete`, `head`, `options`).
- Se monta con `fastify.register(View.as_plugin(...), { prefix: "/ruta" })` → la URL final es el `prefix`.
- Como solo registramos los métodos implementados, **`fastify-405` devuelve 405 + `Allow`** para los demás métodos en **esa misma URL** (sin que tú hagas nada extra). Esto no es el comportamiento por defecto de Fastify (que devolvería 404), de ahí el plugin. citeturn1search6turn0search4turn0search0
---
## Núcleo: `BaseView.as_plugin()` (JS)
```js
// cbv.js
'use strict';
const fp = require('fastify-plugin');
const HTTP_METHODS = ['get','post','put','patch','delete','head','options'];
class HTTPError extends Error {
constructor(statusCode, message, payload) {
super(message);
this.statusCode = statusCode;
this.payload = payload;
}
}
class BaseView {
constructor(ctx = {}) {
Object.assign(this, ctx); // { req, reply, fastify, ...deps }
}
/**
* Devuelve un plugin Fastify que registra rutas por cada método implementado.
* Opciones:
* - deps: objeto de dependencias a inyectar en cada instancia de la vista
* - schemas: { get?: {...}, post?: {...}, ... } // schema por método
* - common: opciones comunes de fastify.route (preHandler, config, etc.)
*/
static as_plugin({ deps = {}, schemas = {}, common = {} } = {}) {
const ViewClass = this;
return fp(async function viewPlugin(fastify) {
// Habilita 405 + Allow en este ámbito del plugin
// (sin este plugin, Fastify respondería 404 a métodos no registrados)
fastify.register(require('fastify-405')); // ver ecosistema Fastify. citeturn0search7
// Por cada método implementado, registramos UNA ruta en "/"
for (const m of HTTP_METHODS) {
const hasMethod = typeof ViewClass.prototype[m] === 'function';
if (!hasMethod) continue;
fastify.route({
method: m.toUpperCase(),
url: '/', // se unirá con options.prefix del register()
...common,
schema: schemas[m] || common.schema,
handler: async (req, reply) => {
const view = new ViewClass({ req, reply, fastify: req.server, ...deps });
try {
const result = await view[m](req, reply);
if (!reply.sent && typeof result !== 'undefined') reply.send(result);
} catch (err) {
if (err && typeof err.statusCode === 'number') {
reply.code(err.statusCode).send(err.payload || { error: err.message || 'Error' });
} else {
req.log.error(err);
reply.code(500).send({ error: 'Internal Server Error' });
}
}
}
});
}
});
}
}
module.exports = { BaseView, HTTPError };
```
> Notas:
> - No auto-escaneamos directorios ni “autoregistro”: **tú decides** dónde y con qué `prefix` montar cada vista.
> - `fastify-405` hace que, si por ejemplo solo registras `GET` y `POST` en `"/users"`, una petición `PUT /users` responda **405** y `Allow: GET, POST`. Sin el plugin, eso sería 404. citeturn0search2turn0search0
---
## Ejemplo de uso
```js
// users-view.js
'use strict';
const { BaseView, HTTPError } = require('./cbv');
class UsersView extends BaseView {
async get(req) {
const limit = Number(req.query.limit || 50);
const offset = Number(req.query.offset || 0);
return (await this.usersRepo.list({ limit, offset }));
}
async post(req, reply) {
const { name } = req.body || {};
if (!name) throw new HTTPError(400, 'name is required');
const created = await this.usersRepo.create({ name });
reply.code(201);
return created;
}
// sin put/patch/delete/head/options -> fastify-405 devolverá 405 + Allow
}
module.exports = { UsersView };
```
```js
// users-plugin.js
'use strict';
const fp = require('fastify-plugin');
const { UsersView } = require('./users-view');
module.exports = fp(async function usersPlugin(fastify) {
const deps = {
usersRepo: {
async list({ limit, offset }) {
const all = [{ id:1, name:'Ada' }, { id:2, name:'Grace' }, { id:3, name:'Hedy' }];
return all.slice(offset, offset + limit);
},
async create({ name }) { return { id: Date.now(), name }; }
}
};
const schemas = {
get: {
querystring: {
type: 'object',
properties: {
limit: { type: 'integer', minimum: 1, maximum: 100 },
offset: { type: 'integer', minimum: 0 }
}
},
response: {
200: { type: 'array', items: { type: 'object', properties: { id: { type: 'number' }, name: { type: 'string' } } } }
}
},
post: {
body: { type: 'object', required: ['name'], properties: { name: { type: 'string', minLength: 1 } } },
response: { 201: { type: 'object', properties: { id: { type: 'number' }, name: { type: 'string' } } } }
}
};
// 🔑 Un solo register con prefix = URL
fastify.register(UsersView.as_plugin({ deps, schemas }), { prefix: '/users' });
});
```
```js
// server.js
'use strict';
const fastify = require('fastify')({ logger: true });
fastify.register(require('./users-plugin'));
fastify.listen({ port: 3000 });
```
### ¿Qué obtienes en `/users`?
- `GET /users` → 200 con lista (valida con su schema).
- `POST /users` → 201 con creado (valida con su schema).
- `PUT/PATCH/DELETE/HEAD/OPTIONS /users` → **405** + `Allow: GET, POST` (gracias a `fastify-405`). citeturn0search2
---
### Sobre schemas por método
Con este diseño (una ruta por método en el mismo `prefix`), **sí puedes** tener `schema` distinto por método (como en Django CBV/DRF). Si algún método comparte opciones (hooks, `config`, etc.), ponlas en `common` y se aplican a todos.
---
Si quieres, te lo paso también en **TypeScript** (con tipos para `schemas`, `deps` y helpers para construir `ModelView` CRUD) o te añado soporte opcional de **HEAD→GET** y **OPTIONS auto-Allow** dentro de tu clase (si no deseas delegarlo a `fastify-405`).
user
- utiliza ESMmodules en vez de CommonJS require
- incluye para el autodescubrimiento todos los metodos soportados por node.js
- ¿como podemos definir el scheme de los metodos dentro de la clase en vez de en register()? ¿quizas a partir de la definicion de tipos con Typescript, tal vez con una libreria externa, o no es posible?
- me gustan tus ejemplos usando nombres de mujeres importantes en computacion en los fixtures
- respecto a HEAD, no lo metas, ya lo hace Fastify por si mismo, pero ten en cuenta su documentacion:
```
exposeHeadRoutes
Default: true
Automatically creates a sibling HEAD route for each GET route defined. If you want a custom HEAD handler without disabling this option, make sure to define it before the GET route.
```
ChatGPT
¡Hecho! Te dejo una versión **ESM** “estilo framework” que:
- usa **`fastify-405`** para devolver **405 + `Allow`** cuando el método no está implementado;
- **no registra `HEAD`** (Fastify lo autogenera junto al `GET`, como indicas);
- registra **una única vez** la vista con `fastify.register(View.as_plugin(), { prefix })`;
- **autodescubre TODOS los métodos soportados por Node.js** a partir de `http.METHODS` (y luego solo registra los que tu clase implemente);
- permite definir **schemas por método dentro de la clase** (y además te doy dos alternativas con TypeBox y Zod).
> Referencias: `exposeHeadRoutes` de Fastify (por defecto `true`) y ecosistema/links de `fastify-405`. citeturn0search1turn0search4turn0search16turn2view0
---
# 1) Núcleo ESM
```js
// cbv.mjs
import fp from 'fastify-plugin';
import allow405 from 'fastify-405'; // plugin que añade 405 + Allow en rutas con handlers. citeturn2view0
import { METHODS } from 'node:http'; // lista de métodos soportados por Node
// Conjunto de métodos: todos los de Node, en minúsculas, sin HEAD (Fastify lo genera con GET)
const NODE_METHODS = Array.from(
new Set(METHODS.map(m => m.toLowerCase()))
).filter(m => m !== 'head'); // HEAD lo maneja Fastify (exposeHeadRoutes). citeturn0search1turn0search16
export class HTTPError extends Error {
constructor(statusCode, message, payload) {
super(message);
this.statusCode = statusCode;
this.payload = payload;
}
}
export class BaseView {
constructor(ctx = {}) {
Object.assign(this, ctx); // { req, reply, fastify, ...deps }
}
/**
* Devuelve un plugin Fastify que:
* - registra fastify-405 en su propio ámbito
* - crea UNA ruta por cada método implementado por la clase
* - aplica schemas per-método tomados de la propia clase (estáticos)
*
* Opciones:
* - deps: objeto de dependencias a inyectar en cada instancia
* - common: opciones comunes de fastify.route (preHandler, config, onRequest, etc.)
*/
static as_plugin({ deps = {}, common = {} } = {}) {
const ViewClass = this;
return fp(async function viewPlugin(fastify) {
// Habilita 405 + Allow en este scope (para métodos no registrados en esta URL)
fastify.register(allow405); // citeturn2view0
// Descubre qué métodos HTTP implementa la clase: get/post/put/... en minúscula
const implemented = NODE_METHODS.filter(m => typeof ViewClass.prototype[m] === 'function');
// Schemas por método, definidos estáticamente en la clase (ver más abajo)
const staticSchemas = ViewClass.schemas && typeof ViewClass.schemas === 'object'
? ViewClass.schemas
: {};
for (const m of implemented) {
fastify.route({
method: m.toUpperCase(),
url: '/', // se unirá con el options.prefix de fastify.register()
...common,
schema: staticSchemas[m] || common.schema,
handler: async (req, reply) => {
const view = new ViewClass({ req, reply, fastify: req.server, ...deps });
try {
const result = await view[m](req, reply);
if (!reply.sent && typeof result !== 'undefined') {
reply.send(result);
}
} catch (err) {
if (err && typeof err.statusCode === 'number') {
reply.code(err.statusCode).send(err.payload || { error: err.message || 'Error' });
} else {
req.log.error(err);
reply.code(500).send({ error: 'Internal Server Error' });
}
}
}
});
}
});
}
}
```
> ✅ **HEAD**: no lo registramos. Fastify creará la ruta HEAD “hermana” de GET automáticamente mientras `exposeHeadRoutes` sea `true`. Si alguna vez quisieras un `HEAD` custom, Fastify requiere **definir `head()` antes del `get()`** o desactivar `exposeHeadRoutes`. citeturn0search1turn0search13
---
# 2) Definir **schemas por método dentro de la clase**
Como los **tipos de TypeScript se borran en runtime**, no se puede leer el tipo y convertirlo mágicamente a JSON Schema sin ayuda. Lo habitual es:
1) **Definir `static schemas` en la clase** (simple y directo).
2) Usar una librería **runtime-friendly** con tipado:
- **TypeBox**: schemas son objetos JSON Schema válidos y obtienes tipos TS por inferencia.
- **Zod**: defines con Zod y conviertes a JSON Schema con `zod-to-json-schema` (o usas `fastify-type-provider-zod` para validación directa).
### A) `static schemas` (plain JSON Schema)
```js
// users-view.mjs
import { BaseView, HTTPError } from './cbv.mjs';
export class UsersView extends BaseView {
static schemas = {
get: {
querystring: {
type: 'object',
properties: {
limit: { type: 'integer', minimum: 1, maximum: 100 },
offset: { type: 'integer', minimum: 0 }
}
},
response: {
200: {
type: 'array',
items: {
type: 'object',
properties: { id: { type: 'number' }, name: { type: 'string' } },
required: ['id','name']
}
}
}
},
post: {
body: {
type: 'object',
required: ['name'],
properties: { name: { type: 'string', minLength: 1 } }
},
response: {
201: {
type: 'object',
properties: { id: { type: 'number' }, name: { type: 'string' } },
required: ['id','name']
}
}
}
};
async get(req) {
const limit = Number(req.query.limit ?? 50);
const offset = Number(req.query.offset ?? 0);
// fixtures con pioneras :)
const all = [
{ id: 1, name: 'Ada' },
{ id: 2, name: 'Grace' },
{ id: 3, name: 'Hedy' },
{ id: 4, name: 'Radia' }
];
return all.slice(offset, offset + limit);
}
async post(req, reply) {
const { name } = req.body ?? {};
if (!name) throw new HTTPError(400, 'name is required');
reply.code(201);
return { id: Date.now(), name };
}
}
```
### B) **TypeBox** (schemas + tipos a la vez)
```js
// users-view-typebox.mjs
import { BaseView, HTTPError } from './cbv.mjs';
import { Type } from '@sinclair/typebox'; // schemas runtime
import { TypeCompiler } from '@sinclair/typebox/compiler'; // opcional, si quieres compilar
export class UsersViewTB extends BaseView {
// JSON Schema “reales”; Fastify puede usarlos tal cual
static schemas = {
get: {
querystring: Type.Object({
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
offset: Type.Optional(Type.Integer({ minimum: 0 }))
}),
response: {
200: Type.Array(
Type.Object({
id: Type.Number(),
name: Type.String()
})
)
}
},
post: {
body: Type.Object({ name: Type.String({ minLength: 1 }) }),
response: {
201: Type.Object({ id: Type.Number(), name: Type.String() })
}
}
};
async get(req) { /* igual que antes */ return [{ id:1, name:'Ada' }]; }
async post(req, reply) { /* igual que antes */ reply.code(201); return { id: Date.now(), name: req.body.name }; }
}
```
> Con TypeBox, además de tener schemas “vivos” en runtime, puedes **inferir tipos TS** en archivos `.ts` si lo prefieres. Si estás en JS puro, igualmente sirve porque es JSON Schema válido.
### C) **Zod** (opción runtime → convertir a JSON Schema)
```js
// users-view-zod.mjs
import { BaseView, HTTPError } from './cbv.mjs';
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
const GetQuery = z.object({
limit: z.number().int().min(1).max(100).optional(),
offset: z.number().int().min(0).optional()
});
const GetResp = z.array(z.object({ id: z.number(), name: z.string() }));
const PostBody = z.object({ name: z.string().min(1) });
const PostResp = z.object({ id: z.number(), name: z.string() });
export class UsersViewZ extends BaseView {
static schemas = {
get: {
querystring: zodToJsonSchema(GetQuery, { target: 'openApi3' }),
response: { 200: zodToJsonSchema(GetResp, { target: 'openApi3' }) }
},
post: {
body: zodToJsonSchema(PostBody, { target: 'openApi3' }),
response: { 201: zodToJsonSchema(PostResp, { target: 'openApi3' }) }
}
};
async get(req) { return [{ id: 1, name: 'Grace' }]; }
async post(req, reply) { const { name } = PostBody.parse(req.body); reply.code(201); return { id: Date.now(), name }; }
}
```
---
# 3) Registro “de una pieza” con `prefix`
```js
// users-plugin.mjs
import fp from 'fastify-plugin';
import { UsersView } from './users-view.mjs'; // o UsersViewTB / UsersViewZ
export default fp(async function usersPlugin(fastify) {
const deps = {
usersRepo: {
async list({ limit = 50, offset = 0 }) {
const all = [
{ id: 1, name: 'Ada' },
{ id: 2, name: 'Grace' },
{ id: 3, name: 'Hedy' },
{ id: 4, name: 'Radia' }
];
return all.slice(offset, offset + limit);
},
async create({ name }) { return { id: Date.now(), name }; }
}
};
// 👇 Un solo registro; la URL final es /users
fastify.register(UsersView.as_plugin({ deps }), { prefix: '/users' });
});
```
```js
// server.mjs
import Fastify from 'fastify';
import usersPlugin from './users-plugin.mjs';
const app = Fastify({ logger: true });
// HEAD se genera automáticamente para GET (exposeHeadRoutes: true) citeturn0search1
await app.register(usersPlugin);
await app.listen({ port: 3000 });
```
---
## Notas finales
- **HEAD**: al no registrarlo, Fastify crea la ruta `HEAD /users` “hermana” del `GET /users` (mientras `exposeHeadRoutes` sea `true`). Si algún día quisieras **`HEAD` custom**, defínelo **antes** del `GET` (o desactiva `exposeHeadRoutes`). citeturn0search1turn0search16
- **405 + `Allow`**: `fastify-405` se encarga automáticamente de devolver **405** con los métodos permitidos en esa URL cuando llega, por ejemplo, un `PUT` pero solo implementaste `GET` y `POST`. citeturn2view0
- **Autodescubrimiento de métodos**: tomamos **todos los métodos que expone Node** (`http.METHODS`) para no dejarnos ninguno exótico (p. ej., `PURGE`, `LINK`, `UNLINK`, según plataforma). Luego registramos **solo** los que tu clase implemente en minúscula (e.g., `purge()` si te diera por soportarlo).
- **Schemas en la clase**: es la vía más sencilla. Si quieres **tipos TS sincronizados**, usa **TypeBox** (recomendado) o **Zod** con conversión a JSON Schema.
¿Te preparo la variante en **TypeScript** (con tipos genéricos para `deps`, helpers `ModelView` CRUD y type providers para TypeBox/Zod)?
user
- HEAD tienes que comprobarlo, aunque Fastify lo procese por si mismo, lo genera por cada GET que este definido, si no hay ya uno definido previamente para esa ruta. Es decir, asegurate de comprobar y añadir un metodo HEAD si esta definido en la clase antes del GET
- descarta zod. Me gusta https://github.com/fastify/fastify-type-provider-json-schema-to-ts, es totalmente transparente, pero me sigue pareciendo que definir el schema por separado de su funcion/metodo va contra DRY. ¿No hay ningun parser desde los argumentos de la funcion definidos en TypeScript? ¿Seria posible definir de este modo tambien los responses y su status code dentro del reply, para generar el schema desde Typescript, o no es lo suficientemente potente y la opcion mas completa es generar los tipos de Typescript desde el schema?
ChatGPT
¡Perfecto! Voy a ajustar tres cosas clave:
1) **HEAD**: si tu clase define `head()`, hay que **registrarlo antes** que `get()` para que Fastify NO autogenere el HEAD “hermano”. Si no defines `head()`, **no lo registramos** y Fastify lo genera automáticamente al registrar `get()` (si `exposeHeadRoutes: true`).
2) **Autodescubrimiento de métodos**: usamos `node:http` → `http.METHODS` (todos los soportados por Node), en minúscula, **excluyendo `head` del set general** para poder colocarlo “primero” si existe, y dejando que Fastify lo autogenere solo cuando no lo implementes.
3) **DRY con tipos/esquemas**:
- En **runtime**, TypeScript **borra tipos**. No hay un parser estándar que “lea” los tipos de la firma de la función en ejecución y produzca JSON Schema.
- Opciones reales hoy:
- **Schema→Types (DRY de runtime)**: seguir con `fastify-type-provider-json-schema-to-ts`. Es “transparente”: defines **JSON Schema** (la verdad única) y obtienes los **tipos TS** derivados para `req.body`, `req.query`, etc.
- **Types→Schema (DRY desde TS)**: necesitas **build step**. Por ejemplo, `ts-json-schema-generator`, `tsoa` (vía decoradores), o pipelines OpenAPI (OpenAPI como fuente o destino). Ninguna es 100% “automágica” leyendo solo la firma, pero dan un flujo “types-first”.
Abajo te dejo código ESM del **as_plugin()** con HEAD tratado correctamente, y después un resumen de estrategias para el DRY tipos/esquemas.
---
# as_plugin() ESM con HEAD “antes que GET”
```js
// cbv.mjs
import fp from 'fastify-plugin';
import allow405 from 'fastify-405';
import { METHODS } from 'node:http';
// Todos los métodos soportados por Node, en minúscula, únicos
const NODE_METHODS = Array.from(new Set(METHODS.map(m => m.toLowerCase())));
// Quitamos 'head' del conjunto principal para tratarlo aparte
const CORE_METHODS = NODE_METHODS.filter(m => m !== 'head');
export class HTTPError extends Error {
constructor(statusCode, message, payload) {
super(message);
this.statusCode = statusCode;
this.payload = payload;
}
}
export class BaseView {
constructor(ctx = {}) {
Object.assign(this, ctx); // { req, reply, fastify, ...deps }
}
/**
* Devuelve un plugin que:
* - registra fastify-405 (405 + Allow para métodos no implementados)
* - registra UNA ruta por cada método que la clase implemente
* - si existe head(), lo registra ANTES que get()
* - aplica schemas por método definidos en la propia clase (static schemas)
*
* @param {object} opts
* - deps: dependencias inyectadas a cada instancia
* - common: opciones comunes Fastify route (preHandler, config, etc.)
*/
static as_plugin({ deps = {}, common = {} } = {}) {
const ViewClass = this;
return fp(async function viewPlugin(fastify) {
fastify.register(allow405);
// ¿La clase implementa head()?
const hasHead = typeof ViewClass.prototype.head === 'function';
// Schemas estáticos por método (opcional)
const staticSchemas = ViewClass.schemas && typeof ViewClass.schemas === 'object'
? ViewClass.schemas
: {};
// 1) HEAD primero si está implementado
if (hasHead) {
fastify.route({
method: 'HEAD',
url: '/',
...common,
schema: staticSchemas.head || common.schema,
handler: async (req, reply) => {
const view = new ViewClass({ req, reply, fastify: req.server, ...deps });
try {
const result = await view.head(req, reply);
if (!reply.sent && typeof result !== 'undefined') reply.send(result);
} catch (err) { handleErr(err, req, reply); }
}
});
}
// 2) Resto de métodos; si no hay head() y sí hay get(),
// Fastify autogenerará HEAD “hermano” (exposeHeadRoutes: true por defecto).
for (const m of CORE_METHODS) {
if (typeof ViewClass.prototype[m] !== 'function') continue;
fastify.route({
method: m.toUpperCase(),
url: '/',
...common,
schema: staticSchemas[m] || common.schema,
handler: async (req, reply) => {
const view = new ViewClass({ req, reply, fastify: req.server, ...deps });
try {
const result = await view[m](req, reply);
if (!reply.sent && typeof result !== 'undefined') reply.send(result);
} catch (err) { handleErr(err, req, reply); }
}
});
}
});
}
}
function handleErr(err, req, reply) {
if (err && typeof err.statusCode === 'number') {
reply.code(err.statusCode).send(err.payload || { error: err.message || 'Error' });
} else {
req.log.error(err);
reply.code(500).send({ error: 'Internal Server Error' });
}
}
```
Uso (igual que antes):
```js
// users-view.mjs
import { BaseView, HTTPError } from './cbv.mjs';
export class UsersView extends BaseView {
static schemas = {
get: {
querystring: {
type: 'object',
properties: {
limit: { type: 'integer', minimum: 1, maximum: 100 },
offset: { type: 'integer', minimum: 0 }
}
},
response: {
200: {
type: 'array',
items: {
type: 'object',
properties: { id: { type: 'number' }, name: { type: 'string' } },
required: ['id','name']
}
}
}
},
post: {
body: {
type: 'object',
required: ['name'],
properties: { name: { type: 'string', minLength: 1 } }
},
response: {
201: {
type: 'object',
properties: { id: { type: 'number' }, name: { type: 'string' } },
required: ['id','name']
}
}
}
// head: {...} // si quieres HEAD custom, incluir y definir this.head()
};
async get(req) {
const limit = Number(req.query.limit ?? 50);
const offset = Number(req.query.offset ?? 0);
const all = [
{ id: 1, name: 'Ada' },
{ id: 2, name: 'Grace' },
{ id: 3, name: 'Hedy' },
{ id: 4, name: 'Radia' }
];
return all.slice(offset, offset + limit);
}
async post(req, reply) {
const { name } = req.body ?? {};
if (!name) throw new HTTPError(400, 'name is required');
reply.code(201);
return { id: Date.now(), name };
}
// Si quieres HEAD custom:
// async head(req, reply) { reply.header('X-Users-Count', '4'); reply.send(); }
}
```
```js
// users-plugin.mjs
import fp from 'fastify-plugin';
import { UsersView } from './users-view.mjs';
export default fp(async function usersPlugin(fastify) {
const deps = {
usersRepo: {
async list({ limit = 50, offset = 0 }) {
const all = [
{ id: 1, name: 'Ada' },
{ id: 2, name: 'Grace' },
{ id: 3, name: 'Hedy' },
{ id: 4, name: 'Radia' }
];
return all.slice(offset, offset + limit);
},
async create({ name }) { return { id: Date.now(), name }; }
}
};
await fastify.register(UsersView.as_plugin({ deps }), { prefix: '/users' });
});
```
---
## DRY de tipos y esquemas: qué se puede y qué no
### 1) **¿Podemos “parsear” la firma TS en runtime y generar JSON Schema?**
No, por **type erasure** de TypeScript. En ejecución solo hay JavaScript. Para “leer” tipos TS necesitas:
- **Decoradores + metadatos** y un compilador configurado (no es estándar y añade fricción).
- O un **build step** que lea el AST de TypeScript y genere JSON Schema / OpenAPI.
### 2) **Schema→Types (recomendado con Fastify)**
Tu opción favorita: **`fastify-type-provider-json-schema-to-ts`**. Ventajas:
- El **JSON Schema** vive junto a tu método (como en `static schemas`).
- El **proveedor** infiere los **tipos TS** de `req.body`, `req.query`, `reply` según el `schema` que has puesto. DRY a nivel de código *runtime*: un único origen (schema).
- Con Ajv, validas y serializas rápido.
**Trade-off**: tu **fuente de verdad** son los **schemas** (no los tipos TS). En muchos equipos esto es deseable (contract-first).
### 3) **Types→Schema (types-first con build step)**
Si prefieres que **los tipos TS sean la verdad única**:
- **`ts-json-schema-generator`**: genera JSON Schema desde interfaces TS. Lo usas en build para producir `schemas.gen.json`, que importas en tu clase (`static schemas = { get: schemas.UsersGet, ... }`).
- **`tsoa`** (Express): decoradores en controladores TS y genera OpenAPI + validación. (No es Fastify-native, pero ilustra el patrón).
- **Pipelines OpenAPI**: define OpenAPI (o genera desde TS) y usa `openapi-typescript` para generar tipos TS. Luego con `@fastify/swagger`/`@fastify/swagger-ui` montas docs y validación (requieres adaptadores o transformaciones para AJV).
**Trade-off**: hay **build step** y tooling adicional; ganas “types-first” y sigues DRY.
### 4) **¿Status code y responses dentro de `reply` a partir de tipos?**
No de forma automática y genérica en runtime. Lo más robusto hoy es:
- En **schema** defines `response: { 200: ..., 201: ..., 400: ... }`.
- Con el **type-provider** (json-schema-to-ts) el tipo de `reply` se **refina** según el status que uses en `reply.code()`. Esto sí lo soporta (en TS con buen tooling):
- si `reply.code(201).send(x)`, el tipo de `x` se infiere del `response[201]`.
- Si vas **types-first**, puedes modelar **discriminated unions** y un generador que transforme “tipo + status” a JSON Schema por build step. Pero sigue siendo **build-time**.
---
## Recomendación práctica (para mantenerte ágil)
- Mantén **`static schemas`** por método en la clase (como en el ejemplo).
- Usa **`fastify-type-provider-json-schema-to-ts`** en tu servidor para disfrutar de **tipos inferidos** y validados **sin duplicar**.
- Si en el futuro quieres invertir la flecha (types→schema), integra un **build step** con `ts-json-schema-generator` y reemplaza los `static schemas` por **imports generados**.
Si quieres, te preparo la variante TS con el **type provider** ya conectado, para que en tus handlers de vista tengas `req.body`, `req.query` y `reply` tipados automáticamente por método y status code.
user
- no dupliques codigo, solo mueve head al principio de la lista de NODE_METHODS
- tienes razon, contract first es mejor opcion, no habia caido en ese detalle. Vamos a seguir con fastify-type-provider-json-schema-to-ts
ChatGPT
¡Perfecto! Te dejo la versión ESM “estilo framework” con:
- **`as_plugin()`** que devuelve un **plugin de Fastify**.
- **`fastify-405`** para 405 + `Allow`.
- **Sin duplicación de código**: solo **reordenamos `HEAD`** (si existe) al **principio** de la lista derivada de `http.METHODS`.
- **Contract-first** usando **`fastify-type-provider-json-schema-to-ts`** (JSTT) para tipado de `req`/`reply` a partir de **JSON Schema**.
- **HEAD**: si la clase implementa `head()`, lo registramos **antes** que `get()`. Si no existe, **no lo registramos** (Fastify autogenera HEAD para GET con `exposeHeadRoutes: true`).
---
# 1) Núcleo: `cbv.mts` (TypeScript, módulos ESM)
```ts
// cbv.mts
import fp from 'fastify-plugin';
import allow405 from 'fastify-405';
import { METHODS } from 'node:http';
import type { FastifyInstance, FastifyReply, FastifyRequest, RouteShorthandOptions } from 'fastify';
export class HTTPError extends Error {
constructor(public statusCode: number, message?: string, public payload?: unknown) {
super(message);
}
}
/**
* Orden de métodos:
* - Tomamos TODOS los que expone Node (http.METHODS) → minúsculas → únicos
* - Movemos 'head' al frente (si existe), para registrarlo ANTES de 'get'
* y evitar que Fastify autogenere un HEAD hermano si definimos head() personalizado.
*/
function orderedNodeMethods(): string[] {
const all = Array.from(new Set(METHODS.map(m => m.toLowerCase())));
const headIdx = all.indexOf('head');
if (headIdx > -1) {
all.splice(headIdx, 1);
all.unshift('head');
}
return all;
}
/**
* Base de vistas CBV
* - Define métodos de instancia: get/post/put/patch/delete/head/options/...
* - Define opcionalmente `static schemas = { get: {...}, post: {...}, ... }`
* (JSON Schema por método), consumidos por Fastify para validar/serializar.
*/
export class BaseView<D extends object = Record<string, unknown>> {
constructor(public ctx: { req: FastifyRequest; reply: FastifyReply; fastify: FastifyInstance } & D) {}
static as_plugin<TDeps extends object = Record<string, unknown>>(opts?: {
deps?: TDeps;
common?: RouteShorthandOptions; // preHandler, config, etc.
}) {
const ViewClass = this as unknown as new (ctx: any) => BaseView<TDeps> & Record<string, any>;
const deps = opts?.deps ?? ({} as TDeps);
const common = opts?.common ?? {};
return fp(async function viewPlugin(fastify) {
// 405 + Allow en este scope
await fastify.register(allow405);
// Schemas estáticos por método (opcional)
const staticSchemas: Record<string, any> =
(ViewClass as any).schemas && typeof (ViewClass as any).schemas === 'object'
? (ViewClass as any).schemas
: {};
// Recorremos TODOS los métodos de Node, con HEAD al principio
for (const m of orderedNodeMethods()) {
const has = typeof (ViewClass as any).prototype[m] === 'function';
if (!has) continue;
fastify.route({
method: m.toUpperCase(),
url: '/', // se compone con el prefix del register()
...common,
schema: staticSchemas[m] ?? common.schema,
handler: async (req, reply) => {
const view = new ViewClass({ req, reply, fastify: req.server, ...deps });
try {
const result = await (view as any)[m](req, reply);
if (!reply.sent && typeof result !== 'undefined') reply.send(result);
} catch (err: any) {
if (err && typeof err.statusCode === 'number') {
reply.code(err.statusCode).send(err.payload ?? { error: err.message ?? 'Error' });
} else {
req.log.error(err);
reply.code(500).send({ error: 'Internal Server Error' });
}
}
}
});
}
});
}
}
```
> Nota: no duplicamos lógica de registro; solo **reordenamos** la lista para que `HEAD` vaya primero si lo implementas. Para el resto, Fastify + `fastify-405` se encargan.
---
# 2) Vista de ejemplo con **schemas en la clase** (contract-first)
```ts
// users-view.mts
import { BaseView, HTTPError } from './cbv.mjs'; // Import ESM emit (o paths TS según compiles)
/**
* Contract-first: schemas por método en la propia clase.
* JSTT inferirá tipos a partir de estos schemas en tu app Fastify.
*/
export class UsersView extends BaseView<{ usersRepo: {
list(p: { limit: number; offset: number }): Promise<Array<{ id: number; name: string }>>;
create(d: { name: string }): Promise<{ id: number; name: string }>;
} }> {
static schemas = {
get: {
querystring: {
type: 'object',
properties: {
limit: { type: 'integer', minimum: 1, maximum: 100 },
offset: { type: 'integer', minimum: 0 }
}
},
response: {
200: {
type: 'array',
items: {
type: 'object',
properties: { id: { type: 'number' }, name: { type: 'string' } },
required: ['id','name'],
additionalProperties: false
}
}
}
},
post: {
body: {
type: 'object',
required: ['name'],
properties: { name: { type: 'string', minLength: 1 } },
additionalProperties: false
},
response: {
201: {
type: 'object',
properties: { id: { type: 'number' }, name: { type: 'string' } },
required: ['id','name'],
additionalProperties: false
}
}
}
// Si defines head(): añade `head: { ... }` y el método se registrará antes que get()
} as const;
async get(req: any) {
const limit = Number(req.query?.limit ?? 50);
const offset = Number(req.query?.offset ?? 0);
// fixtures con pioneras :)
const all = [
{ id: 1, name: 'Ada' },
{ id: 2, name: 'Grace' },
{ id: 3, name: 'Hedy' },
{ id: 4, name: 'Radia' }
];
return all.slice(offset, offset + limit);
}
async post(req: any, reply: any) {
const name = req.body?.name;
if (!name) throw new HTTPError(400, 'name is required');
const created = await this.ctx.usersRepo.create({ name });
reply.code(201);
return created;
}
// Ejemplo de HEAD personalizado (si lo necesitas):
// async head(_req: any, reply: any) {
// reply.header('X-Users-Count', '4').send();
// }
}
```
> En TS puro, si quieres **tipos estrictos por método** (Body/Query/Reply por status), el proveedor JSTT te los dará cuando el **`schema` esté visible** en el contexto de compilación del `route` (ver siguiente bloque). Como aquí registramos rutas “desde dentro” del plugin, el tipado de `req`/`reply` dentro de los métodos puede definirse con `FastifyTypeProvider` avanzados, pero para no alargar, te muestro el **setup recomendado**.
---
# 3) **Servidor** con **`fastify-type-provider-json-schema-to-ts`**
```ts
// server.mts
import Fastify from 'fastify';
import { JSONSchemaToTSProvider } from 'fastify-type-provider-json-schema-to-ts';
import fp from 'fastify-plugin';
import { UsersView } from './users-view.mjs';
// Repos “realistas”
const usersRepo = {
async list({ limit, offset }: { limit: number; offset: number }) {
const all = [
{ id: 1, name: 'Ada' },
{ id: 2, name: 'Grace' },
{ id: 3, name: 'Hedy' },
{ id: 4, name: 'Radia' }
];
return all.slice(offset, offset + limit);
},
async create({ name }: { name: string }) {
return { id: Date.now(), name };
}
};
// Instancia con **type provider JSTT**
const app = Fastify({ logger: true }).withTypeProvider<JSONSchemaToTSProvider>();
// Plugin que monta la vista con prefix = URL
const usersPlugin = fp(async (f) => {
await f.register(UsersView.as_plugin({ deps: { usersRepo } }), { prefix: '/users' });
});
await app.register(usersPlugin);
// Opcional: exposeHeadRoutes por defecto = true. Si lo cambias:
// app.route({ method: 'GET', url: '/', handler: () => 'hi' }) // HEAD autogenerado
await app.listen({ port: 3000 });
```
> **Cómo funciona el tipado JSTT aquí**:
> - Al usar `withTypeProvider<JSONSchemaToTSProvider>()`, Fastify **lee tus `schema` JSON** y refina los tipos de `req.query`, `req.body`, y de `reply` según `response` (incluido el **status code** que uses con `reply.code()`), **en los handlers** donde esos schemas se adjuntan.
> - Como adjuntamos `schema` en cada `route()` que genera el plugin, esa info existe para el compilador y puedes declararla en las firmas de tus métodos si quieres máxima estrictidad (podemos afinarlo más si te interesa).
---
## Sobre DRY (types vs schemas)
- **En runtime no se puede “leer” la firma TS** (type erasure).
- Con Fastify lo más sólido es **contract-first**: *JSON Schema* como **fuente de verdad**, y **JSTT** te infiere los tipos TS → DRY real en ejecución y compilación.
- Si algún día quieres *types-first*, necesitas **build step** (p. ej., `ts-json-schema-generator`) que transforme **interfaces TS** → **JSON Schema** y luego importas esos schemas en `static schemas`.
---
¿Quieres que te deje una **versión ultra-estricta** de `UsersView` donde los métodos `get/post` lleven firmas tipadas con los tipos inferidos por JSTT (Body/Query/Reply por status), para que el editor te marque cualquier desvío del contrato?
user
- olvidate de TS -> JSON schema
- explicame "En TS puro, si quieres tipos estrictos por método (Body/Query/Reply por status), el proveedor JSTT te los dará cuando el schema esté visible en el contexto de compilación del route (ver siguiente bloque). Como aquí registramos rutas “desde dentro” del plugin, el tipado de req/reply dentro de los métodos puede definirse con FastifyTypeProvider avanzados, pero para no alargar, te muestro el setup recomendado."
- muestrame la version ultra-estricta
ChatGPT
¡Vamos por partes!
## 1) Qué significa “el schema esté visible en el contexto de compilación del route”
Fastify tipa `req`/`reply` **a partir del `schema`** que le pasas a `route(...)`. Eso ocurre **en el sitio donde registras la ruta**. Cuando usas el type-provider `fastify-type-provider-json-schema-to-ts` (JSTT):
- Si haces algo como:
```ts
app.withTypeProvider<JSONSchemaToTSProvider>()
.route({ schema, handler: (req, reply) => { /* aquí req/reply van tipados */ } })
```
entonces **dentro de ese handler** `req.body`, `req.query`, y los tipos de `reply.send(...)` quedan **estrictamente inferidos** desde `schema`.
- En nuestro patrón CBV, el `route({ schema, handler })` lo montamos **dentro del plugin que devuelve `as_plugin()`**, y el handler invoca tu método de clase (`view.get`, `view.post`, …).
Resultado: el **handler** del plugin sí ve el `schema` (y por tanto podría ir 100% tipado), **pero tu método de clase** es **una función “normal”** que no recibe esas inferencias automáticamente, porque no está declarado “in situ” como handler genérico de Fastify.
### Cómo lo resolvemos
Contract-first + CBV estricto = hacemos que **los métodos de la vista** lleven **firmas explícitas** que usen **los mismos `schema` estáticos** para derivar tipos. Es decir, derivamos:
- `Querystring`, `Body` y `Params` con `FromSchema<...>`,
- y **devolvemos** exactamente el tipo del `response`/status esperado.
Así mantienes DRY (el contrato de verdad es el JSON Schema) y el compilador te obliga a cumplirlo **dentro del método de clase**.
---
## 2) Versión “ultra-estricta” (ESM + TS + JSTT), con firmas fuertes por método
### `cbv.mts` (igual que antes, sólo reordenamos `HEAD` y no duplicamos código)
```ts
// cbv.mts
import fp from 'fastify-plugin';
import allow405 from 'fastify-405';
import { METHODS } from 'node:http';
import type { FastifyInstance, FastifyReply, FastifyRequest, RouteShorthandOptions } from 'fastify';
export class HTTPError extends Error {
constructor(public statusCode: number, message?: string, public payload?: unknown) {
super(message);
}
}
function orderedNodeMethods(): string[] {
const all = Array.from(new Set(METHODS.map(m => m.toLowerCase())));
const headIdx = all.indexOf('head');
if (headIdx > -1) { all.splice(headIdx, 1); all.unshift('head'); }
return all;
}
export class BaseView<D extends object = Record<string, unknown>> {
constructor(public ctx: { req: FastifyRequest; reply: FastifyReply; fastify: FastifyInstance } & D) {}
static as_plugin<TDeps extends object = Record<string, unknown>>(opts?: {
deps?: TDeps;
common?: RouteShorthandOptions;
}) {
const ViewClass = this as unknown as new (ctx: any) => BaseView<TDeps> & Record<string, any>;
const deps = opts?.deps ?? ({} as TDeps);
const common = opts?.common ?? {};
return fp(async function viewPlugin(fastify) {
await fastify.register(allow405);
const staticSchemas: Record<string, any> =
(ViewClass as any).schemas && typeof (ViewClass as any).schemas === 'object'
? (ViewClass as any).schemas
: {};
for (const m of orderedNodeMethods()) {
if (typeof (ViewClass as any).prototype[m] !== 'function') continue;
fastify.route({
method: m.toUpperCase(),
url: '/',
...common,
schema: staticSchemas[m] ?? common.schema,
handler: async (req, reply) => {
const view = new ViewClass({ req, reply, fastify: req.server, ...deps });
try {
const result = await (view as any)[m](req, reply);
if (!reply.sent && typeof result !== 'undefined') reply.send(result);
} catch (err: any) {
if (err && typeof err.statusCode === 'number') {
reply.code(err.statusCode).send(err.payload ?? { error: err.message ?? 'Error' });
} else {
req.log.error(err);
reply.code(500).send({ error: 'Internal Server Error' });
}
}
}
});
}
});
}
}
```
### `users-view.mts` (métodos *fuertemente tipados* a partir de los schemas de la clase)
- Usamos **`FromSchema`** del paquete `json-schema-to-ts` (el mismo que emplea JSTT) para derivar tipos TS **desde** JSON Schema.
- Forzamos que `get()` devuelva exactamente el **200** y que `post()` devuelva exactamente el **201** (y que su `Body` cumpla).
```ts
// users-view.mts
import { BaseView, HTTPError } from './cbv.mjs';
import type { FastifyReply, FastifyRequest } from 'fastify';
import type { FromSchema } from 'json-schema-to-ts';
export const UsersSchemas = {
get: {
querystring: {
type: 'object',
properties: {
limit: { type: 'integer', minimum: 1, maximum: 100 },
offset: { type: 'integer', minimum: 0 }
},
additionalProperties: false
},
response: {
200: {
type: 'array',
items: {
type: 'object',
properties: { id: { type: 'number' }, name: { type: 'string' } },
required: ['id','name'],
additionalProperties: false
}
}
}
} as const,
post: {
body: {
type: 'object',
required: ['name'],
properties: { name: { type: 'string', minLength: 1 } },
additionalProperties: false
},
response: {
201: {
type: 'object',
properties: { id: { type: 'number' }, name: { type: 'string' } },
required: ['id','name'],
additionalProperties: false
}
}
} as const
} as const;
// Helpers de tipos ultra-estrictos
type GetQ = FromSchema<typeof UsersSchemas.get.querystring>;
type GetR200 = FromSchema<typeof UsersSchemas.get.response[200]>;
type PostB = FromSchema<typeof UsersSchemas.post.body>;
type PostR201 = FromSchema<typeof UsersSchemas.post.response[201]>;
export class UsersView extends BaseView<{
usersRepo: {
list(p: { limit: number; offset: number }): Promise<GetR200>;
create(d: { name: string }): Promise<PostR201>;
}
}> {
// Contract-first: el plugin leerá estos schemas
static schemas = UsersSchemas;
// ✨ Firmas estrictas:
async get(req: FastifyRequest<{ Querystring: GetQ }>): Promise<GetR200> {
const limit = Number(req.query.limit ?? 50);
const offset = Number(req.query.offset ?? 0);
const all: GetR200 = [
{ id: 1, name: 'Ada' },
{ id: 2, name: 'Grace' },
{ id: 3, name: 'Hedy' },
{ id: 4, name: 'Radia' }
];
return all.slice(offset, offset + limit);
}
async post(req: FastifyRequest<{ Body: PostB }>, reply: FastifyReply): Promise<PostR201> {
const { name } = req.body;
if (!name) throw new HTTPError(400, 'name is required');
const created = await this.ctx.usersRepo.create({ name });
reply.code(201);
return created; // el tipo fuerza a devolver exactamente el schema 201
}
// Si defines un HEAD custom, su schema podría ser vacío o con headers;
// al estar 'head' antes que 'get' en cbv, lo registrará primero.
// async head(_req: FastifyRequest, reply: FastifyReply): Promise<void> {
// reply.header('X-Users-Count', '4').send();
// }
}
```
### `server.mts` (Fastify + JSTT provider)
```ts
// server.mts
import Fastify from 'fastify';
import { JSONSchemaToTSProvider } from 'fastify-type-provider-json-schema-to-ts';
import fp from 'fastify-plugin';
import { UsersView } from './users-view.mjs';
const usersRepo = {
async list({ limit, offset }: { limit: number; offset: number }) {
const all = [
{ id: 1, name: 'Ada' },
{ id: 2, name: 'Grace' },
{ id: 3, name: 'Hedy' },
{ id: 4, name: 'Radia' }
];
return all.slice(offset, offset + limit);
},
async create({ name }: { name: string }) {
return { id: Date.now(), name };
}
};
const app = Fastify({ logger: true }).withTypeProvider<JSONSchemaToTSProvider>();
const usersPlugin = fp(async (f) => {
await f.register(UsersView.as_plugin({ deps: { usersRepo } }), { prefix: '/users' });
});
await app.register(usersPlugin);
await app.listen({ port: 3000 });
```
---
### Por qué esto es “ultra-estricto”
- Los **schemas** viven junto al método (contract-first, una sola fuente de verdad).
- **Los tipos** de `req.query`, `req.body` y de **lo que devuelves** están **derivados exactamente** de esos schemas.
- Si cambias el `schema` y no actualizas el código, **TypeScript te rompe** la compilación donde no cumpla.
- El handler “externo” (el que crea el plugin) **sigue validando/serializando** con AJV y, gracias a `fastify-405`, responde **405 + `Allow`** a métodos no implementados.
- **HEAD** se registra antes que `GET` si lo implementas; si no, Fastify lo auto-genera por defecto.
Si quieres, puedo añadir una pequeña utilidad de tipos para **respuestas con múltiples status** (p.ej. `200 | 206 | 404`) y que la firma de tu método adapte el `return` según el `reply.code(...)` que uses.
user
- pon UsersSchemas y los tipos ultra-estrictos en un archivo aparte de users-view.mts, ya que es algo opcional (aunque muy recomendable)
- muestrame como seria con varios status de respuesta, en particular cuando una peticion puede ser fallida, que no lo tienes indicado en el schema
ChatGPT
¡Hecho! Te separo **schemas + tipos ultra-estrictos** en un archivo aparte y te muestro cómo modelar **múltiples códigos de respuesta** (incluyendo fallos) con un pequeño helper que valida en **compile-time** que el `status` y el `body` coinciden con el `schema`.
> Premisas: ESM, contract-first, `fastify-type-provider-json-schema-to-ts` (JSTT), `json-schema-to-ts` para derivar tipos, y tu `cbv.mts` previo (con HEAD primero y `fastify-405`).
---
# 1) Schemas + tipos (archivo **opcional** pero recomendable)
```ts
// users-schemas.mts
import type { FromSchema } from 'json-schema-to-ts';
const User = {
type: 'object',
properties: { id: { type: 'number' }, name: { type: 'string' } },
required: ['id', 'name'],
additionalProperties: false
} as const;
const ErrorPayload = {
type: 'object',
properties: { error: { type: 'string' } },
required: ['error'],
additionalProperties: false
} as const;
export const UsersSchemas = {
// GET /users/:id -> 200 (ok) | 404 (not found)
get: {
params: {
type: 'object',
properties: { id: { type: 'integer', minimum: 1 } },
required: ['id'],
additionalProperties: false
},
response: {
200: User,
404: ErrorPayload
}
} as const,
// POST /users -> 201 (created) | 400 (bad request)
post: {
body: {
type: 'object',
properties: { name: { type: 'string', minLength: 1 } },
required: ['name'],
additionalProperties: false
},
response: {
201: User,
400: ErrorPayload
}
} as const
} as const;
// -------- Tipos ultra-estrictos (derivados del schema) --------
export type GetParams = FromSchema<typeof UsersSchemas.get.params>;
export type Get200 = FromSchema<typeof UsersSchemas.get.response[200]>;
export type Get404 = FromSchema<typeof UsersSchemas.get.response[404]>;
export type PostBody = FromSchema<typeof UsersSchemas.post.body>;
export type Post201 = FromSchema<typeof UsersSchemas.post.response[201]>;
export type Post400 = FromSchema<typeof UsersSchemas.post.response[400]>;
// Mapa de respuestas por método (útil para el helper de reply)
export type GetResponses = typeof UsersSchemas.get.response;
export type PostResponses = typeof UsersSchemas.post.response;
```
---
# 2) Helper para respuestas tipadas por **status**
```ts
// reply-helpers.mts
import type { FastifyReply } from 'fastify';
import type { FromSchema } from 'json-schema-to-ts';
/**
* replyWith(reply, 404, body) valida en compile-time que:
* - 404 existe en el response schema,
* - `body` coincide con el JSON Schema de 404.
*/
export function replyWith<
R extends Record<number, unknown>,
S extends keyof R & number
>(reply: FastifyReply, status: S, body: FromSchema<R[S]>): never {
reply.code(status).send(body as any);
// Tipado "never": tras send() no debes devolver nada
throw new Error('replyWith:unreachable'); // evita que el control continúe
}
```
> El `throw` es para que TypeScript entienda que no sigues ejecutando. (En runtime no se ejecutará tras `send()` si usas `return` justo antes; si prefieres, quita el `throw` y usa `return replyWith(...)`).
---
# 3) Vista (métodos **ultra-estrictos**) usando los tipos del schema
```ts
// users-view.mts
import { BaseView, HTTPError } from './cbv.mjs';
import type { FastifyRequest, FastifyReply } from 'fastify';
import { replyWith } from './reply-helpers.mjs';
import {
UsersSchemas,
GetParams, Get200, Get404, GetResponses,
PostBody, Post201, Post400, PostResponses
} from './users-schemas.mjs';
export class UsersView extends BaseView<{
usersRepo: {
get(id: number): Promise<Get200 | null>;
create(d: { name: string }): Promise<Post201>;
}
}> {
// Contract-first: el plugin tomará estos schemas
static schemas = UsersSchemas;
// GET /users/:id -> 200 | 404 (ultra-estricto)
async get(
req: FastifyRequest<{ Params: GetParams }>,
reply: FastifyReply
): Promise<Get200 | never> {
const id = req.params.id;
const user = await this.ctx.usersRepo.get(id);
if (!user) {
// 404 estrictamente tipado
return replyWith<GetResponses, 404>(reply, 404, { error: 'User not found' });
}
// 200 estricto
return user;
}
// POST /users -> 201 | 400 (ultra-estricto)
async post(
req: FastifyRequest<{ Body: PostBody }>,
reply: FastifyReply
): Promise<Post201 | never> {
const { name } = req.body;
if (!name?.trim()) {
// 400 estrictamente tipado
return replyWith<PostResponses, 400>(reply, 400, { error: 'name is required' });
}
const created = await this.ctx.usersRepo.create({ name });
reply.code(201);
return created; // forza schema 201
}
// Si quieres HEAD custom, defínelo: se registrará ANTES que get() por cbv.mts
// async head(_req: FastifyRequest, reply: FastifyReply): Promise<void> {
// reply.header('X-Users-Count', '4').send();
// }
}
```
---
# 4) Registro: plugin + servidor con JSTT
```ts
// users-plugin.mts
import fp from 'fastify-plugin';
import { UsersView } from './users-view.mjs';
export default fp(async (fastify) => {
const deps = {
usersRepo: {
async get(id: number) {
const all = [
{ id: 1, name: 'Ada' },
{ id: 2, name: 'Grace' },
{ id: 3, name: 'Hedy' },
{ id: 4, name: 'Radia' }
];
return all.find(u => u.id === id) ?? null;
},
async create({ name }: { name: string }) {
return { id: Date.now(), name };
}
}
};
await fastify.register(UsersView.as_plugin({ deps }), { prefix: '/users' });
});
```
```ts
// server.mts
import Fastify from 'fastify';
import { JSONSchemaToTSProvider } from 'fastify-type-provider-json-schema-to-ts';
import usersPlugin from './users-plugin.mjs';
const app = Fastify({ logger: true }).withTypeProvider<JSONSchemaToTSProvider>();
await app.register(usersPlugin);
await app.listen({ port: 3000 });
```
---
## Notas clave
- **No hay TS→Schema**: el contrato único es **JSON Schema** (contract-first).
- **Tipos estrictos en métodos**: gracias a `FromSchema<...>` usando los **schemas de la clase**:
- `Params`, `Body`, `Querystring` → exactos.
- `Reply` **por status**: devuelve el **payload exacto** del `response[status]`. Con `replyWith` forzamos la pareja (`status`, `body`) a compilar.
- **HEAD**: el `cbv.mts` recorre `http.METHODS` moviendo `head` al **frente**, así si defines `head()` se registra **antes** que `get()`; si **no** lo defines, Fastify autogenera `HEAD` “hermano” del `GET` (mientras `exposeHeadRoutes` sea `true`).
- **405 + Allow**: lo gestiona **`fastify-405`** automáticamente cuando piden un método no implementado en esa URL.
Si quieres, puedo añadir una mini util para **uniones de múltiples códigos** en un mismo `return` (p. ej., `return success | replyWith(404, ...)`) con control de flujo refinado por `return never` del helper —pero tal como está ya queda consistente y estricto.
user
¿se puede validar la respuesta multiple mediante la Promise devuelta? ¿quizas un Promise con un OR de varias respuestas, en vez de con never? ¿como seria con las respuestas con status fallido, quizas lanzando una excepcion? ¿como se validaria el tipo? ¿o las excepciones serian solo para casos no controlados? ¿como se validan todos los casos con tipos, si es que se puede? ¿o solo se puede hacer con tests?
ChatGPT
Gran pregunta — aquí tienes las opciones “serias” para **tipar y validar** respuestas múltiples (200/201/4xx/5xx) en Fastify con nuestro CBV + contract-first (JSON Schema + JSTT). Te pongo lo esencial y patrones listos para copiar.
---
# 1) ¿Validar “la respuesta múltiple” vía `Promise` unión?
Sí. Haz que **el método devuelva una unión discriminada** y que el **wrapper** (nuestro `as_plugin`) sea quien envíe `reply`. Así TS valida en *compile-time* y AJV en *runtime*.
### Tipos utilitarios (por método)
```ts
// response-types.mts
import type { FromSchema } from 'json-schema-to-ts';
export type StatusOf<R extends Record<number, any>> = keyof R & number;
export type TypedResult<
R extends Record<number, any>,
S extends StatusOf<R>
> = { status: S; body: FromSchema<R[S]> };
```
### En tus schemas (ej.: POST)
```ts
// users-schemas.mts (extracto)
export const UsersSchemas = {
post: {
body: {/* ... */},
response: {
201: {/* User */},
400: {/* { error: string } */},
}
} as const
} as const;
export type PostResponses = typeof UsersSchemas.post.response;
```
### Método con **Promise unión**
```ts
// users-view.mts (extracto)
import type { TypedResult } from './response-types.mjs';
import type { PostResponses } from './users-schemas.mjs';
import type { FastifyRequest } from 'fastify';
import { BaseView } from './cbv.mjs';
import type { FromSchema } from 'json-schema-to-ts';
type PostBody = FromSchema<typeof UsersSchemas.post.body>;
type Created = FromSchema<typeof UsersSchemas.post.response[201]>;
type BadReq = FromSchema<typeof UsersSchemas.post.response[400]>;
export class UsersView extends BaseView<{ usersRepo: { create(d:{name:string}):Promise<Created> } }> {
static schemas = UsersSchemas;
async post(
req: FastifyRequest<{ Body: PostBody }>
): Promise<
| TypedResult<PostResponses, 201>
| TypedResult<PostResponses, 400>
> {
const name = req.body.name?.trim();
if (!name) {
return { status: 400, body: { error: 'name is required' } }; // ✔️ tipeado
}
const created = await this.ctx.usersRepo.create({ name });
return { status: 201, body: created }; // ✔️ tipeado
}
}
```
### Wrapper que **envía** según `status`
En `cbv.mts`, cambia el “auto-send” para soportar el resultado discriminado:
```ts
// cbv.mts (handler -> envío)
const result = await (view as any)[m](req, reply);
if (!reply.sent && typeof result !== 'undefined') {
if (result && typeof result === 'object' && 'status' in result && 'body' in result) {
reply.code((result as any).status).send((result as any).body);
} else {
// soporte retrocompat: devolver un body “simple” (200 por defecto,
// o usa reply.code(...) dentro del método si prefieres)
reply.send(result);
}
}
```
**Qué ganas**
- **Compile-time:** TS no te deja devolver un `status` o `body` que no cuadre con el schema.
- **Runtime:** Fastify valida el `response` contra el JSON Schema correcto (por `status`).
---
# 2) ¿Y si prefiero “lanzar” para fallos?
Puedes, pero si lanzas un `HTTPError(status, message, payload)` **la comprobación de tipos del `payload` no es automática**. Para tipar el throw, crea una **excepción genérica** parametrizada por el *mapa de respuestas* de ese método:
```ts
// http-failure.mts
import type { FromSchema } from 'json-schema-to-ts';
export class HttpFailure<
R extends Record<number, any>,
S extends keyof R & number
> extends Error {
constructor(public status: S, public body: FromSchema<R[S]>) {
super(`HTTP ${status}`);
}
}
export const fail = <
R extends Record<number, any>,
S extends keyof R & number
>(status: S, body: FromSchema<R[S]>) => new HttpFailure<R, S>(status, body);
```
Uso en el método (p.ej. GET 200/404):
```ts
import { HttpFailure, fail } from './http-failure.mjs';
import type { GetResponses } from './users-schemas.mjs';
async get(/* ... */): Promise<Get200> {
const user = await this.ctx.usersRepo.get(id);
if (!user) throw fail<GetResponses, 404>(404, { error: 'User not found' }); // ✔️ tipeado
return user; // 200
}
```
Y en el wrapper, captura y envía:
```ts
try {
const result = await (view as any)[m](req, reply);
// ... (igual que antes)
} catch (err: any) {
if (err?.status && err?.body) {
reply.code(err.status).send(err.body); // AJV valida contra response[status]
} else {
// excepciones no controladas:
req.log.error(err);
reply.code(500).send({ error: 'Internal Server Error' });
}
}
```
**Cuándo usar cada uno**
- **Unión devuelta (`TypedResult`)** → para *flujos esperados* (201, 400, 404…).
- **Excepción tipada (`HttpFailure`)** → cuando quieres salir del flujo normal sin anidar `if`/`return`, pero **igual** con tipos correctos.
- **Excepciones no controladas** → bugs, timeouts, etc. (500).
---
# 3) ¿Validar “solo con la `Promise`” sin wrapper?
En Fastify, la validación de respuesta la hace **AJV en el `route()`**. Aunque tu método devuelva `Promise<Union>`, **alguien tiene que llamar** a `reply.code(...).send(...)` con el `status/body` para que Fastify sepa contra **qué** schema validar.
Por eso el patrón **CBV + resultado discriminado** o **CBV + excepción tipada** encaja: la **lógica de envío** vive en el wrapper (una vez para todos).
---
# 4) ¿Se puede “validar todos los casos con tipos”?
- **Sí, en compile-time**: con los patrones de arriba, cada `status` y `body` están **atados** a `response[status]` del JSON Schema. TS te “obliga” a cubrir los caminos (o te marca `never`).
- **Runtime**: AJV valida que lo que mandas coincide con el schema.
- **Aún así, tests útiles**: siempre conviene tener tests e2e para asegurarte de que devuelves los `status` esperados según situaciones reales (DB not found, validaciones, etc.).
---
## Mini resumen de patrones
- **Union retornada**
`Promise<TypedResult<R,201> | TypedResult<R,400> | ...>` → wrapper hace `reply.code(status).send(body)`.
- **Excepción tipada**
`throw fail<Responses, 404>(404, {...})` → wrapper captura y envía.
- **Ambos**
Puedes mezclar: devolver `201` normal y lanzar `400/404` en errores esperados.
Si quieres, te dejo una **plantilla base** (`StrictView`) que ya integra ambos estilos (acepta o `TypedResult` o `HttpFailure`) y te reduce aún más el boilerplate en cada vista.
user
pero el objeto devuelto con status y body es custom, y ya no es como funciona Fastify, quiero hacer el reply.code().send() dentro del metodo. ¿Solo se pueede hacer con el helper replyWith()? Parece que solo valida que se esta llamando con los parametros adecuados, ¿no se puede validar el Promise devuelto por send(), o no tiene sentido?
ChatGPT
Buena pregunta — aquí la clave es separar **qué valida Fastify y cuándo** (runtime) de **qué puede validar TypeScript** (compile-time).
## ¿Tiene sentido “validar la Promise devuelta por `send()`?
No realmente:
- En Fastify v4, `reply.send()` **no** devuelve una `Promise<Payload>` útil para validar tipos; el contrato real de validación ocurre **antes de enviar** el body, con AJV, usando el **`schema.response[status]`** de la **ruta**.
- Por tanto, “validar la Promise que devuelve `send()`” no te da garantías extra de tipos ni de contrato.
Si quieres **hacer el `reply.code().send()` dentro del método** (estilo Fastify puro) *y* tener tipos estrictos por `status`, la forma más limpia es envolver el `reply` con un **helper tipado** que encodee el contrato del `response` para ese método.
---
## Opción recomendada: `withReply<R>()` (tipado por `response`)
Define un wrapper que “vista” el `reply` con un tipo que **ata** el `status` a su **payload** según tu `schema.response`.
```ts
// reply-typed.mts
import type { FastifyReply } from 'fastify';
import type { FromSchema } from 'json-schema-to-ts';
type StatusOf<R extends Record<number, unknown>> = keyof R & number;
export type TypedReply<R extends Record<number, unknown>> = {
code<S extends StatusOf<R>>(status: S): {
send: (body: FromSchema<R[S]>) => never; // tras send, no debes seguir
};
};
// Soporte opcional para 204 sin body (si lo modelas como schema vacío):
export type TypedReply204<R extends Record<number, unknown>> = {
code<S extends StatusOf<R>>(status: S): S extends 204
? { send: () => never } // sin body para 204
: { send: (body: FromSchema<R[S]>) => never };
};
export function withReply<R extends Record<number, unknown>>(
reply: FastifyReply
): TypedReply<R> {
return {
code(status) {
return {
send(body: unknown) {
reply.code(status as number).send(body as any);
// marcar control como no retornable:
throw new Error('withReply:unreachable');
}
} as any;
}
} as any;
}
```
> El `throw` es un truco para que TS considere ese camino “terminal” (igual puedes hacer `return r.code(201).send(...)` y quitar el throw si prefieres).
### Uso dentro del método (Fastify puro, con tipos estrictos)
Supón que tu **schema** contract-first para `POST /users` es:
```ts
// users-schemas.mts (extracto)
export const UsersSchemas = {
post: {
body: { /* ... */ },
response: {
201: { /* User */ },
400: { type: 'object', properties: { error: { type: 'string' } }, required: ['error'] }
}
} as const
} as const;
export type PostResponses = typeof UsersSchemas.post.response;
```
En tu vista:
```ts
// users-view.mts (extracto)
import type { FastifyRequest, FastifyReply } from 'fastify';
import { withReply } from './reply-typed.mjs';
import { UsersSchemas, type PostResponses } from './users-schemas.mjs';
import { BaseView } from './cbv.mjs';
import type { FromSchema } from 'json-schema-to-ts';
type PostBody = FromSchema<typeof UsersSchemas.post.body>;
export class UsersView extends BaseView<{ usersRepo: { create(d:{name:string}): Promise<FromSchema<typeof UsersSchemas.post.response[201]>> } }> {
static schemas = UsersSchemas;
async post(req: FastifyRequest<{ Body: PostBody }>, reply: FastifyReply) {
const r = withReply<PostResponses>(reply);
const name = req.body.name?.trim();
if (!name) {
return r.code(400).send({ error: 'name is required' }); // ✅ TS exige payload del 400
}
const created = await this.ctx.usersRepo.create({ name });
return r.code(201).send(created); // ✅ TS exige payload del 201
}
}
```
Ventajas:
- **Compile-time:** si intentas `r.code(201).send({ foo: 'x' })`, TS rompe; no coincide con `response[201]`.
- **Runtime:** Fastify valida/serializa el body según `response[status]` (AJV).
Para **GET con 200 | 404** es igual:
```ts
const r = withReply<GetResponses>(reply);
const user = await this.ctx.usersRepo.get(id);
if (!user) return r.code(404).send({ error: 'User not found' });
return r.code(200).send(user);
```
Y para **204 sin body**, usa `TypedReply204` si has modelado `response[204]` como vacío:
```ts
import { withReply, type TypedReply204 } from './reply-typed.mjs';
const r = withReply<PostResponses>(reply) as unknown as TypedReply204<PostResponses>;
return r.code(204).send(); // sin body si así lo has definido
```
---
## ¿Por qué no inferir tipos “directamente” de `reply.code().send()`?
Porque el tipo de `reply` estándar **no sabe** qué schema tiene la ruta; esa “magia” la hace Fastify cuando defines el handler **in situ** con el type-provider. En el patrón CBV, el handler que conoce el schema vive en el **wrapper** del plugin, y tus métodos de clase son funciones ordinarias. Por eso usamos `withReply<R>()` para **inyectar** el contrato de **ese método** (su `response`) y tener chequeo estricto.
---
## ¿Y las excepciones?
- Si te gusta lanzar en fallos esperados, usa una excepción **tipada**:
```ts
import type { FromSchema } from 'json-schema-to-ts';
export class HttpFailure<R extends Record<number, any>, S extends keyof R & number> extends Error {
constructor(public status: S, public body: FromSchema<R[S]>) { super(`HTTP ${status}`); }
}
export const fail = <R extends Record<number, any>, S extends keyof R & number>(
status: S, body: FromSchema<R[S]>
) => new HttpFailure<R, S>(status, body);
```
En el método:
```ts
if (!user) throw fail<GetResponses, 404>(404, { error: 'User not found' });
```
En el wrapper (una vez para todos):
```ts
} catch (err) {
if (err?.status && err?.body) reply.code(err.status).send(err.body);
else reply.code(500).send({ error: 'Internal Server Error' });
}
```
Tipos: también quedan **validados en compile-time** (el `body` debe coincidir con `response[status]`).
---
## ¿Se puede “validar todos los casos con tipos” sin tests?
- **Sí, en compile-time** para *firmas* y *payloads* (con `withReply<R>()` o `fail<R>()`).
- **No**, los tipos no cubren lógica dinámica (p. ej., que realmente devuelvas 404 cuando no existe). Para eso, **tests** (unit/e2e) siguen siendo necesarios.
- **Runtime**: AJV valida que el body cumple el schema; pero no asegura que hayas elegido el **status** correcto para cada situación. Eso es lógica → tests.
---
### Conclusión
- Hacer `reply.code().send()` **dentro del método** y tener **tipos estrictos** es viable y limpio con un **wrapper tipado** (`withReply<R>()`) o con **excepciones tipadas** (`fail<R>()`).
- Validar “la Promise de `send()`” no aporta valor en Fastify; la validación real sucede en el **router** con AJV.
- Para cubrir **todas** las rutas de éxito/fallo: usa **uniones tipadas por `status`** (vía wrapper/excepción) + **AJV** (runtime) + **tests** para la lógica.
user
esperaba que el Promise devuelto por send() pudiera tener informacion del estado de la respuesta, al menos del status code seteado. Ok, vamos a continuar con la version original de withReply() como helper opcional, pero deveuelve la Promise, no hagas el truco de lanzar la excepcion
ChatGPT
¡Perfecto! Mantengo el patrón **Fastify puro** (hacer `reply.code().send()` **dentro del método**) y dejo **`withReply()` como helper opcional** para tipar **status → payload**. Tal como pides, **`send()` devuelve una `Promise<void>`** (no hay “truco” de lanzar excepción).
---
# Helper tipado (ESM + TS)
```ts
// reply-typed.mts
import type { FastifyReply } from 'fastify';
import type { FromSchema } from 'json-schema-to-ts';
/** Clave numérica del mapa response (e.g. 200 | 201 | 400 | 404...) */
export type StatusOf<R extends Record<number, unknown>> = keyof R & number;
/**
* Reply tipado por contrato: r.code(201).send(payload-201) -> Promise<void>
* - Devuelve Promise<void> para que puedas `return r.code(...).send(...)`
* - La validación de tipos ocurre en compile-time (TS)
* - La validación real de payload ocurre en runtime vía AJV (schema.response[status])
*/
export type TypedReply<R extends Record<number, unknown>> = {
code<S extends StatusOf<R>>(status: S): {
send: (body: FromSchema<R[S]>) => Promise<void>;
};
};
/** Variante opcional: status 204 sin body si lo modelas así en tu schema */
export type TypedReply204<R extends Record<number, unknown>> = {
code<S extends StatusOf<R>>(status: S): S extends 204
? { send: () => Promise<void> }
: { send: (body: FromSchema<R[S]>) => Promise<void> };
};
export function withReply<R extends Record<number, unknown>>(
reply: FastifyReply
): TypedReply<R> {
return {
code(status) {
return {
send(body: unknown) {
// No es una promesa real de “entrega”; es por ergonomía de tipos.
// Fastify valida y envía inmediatamente; devolvemos Promise<void> resuelta.
reply.code(status as number).send(body as any);
return Promise.resolve();
}
} as any;
}
} as any;
}
```
> Nota: en Fastify v4 `reply.send()` no devuelve una promesa “útil”; aquí devolvemos **`Promise<void>`** para que puedas `return`/`await` sin romper el estilo de handlers asíncronos. La **validación real** del body la hace AJV en el `route()` según `schema.response[status]`.
---
# Uso con tu esquema (contract-first, JSTT)
## Schemas + tipos (archivo aparte, recomendado)
```ts
// users-schemas.mts
import type { FromSchema } from 'json-schema-to-ts';
const User = {
type: 'object',
properties: { id: { type: 'number' }, name: { type: 'string' } },
required: ['id','name'],
additionalProperties: false
} as const;
const ErrorPayload = {
type: 'object',
properties: { error: { type: 'string' } },
required: ['error'],
additionalProperties: false
} as const;
export const UsersSchemas = {
get: {
params: {
type: 'object',
properties: { id: { type: 'integer', minimum: 1 } },
required: ['id'],
additionalProperties: false
},
response: {
200: User,
404: ErrorPayload
}
} as const,
post: {
body: {
type: 'object',
properties: { name: { type: 'string', minLength: 1 } },
required: ['name'],
additionalProperties: false
},
response: {
201: User,
400: ErrorPayload
}
} as const,
del: {
// si quieres 204 sin body:
response: { 204: { type: 'null' } } // o bien modela 204 como schema vacío
} as const
} as const;
export type GetParams = FromSchema<typeof UsersSchemas.get.params>;
export type GetResp200 = FromSchema<typeof UsersSchemas.get.response[200]>;
export type GetResp404 = FromSchema<typeof UsersSchemas.get.response[404]>;
export type GetResponses = typeof UsersSchemas.get.response;
export type PostBody = FromSchema<typeof UsersSchemas.post.body>;
export type PostResp201 = FromSchema<typeof UsersSchemas.post.response[201]>;
export type PostResp400 = FromSchema<typeof UsersSchemas.post.response[400]>;
export type PostResponses = typeof UsersSchemas.post.response;
export type DelResponses = typeof UsersSchemas.del.response;
```
## Vista (métodos estrictos con `withReply()`)
```ts
// users-view.mts
import { BaseView } from './cbv.mjs';
import type { FastifyRequest, FastifyReply } from 'fastify';
import { withReply, type TypedReply204 } from './reply-typed.mjs';
import {
UsersSchemas,
GetParams, GetResp200, GetResponses,
PostBody, PostResp201, PostResponses,
DelResponses
} from './users-schemas.mjs';
export class UsersView extends BaseView<{
usersRepo: {
get(id: number): Promise<GetResp200 | null>;
create(d: { name: string }): Promise<PostResp201>;
del(id: number): Promise<void>;
}
}> {
static schemas = UsersSchemas;
// GET /users/:id -> 200 | 404 (valida tipos por status)
async get(req: FastifyRequest<{ Params: GetParams }>, reply: FastifyReply): Promise<void> {
const r = withReply<GetResponses>(reply);
const user = await this.ctx.usersRepo.get(req.params.id);
if (!user) return r.code(404).send({ error: 'User not found' });
return r.code(200).send(user);
}
// POST /users -> 201 | 400 (valida tipos por status)
async post(req: FastifyRequest<{ Body: PostBody }>, reply: FastifyReply): Promise<void> {
const r = withReply<PostResponses>(reply);
const name = req.body.name?.trim();
if (!name) return r.code(400).send({ error: 'name is required' });
const created = await this.ctx.usersRepo.create({ name });
return r.code(201).send(created);
}
// DELETE /users/:id -> 204 (sin body, si tu schema lo modela así)
async delete(req: FastifyRequest<{ Params: GetParams }>, reply: FastifyReply): Promise<void> {
const r = withReply<DelResponses>(reply) as unknown as TypedReply204<DelResponses>;
await this.ctx.usersRepo.del(req.params.id);
return r.code(204).send(); // sin body estrictamente
}
}
```
> Con **JSTT** (type provider) activo en tu app, y al adjuntar estos `schema` en el `route()` que genera tu `as_plugin()`, Fastify validará/serializará cada `response` en runtime. El helper garantiza en **compile-time** que **status y payload** encajan con tu contrato.
---
## Recordatorio de integración (resumen)
- `cbv.mts`: tu `as_plugin()` reordena `HEAD` al principio, registra rutas por método implementado, aplica `static schemas` y usa `fastify-405` para 405+`Allow`.
- Servidor con **JSTT**:
```ts
const app = Fastify({ logger: true }).withTypeProvider<JSONSchemaToTSProvider>();
app.register(UsersView.as_plugin({ deps: { usersRepo } }), { prefix: '/users' });
```
- **HEAD**: si defines `head()` en la clase, se registra antes que `get()`. Si no, Fastify autogenera HEAD cuando `exposeHeadRoutes`=true.
Si quieres, te doy también un **helper de tipos** para que, cuando tu método declare `Promise<void>`, el compilador te avise si **olvidas** llamar a `reply.send()` en todos los caminos (con control-flow tricks).
user
- Combina TypedReply y TypedReply204, 204 nunca debe tener un body
- Reemplaza withReply() con la version original de replyWith()
- explicame lo de "Si quieres, te doy también un helper de tipos para que, cuando tu método declare Promise<void>, el compilador te avise si olvidas llamar a reply.send() en todos los caminos (con control-flow tricks)."
ChatGPT
¡Hecho! Te dejo:
1) Un **tipo unificado** que cubre “todos los status” y fuerza que **204 no lleve body**.
2) Un **helper `replyWith()`** (en vez de `withReply`) que **envía** y devuelve `Promise<Sent>` (sin trucos de `throw`).
3) Cómo usar ese helper en tus métodos.
4) El “truco” para que **TS te avise si olvidas `reply.send()`**: hacemos que el método **tenga que devolver** ese `Sent` “brandeado”, así si falta un camino, el compilador se queja (porque “no todos los caminos devuelven un valor”).
---
## 1) Tipos unificados + `replyWith()` (ESM + TS)
```ts
// reply-typed.mts
import type { FastifyReply } from 'fastify';
import type { FromSchema } from 'json-schema-to-ts';
/** Marca de “ya envié respuesta” para forzar control de flujo en TS */
export type Sent = { readonly __sent: unique symbol };
/** Mapa de respuestas del schema (p.ej. {200: {...}, 404: {...}} ) */
export type ResponseMap = Record<number, unknown>;
/** Conjunto de status válidos */
export type StatusOf<R extends ResponseMap> = keyof R & number;
/** Parámetros del body:
* - Si S = 204 → sin body
* - Si S ≠ 204 → body requerido con el tipo derivado del schema
*/
export type BodyArgs<
R extends ResponseMap,
S extends StatusOf<R>
> = S extends 204 ? [] : [body: FromSchema<R[S]>];
/**
* Envía (code + send) con tipos estrictos por status.
* - 204 no admite body
* - Devuelve Promise<Sent> para que puedas `return replyWith(...)`
* y así TS exija que TODOS los caminos devuelvan algo (evita olvidar send()).
*/
export async function replyWith<
R extends ResponseMap,
S extends StatusOf<R>
>(
reply: FastifyReply,
status: S,
...args: BodyArgs<R, S>
): Promise<Sent> {
// @ts-expect-error: El genérico ya garantiza que args[0] esté bien tipado cuando toca
reply.code(status).send(args[0]);
return { __sent: Symbol('sent') as unknown as never };
}
```
- Si intentas `replyWith<Resp>(reply, 201, { foo: 'x' })` y el `response[201]` no es ese shape → **error de TS**.
- Si intentas `replyWith<Resp>(reply, 204, algo)` → **error de TS** (204 no acepta body).
- Devuelve `Promise<Sent>` para poder **`return`/`await`** y que TS exija terminar el flujo.
---
## 2) Uso en una vista con varios status (contract-first)
Supón tus schemas (contract-first) en `users-schemas.mts`:
```ts
// users-schemas.mts
import type { FromSchema } from 'json-schema-to-ts';
const User = {
type: 'object',
properties: { id: { type: 'number' }, name: { type: 'string' } },
required: ['id','name'],
additionalProperties: false
} as const;
const ErrorPayload = {
type: 'object',
properties: { error: { type: 'string' } },
required: ['error'],
additionalProperties: false
} as const;
export const UsersSchemas = {
get: {
params: {
type: 'object',
properties: { id: { type: 'integer', minimum: 1 } },
required: ['id'],
additionalProperties: false
},
response: {
200: User,
404: ErrorPayload
}
} as const,
post: {
body: {
type: 'object',
properties: { name: { type: 'string', minLength: 1 } },
required: ['name'],
additionalProperties: false
},
response: {
201: User,
400: ErrorPayload
}
} as const,
del: {
response: { 204: { type: 'null' } } // 204 sin body
} as const
} as const;
export type GetParams = FromSchema<typeof UsersSchemas.get.params>;
export type GetResponses = typeof UsersSchemas.get.response;
export type PostBody = FromSchema<typeof UsersSchemas.post.body>;
export type PostResponses = typeof UsersSchemas.post.response;
export type DelResponses = typeof UsersSchemas.del.response;
```
Vista (métodos “ultra-estrictos” que **devuelven `Promise<Sent>`**):
```ts
// users-view.mts
import type { FastifyReply, FastifyRequest } from 'fastify';
import { BaseView } from './cbv.mjs';
import { replyWith, type Sent } from './reply-typed.mjs';
import {
UsersSchemas, type GetParams, type GetResponses,
type PostBody, type PostResponses,
type DelResponses
} from './users-schemas.mjs';
import type { FromSchema } from 'json-schema-to-ts';
type User = FromSchema<typeof UsersSchemas.post.response[201]>;
export class UsersView extends BaseView<{
usersRepo: {
get(id: number): Promise<User | null>;
create(d: { name: string }): Promise<User>;
del(id: number): Promise<void>;
}
}> {
static schemas = UsersSchemas;
// GET /users/:id -> 200 | 404 (compilador obliga a "cerrar" todos los caminos)
async get(req: FastifyRequest<{ Params: GetParams }>, reply: FastifyReply): Promise<Sent> {
const user = await this.ctx.usersRepo.get(req.params.id);
if (!user) {
return replyWith<GetResponses, 404>(reply, 404, { error: 'User not found' });
}
return replyWith<GetResponses, 200>(reply, 200, user);
}
// POST /users -> 201 | 400
async post(req: FastifyRequest<{ Body: PostBody }>, reply: FastifyReply): Promise<Sent> {
const name = req.body.name?.trim();
if (!name) {
return replyWith<PostResponses, 400>(reply, 400, { error: 'name is required' });
}
const created = await this.ctx.usersRepo.create({ name });
return replyWith<PostResponses, 201>(reply, 201, created);
}
// DELETE /users/:id -> 204 (sin body)
async delete(req: FastifyRequest<{ Params: GetParams }>, reply: FastifyReply): Promise<Sent> {
await this.ctx.usersRepo.del(req.params.id);
// 204 no admite body → el tipo lo prohíbe
return replyWith<DelResponses, 204>(reply, 204);
}
}
```
### ¿Qué conseguimos?
- **TS** obliga a que **todas** las rutas de código **devuelvan `Promise<Sent>`**, o sea, que **llames a `replyWith(...)`**. Si te dejas un camino sin `return replyWith(...)`, TS avisa (“Not all code paths return a value”).
- En cada `replyWith<Responses, STATUS>` el **payload** queda **tipado por el schema** de ese `STATUS`.
- **AJV** valida en runtime contra `response[STATUS]`.
---
## 3) ¿Qué era el “truco de control de flujo para no olvidar `send()`”?
Justo lo que acabas de ver: **forzamos el tipo de retorno del método a `Promise<Sent>`**, y hacemos que **la única manera de producir un `Sent`** sea **llamando a `replyWith(...)`**.
Así:
- Si olvidas llamar a `replyWith(...)` en algún camino, **el compilador falla** (no todos los caminos devuelven `Sent`).
- Alternativamente, podrías usar un tipo `never` en `send()` (el enfoque que descartaste) para que el control de flujo “termine”, pero con `Promise<Sent>` evitamos el `throw` y mantenemos el estilo `await/return`.
> Nota: este patrón no “inspecciona” mágicamente `reply`, es simplemente **tipado disciplinado** para obligarte a **cerrar el flujo** en cada método.
---
### Encaja con tu `cbv.mts` actual
Tu `cbv.mts` no necesita cambios: sigue registrando rutas, aplicando `schemas`, ordenando `HEAD` antes que `GET` y usando `fastify-405`.
Como ahora **todos tus métodos devuelven `Promise<Sent>`**, tu wrapper verá que `reply.sent === true` después de `replyWith(...)` y no intentará auto-`send` nada (tal como ya haces).
¿Quieres que te deje un ejemplo de **`UsersView` “list” con `GET /users`** (paginación + `200`/`206`) y cómo modelar **`206`** en el schema y en `replyWith`?
user
ok, retoma el replyWith con never que habia descartado, me gusta mas esa solucion, quiero que el "happy path" se obtenga simplemente devolviendo el valor esperado, y replyWith se use solo para los shortcuts. Igualmente, no me gusta tener que definir el status code dos veces, uno en el generic y otro en los argumentos, creo que antes no lo hacias, eliminalo y que el tipado se valide solo a partir del valor en los argumentos, o si es necesario, quizas añadiendo el tipo devuelto en el generic, nada mas
ChatGPT
¡Hecho! Volvemos al patrón que te gusta:
- **Happy path** ⇒ el método **devuelve** el valor esperado y el wrapper lo **envía** (si no se envió ya).
- **Shortcuts** (errores, early-exit, 204, etc.) ⇒ usas **`replyWith()`**, que **envía** y hace `return` “terminal” vía `never`.
- **Sin duplicar status** en genéricos: en `replyWith` **solo** pasas el **mapa `response`** como genérico (`R`) y el **status** como argumento; el tipo del **body** se infiere de `R[status]`. Para **204**, el tipo fuerza a **no** pasar body.
---
# Helper (ESM + TS): `replyWith()` con `never` y sin duplicar status
```ts
// reply-with.mts
import type { FastifyReply } from 'fastify';
import type { FromSchema } from 'json-schema-to-ts';
export type ResponseMap = Record<number, unknown>;
export type StatusOf<R extends ResponseMap> = keyof R & number;
/** Para 204 no body; resto, body del schema correspondiente */
type BodyArg<R extends ResponseMap, S extends StatusOf<R>> =
S extends 204 ? [] : [body: FromSchema<R[S]>];
/**
* Envía (code + send) y **no retorna** (never):
* - No dupliques el status en el genérico: solo `replyWith<Responses>(reply, 404, {...})`
* - 204 fuerza a **no** pasar body
*/
export function replyWith<R extends ResponseMap, S extends StatusOf<R>>(
reply: FastifyReply,
status: S,
...args: BodyArg<R, S>
): never {
// @ts-expect-error: el genérico ya garantiza que args[0] encaja con el schema del status
reply.code(status).send(args[0]);
// cortar el flujo tipado
throw new Error('__never');
}
```
---
# CBV (handler) — sin cambios “mágicos”
Tu wrapper en `cbv.mts` puede quedarse así (esencial):
```ts
// en el route handler...
const result = await (view as any)[m](req, reply);
if (!reply.sent && typeof result !== 'undefined') {
// happy path: si el método devolvió algo, lo enviamos (status ya puede venir de reply.code() si lo setearon)
reply.send(result);
}
```
---
# Esquemas + tipos (contract-first)
```ts
// users-schemas.mts
import type { FromSchema } from 'json-schema-to-ts';
const User = {
type: 'object',
properties: { id: { type: 'number' }, name: { type: 'string' } },
required: ['id','name'],
additionalProperties: false
} as const;
const ErrorPayload = {
type: 'object',
properties: { error: { type: 'string' } },
required: ['error'],
additionalProperties: false
} as const;
export const UsersSchemas = {
get: {
params: {
type: 'object',
properties: { id: { type: 'integer', minimum: 1 } },
required: ['id'],
additionalProperties: false
},
response: {
200: User,
404: ErrorPayload
}
} as const,
post: {
body: {
type: 'object',
properties: { name: { type: 'string', minLength: 1 } },
required: ['name'],
additionalProperties: false
},
response: {
201: User,
400: ErrorPayload
}
} as const,
del: {
// 204 sin body
response: { 204: { type: 'null' } }
} as const
} as const;
// Tipos derivados (para firmar métodos)
export type GetParams = FromSchema<typeof UsersSchemas.get.params>;
export type Get200 = FromSchema<typeof UsersSchemas.get.response[200]>;
export type GetResponses = typeof UsersSchemas.get.response;
export type PostBody = FromSchema<typeof UsersSchemas.post.body>;
export type Post201 = FromSchema<typeof UsersSchemas.post.response[201]>;
export type PostResponses = typeof UsersSchemas.post.response;
export type DelResponses = typeof UsersSchemas.del.response;
```
---
# Vista (happy path devolviendo valor; errores con `replyWith()`)
```ts
// users-view.mts
import type { FastifyRequest, FastifyReply } from 'fastify';
import { BaseView } from './cbv.mjs';
import { replyWith } from './reply-with.mjs';
import {
UsersSchemas,
type GetParams, type Get200, type GetResponses,
type PostBody, type Post201, type PostResponses,
type DelResponses
} from './users-schemas.mjs';
export class UsersView extends BaseView<{
usersRepo: {
get(id: number): Promise<Get200 | null>;
create(d: { name: string }): Promise<Post201>;
del(id: number): Promise<void>;
}
}> {
static schemas = UsersSchemas;
// GET /users/:id -> happy path: devolver User; error: shortcut 404
async get(req: FastifyRequest<{ Params: GetParams }>, reply: FastifyReply): Promise<Get200> {
const user = await this.ctx.usersRepo.get(req.params.id);
if (!user) {
// solo pasas R y el status como argumento; el body se tipa por response[404]
return replyWith<GetResponses>(reply, 404, { error: 'User not found' });
}
// happy path: devuelve el modelo -> el wrapper hará reply.send(result)
return user;
}
// POST /users -> 201 o 400.
async post(req: FastifyRequest<{ Body: PostBody }>, reply: FastifyReply): Promise<Post201> {
const name = req.body.name?.trim();
if (!name) {
return replyWith<PostResponses>(reply, 400, { error: 'name is required' });
}
const created = await this.ctx.usersRepo.create({ name });
// fija el status de éxito y devuelve el body (happy path)
reply.code(201);
return created;
}
// DELETE /users/:id -> 204 sin body (shortcut)
async delete(req: FastifyRequest<{ Params: GetParams }>, reply: FastifyReply): Promise<never> {
await this.ctx.usersRepo.del(req.params.id);
// 204: el tipo impide pasar body
return replyWith<DelResponses>(reply, 204);
}
}
```
### Por qué así:
- En **happy path** no tocas el helper: simplemente **devuelves** el valor correcto tipado (e.g., `Get200`, `Post201`). Si necesitas un **status distinto al 200**, defines `reply.code(201)` antes de devolver.
- En **errores / early-exit** haces `return replyWith<Responses>(reply, STATUS, body?)`:
- **No duplicas STATUS** en genérico.
- **204** prohíbe body por tipos.
- El método pasa a ser **terminal** en ese camino (`never`), y el compilador te obliga a cubrir todos los casos.
---
## Bonus: asegurar que no “olvidas” enviar en algún camino
Con este patrón:
- Los caminos con `replyWith<...>(...)` son **terminales** (tipo `never`).
- El **happy path** devuelve un tipo concreto (e.g., `Get200`) y el wrapper **envía**.
- Si dejas un camino sin `return` ni `replyWith(...)` ni valor devuelto, **TS falla** (“not all code paths return a value”).
Eso es lo que buscabas con “control-flow tricks”: **no necesitamos hacks extra**; el `never` de `replyWith()` + firmas con return **no-`void`** ya fuerzan la exhaustividad.
user
- fastify ya envia el valor del happy path, o lanza excepcion si ya se uso reply.send() antes, no lo copruebes y lances de nuevo
- explica en el codigo el truco para cortar el flujo tipado
ChatGPT
¡Listo! Ajusto dos cosas clave según pides:
1) En el **wrapper CBV** ya **no miro `reply.sent`**. Si el método devuelve un valor (happy path), **llamo a `reply.send(result)`** y listo. Si el handler ya envió antes, **Fastify lanzará** y no hacemos comprobaciones extra.
2) `replyWith()` vuelve a la versión con **`never`**, pero **sin lanzar excepción en runtime**. El “truco” es devolver `undefined as never` tras `reply.code().send(...)`, lo que **corta el flujo tipado** (TS entiende que ese camino termina) sin tirar un error real.
---
# `cbv.mts` (fragmento del handler)
```ts
// cbv.mts (dentro del fastify.route({ handler }))
handler: async (req, reply) => {
const view = new ViewClass({ req, reply, fastify: req.server, ...deps });
try {
const result = await (view as any)[m](req, reply);
// 👇 Happy path: Fastify enviará el valor o lanzará si ya se envió antes.
// No comprobamos reply.sent ni relanzamos nada.
if (typeof result !== 'undefined') {
reply.send(result);
}
} catch (err: any) {
if (err && typeof err.statusCode === 'number') {
reply.code(err.statusCode).send(err.payload ?? { error: err.message ?? 'Error' });
} else {
req.log.error(err);
reply.code(500).send({ error: 'Internal Server Error' });
}
}
}
```
---
# `reply-with.mts` (helper con `never` y **sin lanzar**)
```ts
// reply-with.mts
import type { FastifyReply } from 'fastify';
import type { FromSchema } from 'json-schema-to-ts';
export type ResponseMap = Record<number, unknown>;
export type StatusOf<R extends ResponseMap> = keyof R & number;
// 204 nunca lleva body; el resto sí, tipado por schema
type BodyArg<R extends ResponseMap, S extends StatusOf<R>> =
S extends 204 ? [] : [body: FromSchema<R[S]>];
/**
* Envía (code + send) y termina el flujo a ojos de TypeScript (return never),
* SIN lanzar excepción en runtime.
*
* ⚙️ Truco para cortar el flujo tipado:
* - La función está tipada como `never`.
* - Tras `reply.code(...).send(...)` hacemos `return undefined as never`.
* - Eso hace que el compilador trate este camino como TERMINAL,
* obligándote a usar replyWith() en todos los atajos (errores, 204, etc.).
*/
export function replyWith<R extends ResponseMap, S extends StatusOf<R>>(
reply: FastifyReply,
status: S,
...args: BodyArg<R, S>
): never {
// @ts-expect-error: el genérico garantiza tipo correcto de args[0] cuando aplica
reply.code(status).send(args[0]);
// 🔑 "Corte" de flujo tipado: no lanza; simplemente devuelve un valor nunca.
return undefined as never;
}
```
**Por qué funciona el “corte” del flujo:**
- Tu método se tipa con un `return` concreto (p. ej., `Promise<User>`).
- En los atajos (errores, `204`, etc.) haces `return replyWith(...)`.
- Como `replyWith` es `never`, **ese camino termina** para TS.
- Si te dejas un camino sin devolver ni usar `replyWith`, TS te avisará (“no todos los caminos retornan valor”).
---
# Ejemplo de uso (vista)
```ts
// users-view.mts (extracto)
import type { FastifyRequest, FastifyReply } from 'fastify';
import { BaseView } from './cbv.mjs';
import { replyWith } from './reply-with.mjs';
import {
UsersSchemas,
type GetParams, type Get200, type GetResponses,
type PostBody, type Post201, type PostResponses,
type DelResponses
} from './users-schemas.mjs';
export class UsersView extends BaseView<{
usersRepo: {
get(id: number): Promise<Get200 | null>;
create(d: { name: string }): Promise<Post201>;
del(id: number): Promise<void>;
}
}> {
static schemas = UsersSchemas;
// GET /users/:id -> devolver 200 (happy path) o cortar con 404
async get(req: FastifyRequest<{ Params: GetParams }>, reply: FastifyReply): Promise<Get200> {
const user = await this.ctx.usersRepo.get(req.params.id);
if (!user) {
return replyWith<GetResponses>(reply, 404, { error: 'User not found' });
}
return user; // ✅ el wrapper hará reply.send(user)
}
// POST /users -> 201 o 400
async post(req: FastifyRequest<{ Body: PostBody }>, reply: FastifyReply): Promise<Post201> {
const name = req.body.name?.trim();
if (!name) {
return replyWith<PostResponses>(reply, 400, { error: 'name is required' });
}
const created = await this.ctx.usersRepo.create({ name });
reply.code(201);
return created; // ✅ el wrapper hará reply.send(created)
}
// DELETE /users/:id -> 204 sin body
async delete(req: FastifyRequest<{ Params: GetParams }>, reply: FastifyReply): Promise<never> {
await this.ctx.usersRepo.del(req.params.id);
return replyWith<DelResponses>(reply, 204); // 🚫 no admite body por tipos
}
}
```
Con esto:
- El **happy path** es natural (devuelves el modelo).
- Los **shortcuts** usan `replyWith()`; TS obliga a cerrar todos los caminos.
- El **wrapper** envía el retorno sin “recomprobar” nada.
- `204` no permite body (a nivel de tipos).
¿Quieres que te deje también una variante para **`206 Partial Content`** (paginación) con schema y ejemplo de `replyWith` + happy path?
user
don't check the result of the method call and call to send() in the handler, nor capture its exceptions, just return its result so Fastify can handle them directly. Do the exceptions processing and sending it back to the client making use of the fastify.setErrorHandler() method, and use there the fastify default handler too
ChatGPT
perfect — aquí va la versión “limpia” que pediste:
- El **handler** de cada ruta **no** hace `reply.send()` ni captura excepciones: simplemente **retorna** lo que devuelva tu método. Fastify se encarga de **enviar el valor retornado** o de **propagar la excepción**.
- El **manejo de errores** se hace con **`fastify.setErrorHandler()` dentro del plugin**, y además **encadenamos** con el **error handler por defecto** de Fastify.
- Seguimos con **`fastify-405`**, **HEAD primero** si tu clase implementa `head()`, y **schemas por método** contract-first.
- `replyWith()` corta el **flujo tipado** sin lanzar en runtime (usa `return undefined as never`), y valida **status → payload** (204 sin body).
---
# `cbv.mts` (ESM + TS)
```ts
// cbv.mts
import fp from 'fastify-plugin';
import allow405 from 'fastify-405';
import { METHODS } from 'node:http';
import type { FastifyInstance, FastifyReply, FastifyRequest, RouteShorthandOptions } from 'fastify';
export class HTTPError extends Error {
constructor(public statusCode: number, message?: string, public payload?: unknown) {
super(message);
}
}
/** Lista de métodos Node en minúscula, con HEAD movido al frente */
function orderedNodeMethods(): string[] {
const all = Array.from(new Set(METHODS.map(m => m.toLowerCase())));
const i = all.indexOf('head');
if (i > -1) { all.splice(i, 1); all.unshift('head'); }
return all;
}
export class BaseView<D extends object = Record<string, unknown>> {
constructor(public ctx: { req: FastifyRequest; reply: FastifyReply; fastify: FastifyInstance } & D) {}
static as_plugin<TDeps extends object = Record<string, unknown>>(opts?: {
deps?: TDeps;
common?: RouteShorthandOptions; // preHandler, config, etc.
}) {
const ViewClass = this as unknown as new (ctx: any) => BaseView<TDeps> & Record<string, any>;
const deps = opts?.deps ?? ({} as TDeps);
const common = opts?.common ?? {};
return fp(async function viewPlugin(fastify) {
// 405 + Allow para métodos no implementados en la misma URL
await fastify.register(allow405);
// ---- Error handler a nivel del plugin ----
// Guardamos el handler por defecto para encadenarlo
const defaultErrorHandler = fastify.errorHandler;
fastify.setErrorHandler((err, req, reply) => {
// Caso típico: errores "HTTP" de la app (si los usas)
if ((err as any)?.statusCode && typeof (err as any).statusCode === 'number') {
const e = err as { statusCode: number; payload?: unknown; message?: string };
return reply.code(e.statusCode).send(e.payload ?? { error: e.message ?? 'Error' });
}
// Delegamos al handler por defecto de Fastify
return defaultErrorHandler(err, req, reply);
});
// Schemas estáticos por método (contract-first)
const staticSchemas: Record<string, any> =
(ViewClass as any).schemas && typeof (ViewClass as any).schemas === 'object'
? (ViewClass as any).schemas
: {};
// Registramos una ruta por cada método implementado
for (const m of orderedNodeMethods()) {
if (typeof (ViewClass as any).prototype[m] !== 'function') continue;
fastify.route({
method: m.toUpperCase(),
url: '/', // se compone con el prefix de fastify.register(...)
...common,
schema: staticSchemas[m] ?? common.schema,
// 🔑 Handler minimalista:
// - NO llama a reply.send()
// - NO captura excepciones
// - Simplemente devuelve lo que retorne el método de la vista
// (Fastify enviará ese valor o propagará la excepción al error handler)
handler: (req, reply) => {
const view = new ViewClass({ req, reply, fastify: req.server, ...deps });
return (view as any)[m](req, reply);
}
});
}
});
}
}
```
---
# `reply-with.mts` (helper: status tipados, 204 sin body, corta flujo tipado)
```ts
// reply-with.mts
import type { FastifyReply } from 'fastify';
import type { FromSchema } from 'json-schema-to-ts';
export type ResponseMap = Record<number, unknown>;
export type StatusOf<R extends ResponseMap> = keyof R & number;
// 204 nunca lleva body; resto sí y tipado por el schema correspondiente
type BodyArg<R extends ResponseMap, S extends StatusOf<R>> =
S extends 204 ? [] : [body: FromSchema<R[S]>];
/**
* Envía (code + send) y marca este camino como TERMINAL para TypeScript (never),
* sin lanzar excepción en runtime.
*
* Truco para cortar el flujo tipado:
* - la función retorna `never`
* - tras send(), hacemos `return undefined as never`
* - así el compilador entiende que este camino "no vuelve",
* obligándote a usar replyWith() en todos los atajos (errores, 204, etc.).
*/
export function replyWith<R extends ResponseMap, S extends StatusOf<R>>(
reply: FastifyReply,
status: S,
...args: BodyArg<R, S>
): never {
// @ts-expect-error: los genéricos garantizan el tipo del body cuando aplica
reply.code(status).send(args[0]);
return undefined as never;
}
```
---
# Ejemplo (schemas + vista)
```ts
// users-schemas.mts
import type { FromSchema } from 'json-schema-to-ts';
const User = {
type: 'object',
properties: { id: { type: 'number' }, name: { type: 'string' } },
required: ['id','name'],
additionalProperties: false
} as const;
const ErrorPayload = {
type: 'object',
properties: { error: { type: 'string' } },
required: ['error'],
additionalProperties: false
} as const;
export const UsersSchemas = {
get: {
params: {
type: 'object',
properties: { id: { type: 'integer', minimum: 1 } },
required: ['id'],
additionalProperties: false
},
response: {
200: User,
404: ErrorPayload
}
} as const,
post: {
body: {
type: 'object',
properties: { name: { type: 'string', minLength: 1 } },
required: ['name'],
additionalProperties: false
},
response: {
201: User,
400: ErrorPayload
}
} as const,
del: {
response: { 204: { type: 'null' } } // 204 sin body
} as const
} as const;
export type GetParams = FromSchema<typeof UsersSchemas.get.params>;
export type Get200 = FromSchema<typeof UsersSchemas.get.response[200]>;
export type GetResponses = typeof UsersSchemas.get.response;
export type PostBody = FromSchema<typeof UsersSchemas.post.body>;
export type Post201 = FromSchema<typeof UsersSchemas.post.response[201]>;
export type PostResponses = typeof UsersSchemas.post.response;
export type DelResponses = typeof UsersSchemas.del.response;
```
```ts
// users-view.mts
import type { FastifyRequest, FastifyReply } from 'fastify';
import { BaseView } from './cbv.mjs';
import { replyWith } from './reply-with.mjs';
import {
UsersSchemas,
type GetParams, type Get200, type GetResponses,
type PostBody, type Post201, type PostResponses,
type DelResponses
} from './users-schemas.mjs';
export class UsersView extends BaseView<{
usersRepo: {
get(id: number): Promise<Get200 | null>;
create(d: { name: string }): Promise<Post201>;
del(id: number): Promise<void>;
}
}> {
static schemas = UsersSchemas;
// GET /users/:id -> happy path: devolver User; error: atajo 404
async get(req: FastifyRequest<{ Params: GetParams }>, _reply: FastifyReply): Promise<Get200> {
const user = await this.ctx.usersRepo.get(req.params.id);
if (!user) {
return replyWith<GetResponses>(_reply, 404, { error: 'User not found' });
}
return user; // Fastify enviará este valor
}
// POST /users -> 201 (devuelves el modelo) o 400 (atajo)
async post(req: FastifyRequest<{ Body: PostBody }>, reply: FastifyReply): Promise<Post201> {
const name = req.body.name?.trim();
if (!name) {
return replyWith<PostResponses>(reply, 400, { error: 'name is required' });
}
const created = await this.ctx.usersRepo.create({ name });
reply.code(201);
return created; // Fastify enviará este valor con 201
}
// DELETE /users/:id -> 204 sin body
async delete(req: FastifyRequest<{ Params: GetParams }>, reply: FastifyReply): Promise<never> {
await this.ctx.usersRepo.del(req.params.id);
return replyWith<DelResponses>(reply, 204); // 204 no admite body (tipado)
}
}
```
---
## Notas rápidas
- **HEAD**: si defines `head()` en tu clase, se registrará **antes** que `get()`; si no lo defines, Fastify generará el HEAD “hermano” de GET (`exposeHeadRoutes: true` por defecto).
- **Manejo de errores**: al no capturar en el handler, cualquier excepción llega al **error handler** del plugin. Ahí:
- puedes mapear tus errores de dominio (ej. `HTTPError`) a status/payload,
- y luego delegar todo lo demás al **handler por defecto** (`defaultErrorHandler(err, req, reply)`).
- **Tipado exhaustivo**: al devolver tipos concretos en happy path y usar `replyWith(...): never` en atajos, TypeScript te avisará si dejas una ruta de código sin “cerrar”.
Si quieres, te preparo un ejemplo con **`206 Partial Content`** (paginación) y cómo modelar `Content-Range`/`Accept-Ranges` en el schema y en el método.
user
en el error handler, añade que si no hay un statusCode, setealo como 500. No lo definas dentro del plugin de BaseView, es un ejemplo, definelo a nivel del servidor de Fastify, donde estas llamando a UserView.as_plugin()
ChatGPT
Perfecto 👌.
Dejamos el **`BaseView.as_plugin()`** minimalista (sin error handler). El **error handler** se define **a nivel del servidor**, donde registras las vistas. Allí:
- Si la excepción ya trae un `statusCode`, se usa.
- Si no trae, se fuerza `500`.
- Se reusa el **handler por defecto** de Fastify para el resto de lógica.
---
# `cbv.mts` (simplificado)
```ts
// cbv.mts
import fp from 'fastify-plugin';
import allow405 from 'fastify-405';
import { METHODS } from 'node:http';
import type { FastifyInstance, FastifyReply, FastifyRequest, RouteShorthandOptions } from 'fastify';
export class HTTPError extends Error {
constructor(public statusCode: number, message?: string, public payload?: unknown) {
super(message);
}
}
function orderedNodeMethods(): string[] {
const all = Array.from(new Set(METHODS.map(m => m.toLowerCase())));
const i = all.indexOf('head');
if (i > -1) { all.splice(i, 1); all.unshift('head'); }
return all;
}
export class BaseView<D extends object = Record<string, unknown>> {
constructor(public ctx: { req: FastifyRequest; reply: FastifyReply; fastify: FastifyInstance } & D) {}
static as_plugin<TDeps extends object = Record<string, unknown>>(opts?: {
deps?: TDeps;
common?: RouteShorthandOptions;
}) {
const ViewClass = this as unknown as new (ctx: any) => BaseView<TDeps> & Record<string, any>;
const deps = opts?.deps ?? ({} as TDeps);
const common = opts?.common ?? {};
return fp(async function viewPlugin(fastify) {
await fastify.register(allow405);
const staticSchemas: Record<string, any> =
(ViewClass as any).schemas && typeof (ViewClass as any).schemas === 'object'
? (ViewClass as any).schemas
: {};
for (const m of orderedNodeMethods()) {
if (typeof (ViewClass as any).prototype[m] !== 'function') continue;
fastify.route({
method: m.toUpperCase(),
url: '/',
...common,
schema: staticSchemas[m] ?? common.schema,
handler: (req, reply) => {
const view = new ViewClass({ req, reply, fastify: req.server, ...deps });
return (view as any)[m](req, reply);
}
});
}
});
}
}
```
---
# `server.mts` (error handler global)
```ts
// server.mts
import Fastify from 'fastify';
import { JSONSchemaToTSProvider } from 'fastify-type-provider-json-schema-to-ts';
import { UsersView } from './users-view.mjs';
const app = Fastify({ logger: true }).withTypeProvider<JSONSchemaToTSProvider>();
// Guardamos el handler por defecto de Fastify
const defaultErrorHandler = app.errorHandler;
// 🔑 Error handler global
app.setErrorHandler((err, req, reply) => {
if ((err as any)?.statusCode && typeof (err as any).statusCode === 'number') {
// Error con status definido (ej. HTTPError de la app)
return reply.code((err as any).statusCode).send(
(err as any).payload ?? { error: (err as any).message ?? 'Error' }
);
}
// Error genérico sin statusCode → forzar 500
reply.code(500);
// Delegamos en el handler por defecto de Fastify (log, stacktrace, etc.)
return defaultErrorHandler(err, req, reply);
});
// ---- Registro de vistas ----
const usersRepo = {
async get(id: number) {
return id === 1 ? { id: 1, name: 'Ada' } : null;
},
async create({ name }: { name: string }) {
return { id: Date.now(), name };
},
async del(_id: number) {
/* delete user */
}
};
await app.register(UsersView.as_plugin({ deps: { usersRepo } }), { prefix: '/users' });
await app.listen({ port: 3000 });
```
---
✅ Así:
- `UsersView` sigue siendo contract-first, devolviendo valores o usando `replyWith()`.
- Los **errores de dominio** (`HTTPError` con `statusCode`) responden con ese código.
- Los **errores sin statusCode** (ej. bug inesperado) responden con **500**.
- El **defaultErrorHandler** de Fastify sigue procesando logs y formato estándar.
¿Quieres que te prepare un `HTTPError` con genéricos (`<Responses, Status>`) para que también valide el tipo del `payload` al lanzar, igual que hicimos con `replyWith()`?
user
se te ha olvidado hacer el send del error al cliente cuando seteas el statusCode a 500, el error handler por defecto solo loggea el error con su estado en la consola del servidor, no notifica al cliente
ChatGPT
¡Buen ojo! Tienes razón: hay que **enviar** algo al cliente cuando no venga `statusCode`. Aquí va el `server.mts` con un **error handler global** que:
- deja que el **handler por defecto** de Fastify haga su logging,
- **y luego** (si no se ha enviado nada) responde al cliente:
- con el `statusCode` y `payload` de tu error “HTTP” (si existen),
- o con **500** y un cuerpo genérico.
```ts
// server.mts
import Fastify from 'fastify';
import { JSONSchemaToTSProvider } from 'fastify-type-provider-json-schema-to-ts';
import { UsersView } from './users-view.mjs';
const app = Fastify({ logger: true }).withTypeProvider<JSONSchemaToTSProvider>();
// Guarda el handler por defecto para reutilizar su logging/formato
const defaultErrorHandler = app.errorHandler;
app.setErrorHandler((err, req, reply) => {
// Deja que el handler por defecto registre/loggee el error
try { defaultErrorHandler(err, req, reply); } catch { /* noop */ }
// Si el default handler ya respondió (u otro middleware), no duplicar
if (reply.sent) return;
const hasStatus = typeof (err as any)?.statusCode === 'number';
const status = hasStatus ? (err as any).statusCode : 500;
// Si tu error trae un payload estructurado, úsalo; si no, algo razonable
const payload = (err as any)?.payload
?? { error: (err as any)?.message ?? (status === 500 ? 'Internal Server Error' : 'Error') };
reply.code(status).send(payload);
});
// --- registro de la vista como antes ---
const usersRepo = {
async get(id: number) { return id === 1 ? { id: 1, name: 'Ada' } : null; },
async create({ name }: { name: string }) { return { id: Date.now(), name }; },
async del(_id: number) { /* ... */ }
};
await app.register(UsersView.as_plugin({ deps: { usersRepo } }), { prefix: '/users' });
await app.listen({ port: 3000 });
```
Esto garantiza que **siempre** llega una respuesta al cliente: tus errores de dominio salen con su `statusCode` y cuerpo; y cualquier otro error termina en un **500** con un JSON sencillo.
user
resume todo el trabajo y todos los puntos discutidos en la conversacion en un articulo para el blog, escribelo en ingles. Incluye todo el codigo final comentando todos los aspectos y detalles del mismo como el del metodo head antes del get o que 204 NO CONTENT nunca debe tener body segun la especificacion de HTTP, y no te olvides de los fixtures con nombres de pioneras de la informatica. Puedes mencionar que la idea surgio en una noche de insomnio (es verdad), y que al no encontrar ninguna alternativa similar a las Class Based Views de Django para Node.js mas que algun "intento" sin apenas repercusion cuyo ultimo commit fue de hace 8-9 años, decidi investigar con tu ayuda el como hacerlo yo mismo, ya que me gusta mucho lo faciles que son de usar al proporcionar toda la funcionalidad ya hecha, y permitiendo al mismo tiempo la flexibilidad de poder adaptarlo a los distintos casos de uso. Explica en el articulo tambien todo el tema de Typescript y del tipado estricto y como funciona, aunque no este directamente vinculado a las Class Based Views y sean realmente solo un añadido de calidad de codigo y buenas practicas, aunque acuerdate de indicar tambien el como se usan y como funciona para definir los tipos a partir del schema con fastify-type-provider-json-schema-to-ts y json-schema-to-ts y que la fuente de verdad del tipado son los schemas (contract first). Tambien explica como funciona el mecanismo de mixins que propones usando class expressions o class factories y composicion de tipos.
ChatGPT
Here’s a long-form blog article draft summarizing all our discussion and the final solution. I’ve written it in English, with code blocks and detailed commentary so you can publish it directly or adapt it to your blog.
---
# Bringing Class-Based Views to Fastify (Inspired by Django)
A few nights ago, in the middle of insomnia, I started wondering:
> *Why doesn’t Node.js have something like Django’s Class-Based Views (CBVs)?*
I did a quick search. What I found were a few experiments from ~8–9 years ago, mostly abandoned with a couple of commits and little traction. Nothing robust, modern, or maintained.
I love Django CBVs: they provide a **clear structure**, lots of **built-in functionality**, and still enough flexibility to adapt them to different use cases. So I decided to sketch out a design for Fastify (and Node.js in general) that feels natural to use, contract-first, and TypeScript-friendly. This post is a walk-through of that exploration.
---
## Why Class-Based Views?
In Django, instead of writing separate functions for each route, you can define a **class** with methods like `get()`, `post()`, `delete()`. Django automatically registers those with the correct HTTP verbs and routes.
The benefits are clear:
- **Organization:** keep related logic together.
- **Inheritance & Mixins:** factor out reusable behavior.
- **Consistency:** no need to wire up handlers manually.
---
## The Challenges in Node.js
- Most Node frameworks (Express, Fastify, Koa) assume **functions** as handlers.
- There’s little to no **out-of-the-box CBV support**.
- TypeScript typing gets tricky: how do we connect the schemas (runtime) to strict request/response types (compile-time)?
- HTTP has subtle rules to respect (e.g. `HEAD` vs `GET`, `204 No Content` never having a body).
---
## Building Blocks
We ended up with a design that combines several ideas:
1. **BaseView**: a class that defines `as_plugin()`, returning a Fastify plugin with routes registered automatically for any methods you implement (`get`, `post`, `delete`, etc.).
2. **`replyWith()` helper**: to handle *shortcuts* (errors, 404s, 204s) in a type-safe way, cutting the control flow.
3. **Error handling**: done globally via `fastify.setErrorHandler()`, not inside the view itself.
4. **Contract-first schemas**: using `fastify-type-provider-json-schema-to-ts` (JSTT) and `json-schema-to-ts`, the JSON Schema is the source of truth, and TypeScript types are derived from it.
5. **Mixins via class factories**: because TypeScript doesn’t have multiple inheritance, we can use class expressions to compose behavior.
---
## The Final `BaseView` Implementation
```ts
// cbv.mts
import fp from 'fastify-plugin';
import allow405 from 'fastify-405';
import { METHODS } from 'node:http';
import type { FastifyInstance, FastifyReply, FastifyRequest, RouteShorthandOptions } from 'fastify';
export class HTTPError extends Error {
constructor(public statusCode: number, message?: string, public payload?: unknown) {
super(message);
}
}
/** Return HTTP methods in lowercase, but put HEAD before GET.
* Why? Because Fastify will auto-generate HEAD routes for GET if none exist.
* If you *do* define your own head(), we need to register it first.
*/
function orderedNodeMethods(): string[] {
const all = Array.from(new Set(METHODS.map(m => m.toLowerCase())));
const i = all.indexOf('head');
if (i > -1) { all.splice(i, 1); all.unshift('head'); }
return all;
}
export class BaseView<D extends object = Record<string, unknown>> {
constructor(public ctx: { req: FastifyRequest; reply: FastifyReply; fastify: FastifyInstance } & D) {}
static as_plugin<TDeps extends object = Record<string, unknown>>(opts?: {
deps?: TDeps;
common?: RouteShorthandOptions;
}) {
const ViewClass = this as unknown as new (ctx: any) => BaseView<TDeps> & Record<string, any>;
const deps = opts?.deps ?? ({} as TDeps);
const common = opts?.common ?? {};
return fp(async function viewPlugin(fastify) {
await fastify.register(allow405); // 405 + Allow for unsupported methods
const staticSchemas: Record<string, any> =
(ViewClass as any).schemas && typeof (ViewClass as any).schemas === 'object'
? (ViewClass as any).schemas
: {};
for (const m of orderedNodeMethods()) {
if (typeof (ViewClass as any).prototype[m] !== 'function') continue;
fastify.route({
method: m.toUpperCase(),
url: '/', // combined with prefix at register time
...common,
schema: staticSchemas[m] ?? common.schema,
// Handler: just return whatever the view method returns.
// Fastify itself will send it or propagate errors to setErrorHandler().
handler: (req, reply) => {
const view = new ViewClass({ req, reply, fastify: req.server, ...deps });
return (view as any)[m](req, reply);
}
});
}
});
}
}
```
---
## Shortcuts with `replyWith()`
The happy path is simple: return your value, and Fastify will send it. But for shortcuts (errors, 404, 204), we use `replyWith()`.
```ts
// reply-with.mts
import type { FastifyReply } from 'fastify';
import type { FromSchema } from 'json-schema-to-ts';
export type ResponseMap = Record<number, unknown>;
export type StatusOf<R extends ResponseMap> = keyof R & number;
type BodyArg<R extends ResponseMap, S extends StatusOf<R>> =
S extends 204 ? [] : [body: FromSchema<R[S]>];
/**
* Sends a response with code+body, and marks this branch as terminal (`never`).
* Trick: after reply.send(), we return `undefined as never`.
* This way TypeScript enforces that every code path either returns a value
* (happy path) or uses replyWith() (shortcut).
*/
export function replyWith<R extends ResponseMap, S extends StatusOf<R>>(
reply: FastifyReply,
status: S,
...args: BodyArg<R, S>
): never {
// @ts-expect-error: generics enforce type correctness
reply.code(status).send(args[0]);
return undefined as never;
}
```
### Why `never`?
Because it cuts the control flow in TypeScript. If you forget to `return` something in a method, the compiler will complain. This ensures exhaustiveness.
---
## Contract-First Schemas
We define schemas as the **single source of truth**, then derive TypeScript types using `json-schema-to-ts`.
```ts
// users-schemas.mts
import type { FromSchema } from 'json-schema-to-ts';
const User = {
type: 'object',
properties: { id: { type: 'number' }, name: { type: 'string' } },
required: ['id','name'],
additionalProperties: false
} as const;
const ErrorPayload = {
type: 'object',
properties: { error: { type: 'string' } },
required: ['error'],
additionalProperties: false
} as const;
export const UsersSchemas = {
get: {
params: {
type: 'object',
properties: { id: { type: 'integer', minimum: 1 } },
required: ['id'],
additionalProperties: false
},
response: {
200: User,
404: ErrorPayload
}
} as const,
post: {
body: {
type: 'object',
properties: { name: { type: 'string', minLength: 1 } },
required: ['name'],
additionalProperties: false
},
response: {
201: User,
400: ErrorPayload
}
} as const,
del: {
// According to the HTTP spec, 204 No Content must *never* have a body.
response: { 204: { type: 'null' } }
} as const
} as const;
// Derived types
export type GetParams = FromSchema<typeof UsersSchemas.get.params>;
export type Get200 = FromSchema<typeof UsersSchemas.get.response[200]>;
export type GetResponses = typeof UsersSchemas.get.response;
export type PostBody = FromSchema<typeof UsersSchemas.post.body>;
export type Post201 = FromSchema<typeof UsersSchemas.post.response[201]>;
export type PostResponses = typeof UsersSchemas.post.response;
export type DelResponses = typeof UsersSchemas.del.response;
```
---
## The `UsersView`
Example view with fixtures named after pioneering women in computing: **Ada Lovelace**, **Grace Hopper**, **Hedy Lamarr**, **Radia Perlman**.
```ts
// users-view.mts
import type { FastifyRequest, FastifyReply } from 'fastify';
import { BaseView } from './cbv.mjs';
import { replyWith } from './reply-with.mjs';
import {
UsersSchemas,
type GetParams, type Get200, type GetResponses,
type PostBody, type Post201, type PostResponses,
type DelResponses
} from './users-schemas.mjs';
export class UsersView extends BaseView<{
usersRepo: {
get(id: number): Promise<Get200 | null>;
create(d: { name: string }): Promise<Post201>;
del(id: number): Promise<void>;
}
}> {
static schemas = UsersSchemas;
// GET /users/:id
async get(req: FastifyRequest<{ Params: GetParams }>, reply: FastifyReply): Promise<Get200> {
const user = await this.ctx.usersRepo.get(req.params.id);
if (!user) {
return replyWith<GetResponses>(reply, 404, { error: 'User not found' });
}
return user; // happy path
}
// POST /users
async post(req: FastifyRequest<{ Body: PostBody }>, reply: FastifyReply): Promise<Post201> {
const name = req.body.name?.trim();
if (!name) {
return replyWith<PostResponses>(reply, 400, { error: 'name is required' });
}
const created = await this.ctx.usersRepo.create({ name });
reply.code(201);
return created;
}
// DELETE /users/:id
async delete(req: FastifyRequest<{ Params: GetParams }>, reply: FastifyReply): Promise<never> {
await this.ctx.usersRepo.del(req.params.id);
return replyWith<DelResponses>(reply, 204);
}
}
```
---
## Global Error Handler
Error handling is done once, at the Fastify server level:
```ts
// server.mts
import Fastify from 'fastify';
import { JSONSchemaToTSProvider } from 'fastify-type-provider-json-schema-to-ts';
import { UsersView } from './users-view.mjs';
const app = Fastify({ logger: true }).withTypeProvider<JSONSchemaToTSProvider>();
const defaultErrorHandler = app.errorHandler;
app.setErrorHandler((err, req, reply) => {
try { defaultErrorHandler(err, req, reply); } catch { /* noop */ }
if (reply.sent) return;
const status = typeof (err as any)?.statusCode === 'number' ? (err as any).statusCode : 500;
const payload = (err as any)?.payload
?? { error: (err as any)?.message ?? (status === 500 ? 'Internal Server Error' : 'Error') };
reply.code(status).send(payload);
});
// Example repo with fixtures
const usersRepo = {
async get(id: number) {
const all = [
{ id: 1, name: 'Ada' },
{ id: 2, name: 'Grace' },
{ id: 3, name: 'Hedy' },
{ id: 4, name: 'Radia' }
];
return all.find(u => u.id === id) ?? null;
},
async create({ name }: { name: string }) {
return { id: Date.now(), name };
},
async del(_id: number) { /* ... */ }
};
await app.register(UsersView.as_plugin({ deps: { usersRepo } }), { prefix: '/users' });
await app.listen({ port: 3000 });
```
---
## About Mixins
One of the nice parts of Django CBVs is inheritance and mixins. TypeScript doesn’t allow multiple inheritance, but you can achieve similar results with **class factories**:
```ts
type Constructor<T = {}> = new (...args: any[]) => T;
function LoggingMixin<TBase extends Constructor>(Base: TBase) {
return class extends Base {
log(message: string) {
console.log(`[${this.constructor.name}] ${message}`);
}
};
}
class MyView extends LoggingMixin(BaseView) {
async get() {
this.log('Handling GET');
return { ok: true };
}
}
```
This pattern lets you **compose behavior** flexibly, and types carry through correctly.
---
## TypeScript Strictness
This whole design leans heavily on **contract-first schemas**:
- **Source of truth**: JSON Schema.
- **Compile-time types**: derived via `json-schema-to-ts`.
- **Runtime validation/serialization**: Fastify + AJV.
- **Type-provider (JSTT)**: glues the schemas into `req.query`, `req.body`, and `reply.send()` signatures.
That means if you update the schema, TypeScript will **force you to update your code**, or it won’t compile.
---
## Conclusion
Starting from a bout of insomnia, I ended up building a Django-like CBV system for Fastify:
- **Automatic method routing** with `as_plugin()`.
- **HEAD registered before GET** to align with Fastify’s behavior.
- **405 Method Not Allowed** handled by `fastify-405`.
- **`replyWith()`** helper for type-safe shortcuts (404, 400, 204).
- **204 No Content** strictly without body, enforced by types.
- **Global error handler** consistent with Fastify’s default.
- **Contract-first schemas** with strict TypeScript typing.
- **Mixins via class factories** for flexible composition.
It’s not a full-blown library (yet), but it shows how powerful the combination of **Fastify**, **TypeScript**, and a few design patterns can be. If you miss Django’s CBVs in Node.js, this approach might scratch that itch.
And hey, at least my insomnia was good for something.
---
Would you like me to extend this article with a **ready-to-use npm package skeleton** (with `package.json`, `tsconfig`, and published types), so you could actually open-source it as `fastify-cbv`?
user
- estas llamando a allow405 con los metodos por defecto, en vez de los metodos incluidos en la vista, corrigelo
- prepare the npm package skeleton
ChatGPT
Got it. I’ve fixed the 405 behavior so it **uses only the HTTP methods actually implemented by the view**, and I’ve included a full **npm package skeleton** you can drop into a repo.
Two quick notes before the code:
- There are two community plugins that solve 405 in Fastify-land:
- **`fastify-allow`**: automatically adds an `Allow` header and replies `405` for unhandled methods on a registered route. It **derives the allowed methods from the routes you actually registered**, which is exactly what we want.
- **`fastify-405`**: older/simple helper; historically people passed a static list. Since we now compute allowed methods per-view, the most robust approach today is **`fastify-allow`** (no manual lists, no drift).
- Per your request, we make the plugin register **only** the methods that the view implements. The 405 logic then works off that set (via `fastify-allow`), so `Allow` is always correct.
If you strictly need `fastify-405`, you can still wire it in similarly—but `fastify-allow` avoids any guessing and stays in sync with the routes you define.
---
# 📦 Package skeleton
```
fastify-cbv/
├─ package.json
├─ tsconfig.json
├─ README.md
├─ src/
│ ├─ cbv.mts
│ ├─ reply-with.mts
│ ├─ mixins.mts
│ ├─ examples/
│ │ ├─ users-schemas.mts
│ │ ├─ users-view.mts
│ │ └─ server.mts
└─ .npmignore
```
## `package.json`
```json
{
"name": "fastify-cbv",
"version": "0.1.0",
"description": "Class-Based Views for Fastify (Django-style) with contract-first schemas and strict TypeScript types.",
"type": "module",
"main": "./dist/cjs/index.cjs",
"types": "./dist/types/index.d.ts",
"module": "./dist/esm/index.js",
"exports": {
".": {
"types": "./dist/types/index.d.ts",
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.cjs"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc -p tsconfig.json && tsc -p tsconfig.json --module commonjs --outDir dist/cjs && cp -R dist/esm/*.d.ts dist/types || true",
"dev": "node --loader ts-node/esm src/examples/server.mts",
"test": "node -e \"console.log('add your favorite runner')\"",
"lint": "eslint ."
},
"keywords": ["fastify", "cbv", "class-based-views", "http", "typescript"],
"author": "you",
"license": "MIT",
"peerDependencies": {
"fastify": ">=4 || >=5",
"fastify-type-provider-json-schema-to-ts": "^3",
"json-schema-to-ts": "^3"
},
"dependencies": {
"fastify-allow": "^2.0.0",
"fastify-plugin": "^4.5.1"
},
"devDependencies": {
"typescript": "^5.6.3",
"ts-node": "^10.9.2",
"@types/node": "^20.12.12",
"eslint": "^9.10.0"
}
}
```
> We ship both ESM and CJS builds for compatibility. The source uses **ESM** (`.mts`).
## `tsconfig.json`
```json
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"lib": ["ES2022"],
"moduleResolution": "Bundler",
"declaration": true,
"declarationDir": "dist/esm",
"outDir": "dist/esm",
"rootDir": "src",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true
},
"include": ["src"]
}
```
## `.npmignore`
```
src/
node_modules/
*.log
*.map
*.tsbuildinfo
```
---
# 🧠 Core: `BaseView` (ESM, HEAD-first, only implemented methods)
- Registers a route **per implemented method** (`get`, `post`, etc.).
- Moves **`HEAD` before `GET`** so a custom `head()` wins; otherwise Fastify will auto-generate HEAD for GET (which is correct).
- Uses **`fastify-allow`** in the view scope, which derives the `Allow` header and returns `405` for methods you didn’t register at that path.
```ts
// src/cbv.mts
import fp from 'fastify-plugin';
import fastifyAllow from 'fastify-allow';
import { METHODS } from 'node:http';
import type { FastifyInstance, FastifyReply, FastifyRequest, RouteShorthandOptions } from 'fastify';
export class HTTPError extends Error {
constructor(public statusCode: number, message?: string, public payload?: unknown) {
super(message);
}
}
/** Node’s methods in lowercase, with HEAD moved first.
* If you define head(), we must register it before get().
*/
function orderedNodeMethods(): string[] {
const all = Array.from(new Set(METHODS.map(m => m.toLowerCase())));
const i = all.indexOf('head');
if (i > -1) { all.splice(i, 1); all.unshift('head'); }
return all;
}
export class BaseView<D extends object = Record<string, unknown>> {
constructor(public ctx: { req: FastifyRequest; reply: FastifyReply; fastify: FastifyInstance } & D) {}
static as_plugin<TDeps extends object = Record<string, unknown>>(opts?: {
deps?: TDeps;
common?: RouteShorthandOptions;
}) {
const ViewClass = this as unknown as new (ctx: any) => BaseView<TDeps> & Record<string, any>;
const deps = opts?.deps ?? ({} as TDeps);
const common = opts?.common ?? {};
return fp(async function viewPlugin(fastify) {
// Derive allowed methods from what we actually register in this scope.
await fastify.register(fastifyAllow);
const staticSchemas: Record<string, any> =
(ViewClass as any).schemas && typeof (ViewClass as any).schemas === 'object'
? (ViewClass as any).schemas
: {};
for (const m of orderedNodeMethods()) {
if (typeof (ViewClass as any).prototype[m] !== 'function') continue;
fastify.route({
method: m.toUpperCase(),
url: '/', // combined with options.prefix
...common,
schema: staticSchemas[m] ?? common.schema,
// minimal handler: return the method result; Fastify sends or forwards errors
handler: (req, reply) => {
const view = new ViewClass({ req, reply, fastify: req.server, ...deps });
return (view as any)[m](req, reply);
}
});
}
});
}
}
```
> If you still prefer `fastify-405`, register it *after* the view routes and **pass the computed list** for that scope. With `fastify-allow` we don’t need to pass anything—allowed methods are inferred from registered handlers.
---
# ✂️ Shortcuts: `replyWith()` (never returns, 204 has no body)
- Happy path: **return** your value; set `reply.code(201)` if needed.
- Shortcuts: `return replyWith<Responses>(reply, 404, {...})`.
- **204 No Content**: the type enforces **no body** (per HTTP spec).
```ts
// src/reply-with.mts
import type { FastifyReply } from 'fastify';
import type { FromSchema } from 'json-schema-to-ts';
export type ResponseMap = Record<number, unknown>;
export type StatusOf<R extends ResponseMap> = keyof R & number;
// 204 never has a body; others must match the schema payload
type BodyArg<R extends ResponseMap, S extends StatusOf<R>> =
S extends 204 ? [] : [body: FromSchema<R[S]>];
/** Send code+body and cut the control flow (never), without throwing at runtime.
* Trick: `return undefined as never` right after `reply.send(...)`.
*/
export function replyWith<R extends ResponseMap, S extends StatusOf<R>>(
reply: FastifyReply,
status: S,
...args: BodyArg<R, S>
): never {
// @ts-expect-error: generics guarantee args[0] type when applicable
reply.code(status).send(args[0]);
return undefined as never;
}
```
---
# 🧩 Optional: Mixins with class factories
```ts
// src/mixins.mts
export type Ctor<T = {}> = new (...args: any[]) => T;
export function LoggingMixin<TBase extends Ctor>(Base: TBase) {
return class extends Base {
log(message: string) {
// You can swap this out with your logger
console.log(`[${this.constructor.name}] ${message}`);
}
};
}
```
Use them like:
```ts
class MyView extends LoggingMixin(BaseView) {
async get() {
this.log('handling GET');
return { ok: true };
}
}
```
---
# 🧪 Example: Users view with pioneers fixtures
Schemas are the **source of truth** (contract-first). Types are **derived** via `json-schema-to-ts`. Runtime validation/serialization is done by Fastify/Ajv. If you flip a schema, TypeScript will force code changes.
```ts
// src/examples/users-schemas.mts
import type { FromSchema } from 'json-schema-to-ts';
const User = {
type: 'object',
properties: { id: { type: 'number' }, name: { type: 'string' } },
required: ['id','name'],
additionalProperties: false
} as const;
const ErrorPayload = {
type: 'object',
properties: { error: { type: 'string' } },
required: ['error'],
additionalProperties: false
} as const;
export const UsersSchemas = {
get: {
params: {
type: 'object',
properties: { id: { type: 'integer', minimum: 1 } },
required: ['id'],
additionalProperties: false
},
response: {
200: User,
404: ErrorPayload
}
} as const,
post: {
body: {
type: 'object',
properties: { name: { type: 'string', minLength: 1 } },
required: ['name'],
additionalProperties: false
},
response: {
201: User,
400: ErrorPayload
}
} as const,
del: {
// HTTP: 204 No Content MUST NOT include a body
response: { 204: { type: 'null' } }
} as const
} as const;
// Derived types
export type GetParams = FromSchema<typeof UsersSchemas.get.params>;
export type Get200 = FromSchema<typeof UsersSchemas.get.response[200]>;
export type GetResponses = typeof UsersSchemas.get.response;
export type PostBody = FromSchema<typeof UsersSchemas.post.body>;
export type Post201 = FromSchema<typeof UsersSchemas.post.response[201]>;
export type PostResponses = typeof UsersSchemas.post.response;
export type DelResponses = typeof UsersSchemas.del.response;
```
```ts
// src/examples/users-view.mts
import type { FastifyRequest, FastifyReply } from 'fastify';
import { BaseView } from '../cbv.mjs';
import { replyWith } from '../reply-with.mjs';
import {
UsersSchemas,
type GetParams, type Get200, type GetResponses,
type PostBody, type Post201, type PostResponses,
type DelResponses
} from './users-schemas.mjs';
export class UsersView extends BaseView<{
usersRepo: {
get(id: number): Promise<Get200 | null>;
create(d: { name: string }): Promise<Post201>;
del(id: number): Promise<void>;
}
}> {
static schemas = UsersSchemas;
async get(req: FastifyRequest<{ Params: GetParams }>, reply: FastifyReply): Promise<Get200> {
const user = await this.ctx.usersRepo.get(req.params.id);
if (!user) {
return replyWith<GetResponses>(reply, 404, { error: 'User not found' });
}
return user; // happy path
}
async post(req: FastifyRequest<{ Body: PostBody }>, reply: FastifyReply): Promise<Post201> {
const name = req.body.name?.trim();
if (!name) {
return replyWith<PostResponses>(reply, 400, { error: 'name is required' });
}
const created = await this.ctx.usersRepo.create({ name });
reply.code(201); // set success status; Fastify will send the returned value
return created;
}
async delete(req: FastifyRequest<{ Params: GetParams }>, reply: FastifyReply): Promise<never> {
await this.ctx.usersRepo.del(req.params.id);
return replyWith<DelResponses>(reply, 204); // 204: no body by type
}
}
```
```ts
// src/examples/server.mts
import Fastify from 'fastify';
import { JSONSchemaToTSProvider } from 'fastify-type-provider-json-schema-to-ts';
import { UsersView } from './users-view.mjs';
// Fixtures named after pioneers: Ada, Grace, Hedy, Radia
const usersRepo = {
async get(id: number) {
const all = [
{ id: 1, name: 'Ada' },
{ id: 2, name: 'Grace' },
{ id: 3, name: 'Hedy' },
{ id: 4, name: 'Radia' }
];
return all.find(u => u.id === id) ?? null;
},
async create({ name }: { name: string }) {
return { id: Date.now(), name };
},
async del(_id: number) {
/* pretend to delete */
}
};
const app = Fastify({ logger: true }).withTypeProvider<JSONSchemaToTSProvider>();
// Global error handler: reuse default logging, then ensure a client response.
// If error has statusCode/payload, use it; otherwise set 500.
const defaultErrorHandler = app.errorHandler;
app.setErrorHandler((err, req, reply) => {
try { defaultErrorHandler(err, req, reply); } catch { /* noop */ }
if (reply.sent) return;
const status = typeof (err as any)?.statusCode === 'number' ? (err as any).statusCode : 500;
const payload = (err as any)?.payload
?? { error: (err as any)?.message ?? (status === 500 ? 'Internal Server Error' : 'Error') };
reply.code(status).send(payload);
});
// Mount the view as a single plugin, one register per URL.
await app.register(UsersView.as_plugin({ deps: { usersRepo } }), { prefix: '/users' });
await app.listen({ port: 3000 });
```
---
## How it all fits together
- **One registration per URL**: `fastify.register(View.as_plugin(), { prefix: '/users' })`.
- The plugin **only** registers methods your class implements.
→ `fastify-allow` then **adds `Allow`** and **returns 405** for unhandled methods at that URL.
- **`HEAD` first**: if you define `head()`, we register it **before** `get()`. Otherwise Fastify will auto-create a HEAD sibling for GET (the default behavior you want).
- **Contract-first**: JSON Schemas live next to the methods; types are derived via `json-schema-to-ts`; runtime validation is AJV.
- **Strict typing & control flow**: `replyWith(...): never` lets you early-return with a specific status/body and makes TypeScript **enforce exhaustiveness**.
- **204 No Content**: enforced to have **no body** in the type (`replyWith<..., 204>(reply, 204)` accepts **no** payload).
- **Mixins**: add cross-cutting behavior with class factories when you need them.
- **Global error handling**: use `setErrorHandler()` once; let views stay minimal.
---
## Next steps
- Publish this as `fastify-cbv` (you’re already 90% there).
- Add a `ModelView` helper (CRUD defaults) and a tiny doc site.
- Optional: an adapter to auto-generate OpenAPI from your schemas.
If you want, I can also drop in a `ModelView` base class and a few tests so you can `npm publish` right away.