← Volver al blog

Dentro de la arquitectura Rasepi: Plugins, Action Guards y Pipelines

Un profundo recorrido técnico sobre el funcionamiento real del sistema de plugins de Rasepi, el canal de protección de acciones y el motor de traducción a nivel de bloque, con código real de la base de código.

Dentro de la arquitectura Rasepi: Plugins, Action Guards y Pipelines

La mayoría de las plataformas de documentación hablan de "extensibilidad" como las aerolíneas hablan de "espacio para las piernas": técnicamente presente, prácticamente decepcionante. Queríamos que la arquitectura de Rasepi fuera realmente extensible sin volverse impredecible, así que construimos tres sistemas interconectados: plugins para la capacidad, guardias de acción para el control, y pipelines para la ejecución determinista.

Este artículo explica cómo funciona cada uno de ellos en nuestra base de código real.

Arquitectura Rasepi: Plugins, Guardas y Pipelines trabajando juntos](/es/blog/img/architecture-pipeline.svg)

El sistema de plugins: modular por diseño

Cada plugin en Rasepi implementa IPluginModule - una única interfaz que declara qué es el plugin, qué servicios necesita y qué rutas expone:

public interface IPluginModule
{
    PluginManifest Manifest { get; }
    void RegisterServices(IServiceCollection services);
    void MapRoutes(IEndpointRouteBuilder routes);
}

El PluginManifest es puro dato. Describe el plugin sin ejecutar nada:

public sealed class PluginManifest
{
    public required string Id { get; init; }
    public required string Name { get; init; }
    public required string Version { get; init; }
    public string Description { get; init; }
    public string Category { get; init; }
    public IReadOnlyDictionary<string, string> UiContributions { get; init; }
    public bool HasSettings { get; init; }
    public bool HasEndpoints { get; init; }
    public IReadOnlyList<string> Dependencies { get; init; }
}

Fíjate en UiContributions - ese diccionario mapea los puntos de extensión del frontend a los nombres de los componentes, para que el frontend Vue sepa qué componentes UI aporta cada plugin (un botón de la barra de herramientas, un panel de la barra lateral, una página de configuración).

El registro es una línea por plugin

Al inicio, registramos los plugins a través de una API fluida:

var pluginRegistry = new PluginRegistry();

pluginRegistry
    .AddPlugin<WorkflowPluginModule>(builder.Services)
    .AddPlugin<RulesPluginModule>(builder.Services)
    .AddPlugin<RetentionPluginModule>(builder.Services)
    .AddPlugin<ClassificationPluginModule>(builder.Services);

Cada llamada instanciará el módulo, lo almacenará en el registro y llamará a RegisterServices() para conectar sus dependencias. Después de que la aplicación se compila, una sola línea asigna todas las rutas de los plugins:

app.MapPluginRoutes(pluginRegistry);

Bajo el capó, cada plugin obtiene un grupo de rutas en /api/plugins/{pluginId}/ con autorización aplicada automáticamente.

Ejemplo real: el plugin Workflow

Este es el aspecto de un plugin real: el módulo Workflow & Approvals:

public sealed class WorkflowPluginModule : IPluginModule
{
    public const string PluginId = "workflow";

    public PluginManifest Manifest { get; } = new()
    {
        Id = PluginId,
        Name = "Workflow & Approvals",
        Version = "1.0.0",
        Description = "Adds approval workflows to entry publishing.",
        Category = "Workflow",
        HasSettings = true,
        HasEndpoints = true,
        UiContributions = new Dictionary<string, string>
        {
            ["entry.toolbar.publish"] = "WorkflowPublishButton",
            ["entry.sidebar.status"]  = "WorkflowStatusPanel",
            ["hub.admin.settings"]    = "WorkflowHubSettings",
        }
    };

    public void RegisterServices(IServiceCollection services)
    {
        services.AddScoped<IWorkflowService, WorkflowService>();
        services.AddScoped<IActionGuard, WorkflowPublishGuard>();
    }

