Escribe tu primer plugin backend
|
Objetivos del capítulo
|
El primer plugin: sazon-status
Hora de cocinar algo: vamos a escribir un plugin que expone el estado de cada servicio de Sazón Foods. Como una pizarra en la cocina donde el jefe anota si cada plato está listo. La pizarra se sirve vía HTTP, validada con Zod y protegida por auth.
Construimos el plugin @sazon/plugin-sazon-status-backend en el crate del capítulo.
Anatomía de un plugin backend
Un plugin backend tiene tres piezas mínimas:
import {
createBackendPlugin,
coreServices,
} from '@backstage/backend-plugin-api';
import { createRouter } from './router';
export const sazonStatusPlugin = createBackendPlugin({
id: 'sazon-status',
register(env) {
env.registerInit({
deps: {
httpRouter: coreServices.httpRouter,
httpAuth: coreServices.httpAuth,
logger: coreServices.logger,
},
async init({ httpRouter, httpAuth, logger }) {
httpRouter.use(
await createRouter({
httpAuth,
logger,
}),
);
},
});
},
});
|
Qué acabas de ver
|
El router con Zod
Cada endpoint se valida con un esquema Zod. Si la entrada no cumple, lanzamos InputError y un middleware global lo convierte en 400.
const StatusQuery = z.object({
serviceName: z.string().min(1).max(64),
});
export async function createRouter(options: {
httpAuth: HttpAuthService;
logger: LoggerService;
}): Promise {
const router = Router();
const { httpAuth, logger } = options;
router.get('/health', (_req, res) => {
res.json({ status: 'ok' });
});
router.get('/status/:serviceName', async (req, res) => {
const parsed = StatusQuery.safeParse(req.params);
if (!parsed.success) {
throw new InputError(`Invalid service name: ${parsed.error.message}`);
}
const credentials = await httpAuth.credentials(req);
const principal = await httpAuth.getPrincipal(credentials);
if (!principal) {
res.status(401).json({
type: 'about:blank',
title: 'Unauthorized',
status: 401,
});
return;
}
logger.info(`status requested by ${principal.subject}`);
// In real life: query a DB. Here we mock the response.
res.json({
serviceName: parsed.data.serviceName,
status: 'healthy',
checkedBy: principal.subject,
});
});
router.use((err: Error, _req: any, res: any, _next: any) => {
if (err instanceof InputError) {
res.status(400).json({ type: 'about:blank', title: err.message, status: 400 });
} else if (err instanceof NotFoundError) {
res.status(404).json({ type: 'about:blank', title: err.message, status: 404 });
} else {
res.status(500).json({ type: 'about:blank', title: err.message, status: 500 });
}
});
return router;
}
|
¿Por qué Zod?
El :apéndice F repasa TypeScript básico si vienes de otro lenguaje. |
Middleware de autenticación
Cualquier endpoint serio necesita saber quién llama. Usamos HttpAuthService para extraer el Principal:
if (!principal) {
res.status(401).json({
type: 'about:blank',
title: 'Unauthorized',
status: 401,
});
return;
}
|
401 vs 403
Si tienes RBAC configurado (ver :cap-14), usa el |
Tests con supertest
Probar routers Express sin levantar un servidor real es trivial con supertest:
import express from 'express';
import request from 'supertest';
import { createRouter } from '../router';
const fakeHttpAuth = {
credentials: jest.fn().mockResolvedValue({ principal: { subject: 'user:default/tester' } }),
getPrincipal: jest.fn().mockResolvedValue({ subject: 'user:default/tester' }),
};
const fakeLogger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
child: jest.fn().mockReturnThis(),
};
describe('sazon-status router', () => {
let app: express.Express;
beforeEach(async () => {
app = express();
app.use(await createRouter({
httpAuth: fakeHttpAuth as any,
logger: fakeLogger as any,
}));
});
it('GET /health returns ok', async () => {
const res = await request(app).get('/health');
expect(res.status).toBe(200);
expect(res.body).toEqual({ status: 'ok' });
});
it('GET /status/:serviceName returns 200 with principal', async () => {
const res = await request(app).get('/status/sazon-api');
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
serviceName: 'sazon-api',
status: 'healthy',
checkedBy: 'user:default/tester',
});
});
it('GET /status/ with empty name returns 400', async () => {
const res = await request(app).get('/status/');
expect([400, 404]).toContain(res.status);
});
});
|
Fakes vs mocks
En el test usamos fakes (objetos simples que cumplen la interfaz) en lugar de mocks con |
Registrar el plugin en el backend
Una vez escrito, lo registramos en el index.ts:
backend.add(import('@sazon/plugin-sazon-status-backend'));
-
Crea el paquete
plugins/sazon-status-backendconcreate-backend-plugin. -
Define
createRouter()con Express + Zod. -
Aplica
HttpAuthServicea los endpoints sensibles. -
Escribe tests con supertest y fakes.
-
Registra con
backend.add(import('@sazon/plugin-sazon-status-backend')).
Resumen
-
Un plugin backend =
createBackendPlugin()+createRouter()conHttpRouterService. -
Zod valida entradas;
InputErrorse traduce a 400 vía middleware. -
HttpAuthService.credentials()+getPrincipal()da la identidad del cliente. -
supertestpermite testear routers sin servidor real.
Glosario del capítulo
- Zod
-
Librería de validación de esquemas para TypeScript con inferencia de tipos.
- Principal
-
Objeto que representa al usuario autenticado (subject, type).
- supertest
-
Librería para testear servidores HTTP sin levantarlos (fake app).
- fake
-
Implementación ligera que cumple un contrato, usada en tests.
Próximo capítulo
Persistencia, eventos y auth flow en el backend —de HTTP plano a Knex, EventsService y protección de endpoints con HttpAuthService.