Escribe tu primer plugin backend

Objetivos del capítulo
  • Crear un plugin backend con createBackendPlugin.

  • Exponer endpoints HTTP con Express y validar la entrada con Zod.

  • Aplicar middleware de autenticación con HttpAuthService.

  • Testear el plugin con supertest.

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
  • createBackendPlugin({ id, register }) — la unidad registrada.

  • env.registerInit({ deps, init }) — declaras los coreServices que necesitas.

  • httpRouter.use(await createRouter(…​)) — montas el router Express en el HttpRouterService compartido.

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?
  • Inmutable, declarativo, inferencia de tipos TS.

  • Genera mensajes de error legibles (input.path: message).

  • Vive con el código del plugin, no en un esquema externo.

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
  • 401 Unauthorized: el cliente no se autenticó (no hay token).

  • 403 Forbidden: el cliente se autenticó pero no tiene permiso (RBAC).

Si tienes RBAC configurado (ver :cap-14), usa el PermissionsService en lugar del chequeo manual.

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 jest.fn(). Un fake es más legible y refleja el contrato real. Los mocks se reservan para verificar interacciones.

Registrar el plugin en el backend

Una vez escrito, lo registramos en el index.ts:

backend.add(import('@sazon/plugin-sazon-status-backend'));
Example 6. Receta del capítulo
  1. Crea el paquete plugins/sazon-status-backend con create-backend-plugin.

  2. Define createRouter() con Express + Zod.

  3. Aplica HttpAuthService a los endpoints sensibles.

  4. Escribe tests con supertest y fakes.

  5. Registra con backend.add(import('@sazon/plugin-sazon-status-backend')).

Resumen

  • Un plugin backend = createBackendPlugin() + createRouter() con HttpRouterService.

  • Zod valida entradas; InputError se traduce a 400 vía middleware.

  • HttpAuthService.credentials() + getPrincipal() da la identidad del cliente.

  • supertest permite 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.