    public void MapRoutes(IEndpointRouteBuilder routes)
    {
        WorkflowEndpoints.Map(routes);
    }
}

La plataforma central nunca hace referencia a WorkflowService o WorkflowPublishGuard directamente. Los descubre a través del contenedor DI. Esta es la clave del acoplamiento cero: la aplicación principal nunca toca el código de los plugins.

Guardias de acción: la capa de control

Los plugins añaden capacidades. Los guardianes de acción deciden si esa capacidad - o cualquier acción del núcleo - puede llevarse a cabo. Son validadores síncronos que interceptan las operaciones antes de su ejecución.

Flujo de evaluación de guardias de acción](/es/blog/img/action-guard-flow.svg)

La interfaz es deliberadamente mínima:

public interface IActionGuard
{
    string PluginId { get; }
    string? ActionName { get; }  // null means guard ALL actions

    Task<ActionGuardResult> EvaluateAsync(
        ActionGuardContext context,
        IServiceProvider services,
        CancellationToken ct = default);
}

Cuando ActionName es null, la guardia se ejecuta para cada acción. Cuando se establece en algo como "Entry.Publish", sólo intercepta esa acción específica.

Los contratos de contexto y resultado

Cada guardia recibe un contexto tipado con el nombre de la acción, el inquilino, el usuario, la entidad y una bolsa de propiedades:

public sealed record ActionGuardContext(
    string ActionName,
    Guid TenantId,
    Guid UserId,
    Guid EntityId,
    IReadOnlyDictionary<string, object?> Properties)
{
    public T? Get<T>(string key) =>
        Properties.TryGetValue(key, out var v) && v is T typed
            ? typed : default;
}

Y cada guardia devuelve un resultado predecible: permitir, denegar o permitir-con-modificaciones:

public sealed record ActionGuardResult
{
    public bool IsAllowed { get; init; }
    public string? ReasonCode { get; init; }
    public string? Message { get; init; }
    public IReadOnlyDictionary<string, object?>? Modifications { get; init; }

    public static ActionGuardResult Allow() =>
        new() { IsAllowed = true };

    public static ActionGuardResult Deny(
        string reasonCode, string message) =>
        new() { IsAllowed = false, ReasonCode = reasonCode, Message = message };
}

El campo Modifications es importante: un guardia puede aprobar una acción pero reescribir parte del contenido (por ejemplo, redactar secretos antes de publicarlos).

Nombres canónicos de las acciones

Definimos todas las acciones interceptables como constantes de cadena para que no haya ambigüedad sobre a qué puede apuntar un guardia:

public static class ActionNames
{
    public static class Entry
    {
        public const string Create  = "Entry.Create";
        public const string Save    = "Entry.Save";
        public const string Publish = "Entry.Publish";
        public const string Delete  = "Entry.Delete";
        public const string Archive = "Entry.Archive";
        public const string Renew   = "Entry.Renew";
    }

    public static class Hub
    {
        public const string Create = "Hub.Create";
        public const string Delete = "Hub.Delete";
        public const string TransferOwnership = "Hub.TransferOwnership";
    }

    public static class Translation
    {
        public const string Create  = "Translation.Create";
        public const string Publish = "Translation.Publish";
    }
}

Ejemplo real: bloquear la publicación sin aprobación

El plugin Workflow registra una guardia que intercepta Entry.Publish:

public sealed class WorkflowPublishGuard : IActionGuard
{
    public string PluginId => WorkflowPluginModule.PluginId;
    public string? ActionName => ActionNames.Entry.Publish;

    public async Task<ActionGuardResult> EvaluateAsync(
        ActionGuardContext context,
        IServiceProvider services,
        CancellationToken ct = default)
    {
        var db = services.GetRequiredService<RasepiDbContext>();
        var entry = await db.Entries
            .AsNoTracking()
            .FirstOrDefaultAsync(e => e.Id == context.EntityId, ct);

        if (entry is null)
            return ActionGuardResult.Allow();

        var workflowService = services.GetRequiredService<IWorkflowService>();
        var check = await workflowService
            .CheckPublishAllowedAsync(entry.Id, entry.HubId);

        if (check.IsAllowed)
            return ActionGuardResult.Allow();

        return ActionGuardResult.Deny(
            "workflow.approval_required",
            check.Message ?? "Approval required before publishing.");
    }
}

