← Volver al blog

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

Un recorrido técnico en profundidad sobre el funcionamiento real del sistema de plugins de Rasepi, la canalización de la guardia de acción y el motor de traducción a nivel de bloque, con código real de la base de código.

Bajo el capó
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. Yo quería 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, guardias y tuberías 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íjese en UiContributions. Ese diccionario mapea puntos de extensión del frontend a nombres de componentes, de modo que el frontend Vue sabe qué componentes de interfaz de usuario 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 instancia el módulo, lo almacena en el registro y llama a RegisterServices() para cablear sus dependencias. Tras la compilación de la aplicación, una única línea asigna todas las rutas de los plugins:

app.MapPluginRoutes(pluginRegistry);

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

Ejemplo real: el plugin Workflow

He aquí 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. Esa es la clave del acoplamiento cero. La aplicación core nunca toca el código de los plugins.

Guardias de acción: la capa de control

Los plugins añaden capacidad. Los guardianes de acción deciden si esa capacidad, o cualquier acción del núcleo, tiene permiso para proceder. 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 tipificado con el nombre de la acción, el arrendatario, 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 complemento de flujo de trabajo registra un 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. Simplemente llama a Entry.Publish a través de la tubería, y el guardia lo bloquea si el flujo de trabajo no se ha completado.

La canalización de acciones: 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.

public sealed class ActionPipeline : IActionPipeline
{
    public async Task<ActionPipelineResult> ExecuteAsync(
        string actionName,
        ActionGuardContext context,
        Func<Task> action,
        CancellationToken ct = default)
    {
        var result = await EvaluateAsync(actionName, context, ct);
        if (!result.IsAllowed) return result;

        await action();  // All guards passed — execute

        return result;   // Return modifications for caller
    }
}

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.
  2. Todo-debe-pasar. Si alguna guarda deniega, la acción se bloquea. Esta es una postura de seguridad deliberada.
  3. Guard errors fail open. Si un guard 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

La resolución consulta la tabla TenantPluginInstallations (automáticamente delimitada 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. Una vez completada una acción, 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é, cuándo
  • Facturación de traducciones. Rastrea los costes por proveedor
  • Cualquier plugin puede suscribirse a los eventos del dominio.

Los manejadores de eventos del plugin 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 a un tenant que sólo tenga instalado el plugin B.

El motor de traducción a nivel de bloque

Aquí es donde la arquitectura da sus frutos de forma más visible.

Traducción a nivel de bloque: 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, encabezados, elementos de listas. Cuando un usuario edita un párrafo en un documento de 50 bloques, sólo es necesario volver a traducir ese párrafo. Ésa es la fuente de nuestro 94% de ahorro de costes.

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

Cuando un usuario guarda un documento, el editor TipTap envía JSON de la siguiente forma:

{
  "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 los 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. El sistema compara entonces el SourceContentHash de cada bloque de traducción con el hash de origen actual, y las discrepancias se marcan como 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 hace un seguimiento de esto:

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. Como sólo enviamos los bloques antiguos, no todo el documento, los costes se mantienen al mínimo.

Aislamiento del inquilino: la red de seguridad invisible

Todos los sistemas descritos anteriormente funcionan 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 haga automáticamente:

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

El resultado: db.Hubs.ToListAsync() siempre devuelve sólo los centros del inquilino 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 arrendatario.
  2. El controlador llama a la tubería. IActionPipeline.ExecuteAsync("Entry.Publish", context, action)
  3. Pipeline resuelve guardias. Consulta qué plugins tiene habilitados el inquilino, selecciona las guardias aplicables.
  4. Las guardias evalúan. La guardia Workflow comprueba las aprobaciones, la guardia Retention comprueba la política, la guardia Rules valida el contenido. ¿Todos pasan? La entrada se publica.
  5. **Se pone en cola el evento Entry.Published. Un trabajador en segundo plano registra la actividad, actualiza la facturación de la traducción y llama a los controladores de eventos del plugin.
  6. **Se identifican los bloques obsoletos para su retraducción.

Cada capa hace su trabajo. Ninguna capa se mete en otra. Esa es la arquitectura.

No construimos esto porque la extensibilidad esté de moda. Lo construimos porque una plataforma de documentación que no pueda adaptarse al flujo de trabajo de cada equipo acabará siendo sustituida por otra que sí pueda hacerlo. Y una plataforma que se adapta sin guardarraíles acabará rompiendo 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 →