La plataforma central no sabe nada acerca de los flujos de trabajo de aprobación. Sólo llama a Entry.Publish a través de la tubería, y la guardia lo bloquea si el flujo de trabajo no se ha completado.

El canal de acción: donde todo converge

El ActionPipeline es la única ruta de ejecución para todas las operaciones protegidas. Resuelve qué guardias se aplican, las evalúa y bloquea o ejecuta la acción.

BLOQUEO DE CÓDIGO 10

El método EvaluateAsync hace el trabajo pesado:

public async Task<ActionPipelineResult> EvaluateAsync(
    string actionName,
    ActionGuardContext context,
    CancellationToken ct = default)
{
    // 1. Which plugins are enabled for this tenant?
    var enabledPlugins = await _resolver.GetEnabledPluginIdsAsync();

    // 2. Which guards match this action?
    var applicable = _guards
        .Where(g => enabledPlugins.Contains(g.PluginId))
        .Where(g => g.ActionName == null || g.ActionName == actionName)
        .ToList();

    // 3. Evaluate each guard
    var denials = new List<ActionGuardResult>();
    var modifications = new List<ActionGuardResult>();

    foreach (var guard in applicable)
    {
        try
        {
            var guardResult = await guard.EvaluateAsync(context, _services, ct);
            if (!guardResult.IsAllowed)
                denials.Add(guardResult);
            else if (guardResult.Modifications?.Count > 0)
                modifications.Add(guardResult);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Guard threw. Treating as Allow.");
        }
    }

    // 4. Any denial blocks the whole action
    if (denials.Count > 0)
        return ActionPipelineResult.Blocked(denials);

    return modifications.Count > 0
        ? ActionPipelineResult.Allowed(modifications)
        : ActionPipelineResult.Allowed();
}

Tres importantes decisiones de diseño aquí:

  1. Resolución por inquilino - el TenantPluginResolver comprueba qué plugins tiene instalados y habilitados cada inquilino. Una guardia para un plugin deshabilitado nunca se ejecuta.
    1. Todos deben pasar - si cualquier guardia deniega, la acción se bloquea. Esta es una postura de seguridad deliberada.
  2. Guard errors fail open - si una guardia lanza una excepción, se registra y se trata como Allow(). Esto evita que un plugin roto bloquee toda la plataforma.

Resolución de plugins por inquilino

El solucionador consulta la tabla TenantPluginInstallations (asignada automáticamente al inquilino actual por los filtros de consulta globales de EF):

public sealed class TenantPluginResolver : ITenantPluginResolver
{
    public async Task<IReadOnlySet<string>> GetEnabledPluginIdsAsync(
        CancellationToken ct = default)
    {
        if (_cache is not null) return _cache;

        var ids = await _db.TenantPluginInstallations
            .Where(i => i.IsEnabled)
            .Select(i => i.PluginId)
            .ToListAsync(ct);

        _cache = ids.ToHashSet();
        return _cache;
    }
}

Efectos secundarios basados en eventos

Las acciones son síncronas. Los efectos secundarios no lo son. Cuando una acción finaliza, el servicio publica un evento de dominio:

await _eventPublisher.PublishAsync(
    EventNames.Entry.Created, entry.Id, new { entry.OriginalLanguage });

Los eventos se ponen en cola en un canal en memoria y son procesados por un EventConsumerWorker en segundo plano. El trabajador enruta los eventos a múltiples sistemas:

  • Seguimiento de la actividad**: registra quién hizo qué y cuándo.
  • Facturación de traducciones**: seguimiento de los costes por proveedor.
  • Cualquier plugin puede suscribirse a los eventos del dominio.

Los controladores de eventos de los plugins implementan IPluginEventHandler:

public interface IPluginEventHandler
{
    string PluginId { get; }
    IReadOnlyList<string> SubscribedEvents { get; }

    Task HandleAsync(
        string eventName, Guid entityId,
        Guid? tenantId, Guid? userId,
        string payloadJson, IServiceProvider services,
        CancellationToken ct = default);
}

El trabajador sólo invoca manejadores cuyo plugin está habilitado para el tenant. Esto significa que los efectos secundarios del plugin A nunca se filtran en un tenant que sólo tiene instalado el plugin B.

El motor de traducción a nivel de bloque

Aquí es donde la arquitectura es más visible.

Traducción por bloques: sólo se retraducen los bloques modificados](/es/blog/img/block-translation.svg)

Las plataformas tradicionales traducen documentos enteros. Nosotros traducimos bloques individuales: párrafos, títulos, elementos de listas. Cuando un usuario edita un párrafo de un documento de 50 bloques, sólo hay que volver a traducir ese párrafo. De ahí nuestro ahorro del 94%.

Cómo se crean los bloques a partir de TipTap JSON

Cuando un usuario guarda un documento, el editor TipTap envía JSON como este:

{
  "type": "doc",
  "content": [
    {
      "type": "paragraph",
      "attrs": { "blockId": "a1b2c3d4-..." },
      "content": [{ "type": "text", "text": "Hello world" }]
    }
  ]
}

El BlockTranslationService analiza este JSON y crea registros EntryBlock individuales:

public async Task<List<EntryBlock>> CreateBlocksFromDocumentAsync(
    Guid entryId, string language, string contentJson,
    int version, Guid userId)
{
    var doc = JsonDocument.Parse(contentJson);
    var content = doc.RootElement.GetProperty("content");

    int position = 0;
    foreach (var node in content.EnumerateArray())
    {
        var blockType = node.GetProperty("type").GetString();
        var blockJson = JsonSerializer.Serialize(node);

        // Strip metadata attrs before hashing
        var hashInput = StripBlockMetaAttrs(blockJson);

        var block = new EntryBlock
        {
            Id = ExtractOrGenerateBlockId(node),
            EntryId = entryId,
            Language = language,
            Position = position++,
            BlockType = blockType,
            ContentJson = blockJson,
            ContentHash = CalculateContentHash(hashInput),
            IsNoTranslate = ExtractNoTranslateFlag(node),
            Version = version,
        };

        _context.EntryBlocks.Add(block);
    }

    await _context.SaveChangesAsync();
    return blocks;
}

hash SHA256 para detección de caducidad

El hash del contenido es el núcleo de la detección de caducidad. Hacemos un hash del contenido del bloque (después de eliminar atributos de metadatos como blockId y deleted) utilizando SHA256:

private string CalculateContentHash(string content)
{
    using var sha256 = SHA256.Create();
    var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(content));
    return Convert.ToHexString(hashBytes);
}

Cuando cambia un bloque fuente, cambia su hash. A continuación, el sistema compara el SourceContentHash de cada bloque de traducción con el hash de origen actual: las discrepancias se marcan con Stale:

public async Task MarkTranslationsAsStaleAsync(List<Guid> changedBlockIds)
{
    var affected = await _context.TranslationBlocks
        .Where(t => changedBlockIds.Contains(t.SourceBlockId))
        .ToListAsync();

    foreach (var translation in affected)
    {
        translation.Status = TranslationStatus.Stale;
        translation.UpdatedAt = DateTime.UtcNow;
    }

    await _context.SaveChangesAsync();
}

Adaptación de la estructura

Los traductores pueden cambiar los tipos de bloque de un idioma a otro. Una lista con viñetas en inglés puede convertirse en una lista numerada en alemán: una preferencia cultural. El sistema lo tiene en cuenta:

var translation = new TranslationBlock
{
    SourceBlockId = sourceBlockId,
    Language = targetLanguage,
    BlockType = translatedBlockType,
    SourceBlockType = sourceBlock.BlockType,
    IsStructureAdapted = translatedBlockType != sourceBlock.BlockType,
    SourceContentHash = sourceBlock.ContentHash,
    Status = TranslationStatus.UpToDate,
};

Proveedores de traducción como plugins

Los servicios de traducción externos (DeepL, Google Translate, etc.) se conectan a través de ITranslationProviderPlugin:

public interface ITranslationProviderPlugin : IRasepiPlugin
{
    string[] GetSupportedLanguages();

    Task<string> TranslateAsync(
        string text, string sourceLanguage, string targetLanguage);

    Task<TranslationBatchResult> TranslateBatchAsync(
        Dictionary<string, string> texts,
        string sourceLanguage, string targetLanguage);
}

El método por lotes recibe un diccionario de identificadores de bloque a contenido, los traduce todos y devuelve las traducciones con un recuento de caracteres facturados. Dado que sólo enviamos bloques antiguos, y no todo el documento, los costes se mantienen al mínimo.

Aislamiento del inquilino: la red de seguridad invisible

Todos los sistemas descritos anteriormente se ejecutan dentro de un estricto aislamiento de inquilinos.

El TenantContextMiddleware resuelve el inquilino a partir del JWT en cada solicitud y verifica la pertenencia:

public async Task InvokeAsync(
    HttpContext context, TenantContext tenantContext, RasepiDbContext db)
{
    var tenantIdClaim = context.User.FindFirstValue("tenant_id");
    var userIdClaim = context.User.FindFirstValue(ClaimTypes.NameIdentifier);

    // Populate scoped context
    tenantContext.TenantId = Guid.Parse(tenantIdClaim);
    tenantContext.UserId = Guid.Parse(userIdClaim);

    // Verify membership — fail closed
    var membership = await db.TenantMemberships
        .Where(m => m.TenantId == tenantContext.TenantId
                  && m.UserId == tenantContext.UserId)
        .FirstOrDefaultAsync();

    if (membership == null)
    {
        context.Response.StatusCode = 401;
        return;  // No membership = no access
    }
}

Los filtros de consulta globales de Entity Framework garantizan que incluso si un desarrollador se olvida de filtrar por inquilino, la capa de base de datos lo hace automáticamente:

modelBuilder.Entity<Hub>()
    .HasQueryFilter(h => h.TenantId == _tenantContext.TenantId);

El resultado: db.Hubs.ToListAsync() siempre devuelve sólo los hubs del tenant actual. Las fugas de datos requieren eludir activamente el filtro de consulta, lo que está prohibido en nuestra base de código.

La imagen completa

Cuando un usuario hace clic en "Publicar" en una entrada, esto es lo que ocurre:

  1. La solicitud entra - la autenticación valida el JWT, TenantContextMiddleware resuelve y verifica el inquilino
  2. El controlador llama a la tubería - IActionPipeline.ExecuteAsync("Entry.Publish", context, action)
    1. La canalización resuelve las protecciones - consulta qué plugins ha habilitado el inquilino, selecciona las protecciones aplicables.
    1. Guardias evalúan - la guardia de flujo de trabajo comprueba las aprobaciones, la guardia de retención comprueba la política, la guardia de reglas valida el contenido.
  3. ¿Todo correcto? La acción se ejecuta - la entrada se publica
  4. **El evento Entry.Published se pone en cola.
    1. Procesos del trabajador en segundo plano - se registra la actividad, se actualiza la facturación de la traducción, se llama a los controladores de eventos del plugin
    1. Comprobación de traducciones de bloques - se identifican los bloques obsoletos para su retraducción.

Cada capa hace su trabajo. Ninguna capa afecta a otra. Así es la arquitectura.

No construimos esto porque la extensibilidad esté de moda. Lo construimos porque una plataforma de documentación que no puede adaptarse al flujo de trabajo de cada equipo será eventualmente reemplazada por una que pueda - y una plataforma que se adapta sin guardarraíles eventualmente romperá algo que importa.

Mantén tu documentación actualizada. Automáticamente.

Rasepi impone fechas de revisión, supervisa la salud del contenido y publica en más de 40 idiomas.

Empieza gratis →