Full-Stack Plugin Development
This guide walks through building a complete Rasepi plugin that spans both the .NET backend (action guards, API endpoints, event handlers) and the Vue 3 frontend (UI contribution slots, editor integration, event bus). For individual guides, see Backend Plugins and Frontend Plugins.
Overview
A full-stack Rasepi plugin consists of two tightly-linked halves:
Backend (.NET)
IPluginModule— entry point & manifestIActionGuard— intercept actionsIPluginEventHandler— react to events- Minimal API endpoints for plugin functionality
UiContributionsin manifest
Frontend (Vue 3)
- Vue components for each UI slot
- Pinia store integration (
usePluginsStore) - API calls to plugin endpoints
- Event bus for plugin-to-plugin messaging
- Dynamic component rendering via
<component :is>
The bridge between them is the UI Contribution Slots system: the backend manifest declares which slots the plugin occupies and which Vue component to render. The Pinia store fetches these declarations and the app renders them dynamically.
Architecture Diagram
┌─────────────────────────────────────────────────────────────┐
│ BACKEND (.NET) │
│ │
│ ┌─────────────┐ ┌───────────────┐ ┌────────────────┐ │
│ │ PluginModule │──▶│ PluginManifest│──▶│ UiContributions│ │
│ │ (entry point)│ │ (metadata) │ │ (slot → comp) │ │
│ └──────┬───────┘ └───────────────┘ └────────────────┘ │
│ │ │
│ ├──▶ RegisterServices() │
│ │ ├── IActionGuard implementations │
│ │ ├── IPluginEventHandler implementations │
│ │ └── Custom services (repos, clients) │
│ │ │
│ └──▶ MapRoutes() │
│ └── /api/plugins/{id}/... │
│ │
├──────────────────────────── API ─────────────────────────────┤
│ │
│ GET /api/plugins → Available plugins │
│ GET /api/plugins/{id}/ui-contribs → UI contributions │
│ POST /api/tenant/plugins/{id}/... → Install/enable/config │
│ * /api/plugins/{id}/* → Plugin custom routes │
│ │
├──────────────────────────────────────────────────────────────┤
│ FRONTEND (Vue 3) │
│ │
│ ┌──────────────────┐ ┌─────────────────┐ │
│ │ usePluginsStore │──▶│ loadUiContribs()│ │
│ │ (Pinia) │ └────────┬────────┘ │
│ └──────────────────┘ │ │
│ │ ▼ │
│ │ getContributionsForSlot('entry.toolbar │
│ │ .publish') │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────┐ ┌──────────────────────┐ │
│ │ PluginManager │ │ <component :is="..."> │ │
│ │ (singleton) │ │ (dynamic rendering) │ │
│ └─────────────────┘ └──────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────┘
Project Structure
Recommended file layout for a full-stack plugin. Backend code lives in the backend project; frontend components live alongside the app.
Rasepi/
├── backend/
│ └── Rasepi.Api/
│ └── Plugins/
│ └── MyPlugin/ ← Backend plugin folder
│ ├── MyPluginModule.cs ← IPluginModule entry point
│ ├── MyPublishGuard.cs ← IActionGuard
│ ├── MyEventHandler.cs ← IPluginEventHandler
│ ├── MyPluginEndpoints.cs ← Minimal API routes
│ ├── MyPluginService.cs ← Business logic
│ └── Models/
│ ├── MyConfig.cs
│ └── MyResponse.cs
│
└── frontend/
└── src/
└── components/
└── plugins/
└── my-plugin/ ← Frontend plugin folder
├── MyToolbarButton.vue ← UI slot component
├── MySidebarPanel.vue ← UI slot component
├── MySettingsPanel.vue ← Admin settings
└── composables/
└── useMyPlugin.ts ← API calls & state
Step 1: Define the Manifest
Start by defining your plugin module with a manifest that declares metadata and UI contributions. The UI contributions map tells the frontend which Vue components to render in which slots.
// backend/Rasepi.Api/Plugins/Approval/ApprovalPluginModule.cs
using Rasepi.Plugins.SDK;
namespace Rasepi.Api.Plugins.Approval;
public sealed class ApprovalPluginModule : IPluginModule
{
public const string PluginId = "approval";
public PluginManifest Manifest { get; } = new()
{
Id = PluginId,
Name = "Approval Workflow",
Version = "1.0.0",
Description = "Require manager approval before entries are published.",
Category = "Workflow",
HasSettings = true,
HasEndpoints = true,
// ← KEY: declare which Vue components go into which UI slots
UiContributions = new Dictionary<string, string>
{
["entry.toolbar.publish"] = "ApprovalPublishButton",
["entry.sidebar.status"] = "ApprovalStatusPanel",
["entry.approval.dialog"] = "ApprovalDialog",
["hub.admin.settings"] = "ApprovalHubSettings",
}.AsReadOnly(),
Dependencies = Array.Empty<string>(),
};
public void RegisterServices(IServiceCollection services)
{
services.AddScoped<IActionGuard, ApprovalPublishGuard>();
services.AddScoped<IPluginEventHandler, ApprovalEventHandler>();
services.AddScoped<IApprovalService, ApprovalService>();
}
public void MapRoutes(IEndpointRouteBuilder routes)
{
ApprovalEndpoints.Map(routes);
}
}
Step 2: Backend Action Guards
The guard checks whether the entry has been approved before allowing publication. If not, it returns a Deny result with a machine-readable reasonCode and a structured Metadata object that the frontend can use to show an approval dialog.
// backend/Rasepi.Api/Plugins/Approval/ApprovalPublishGuard.cs
using Rasepi.Plugins.SDK;
namespace Rasepi.Api.Plugins.Approval;
public sealed class ApprovalPublishGuard : IActionGuard
{
public string PluginId => ApprovalPluginModule.PluginId;
public string? ActionName => ActionNames.Entry.Publish;
public async Task<ActionGuardResult> EvaluateAsync(
ActionGuardContext context,
IServiceProvider services,
CancellationToken ct = default)
{
var approvalService = services.GetRequiredService<IApprovalService>();
var status = await approvalService.GetApprovalStatusAsync(
context.EntityId, ct);
if (status == ApprovalStatus.Approved)
return ActionGuardResult.Allow();
// Deny with metadata the frontend can use
return ActionGuardResult.Deny(
reasonCode: "approval.required",
message: "This entry requires approval before publishing.",
metadata: new Dictionary<string, object?>
{
["currentStatus"] = status.ToString(),
["requiredApprovers"] = await approvalService
.GetRequiredApproversAsync(context.EntityId, ct),
});
}
}
💡 Frontend picks up the denial
When the guard denies Entry.Publish, the API returns a 422 response. The frontend's publish handler reads the reasonCode and metadata to show the appropriate UI — in this case, an approval request dialog contributed by the entry.approval.dialog slot.
Step 3: API Endpoints
Custom endpoints for the approval workflow. The frontend calls these to request, approve, or reject approvals.
// backend/Rasepi.Api/Plugins/Approval/ApprovalEndpoints.cs
namespace Rasepi.Api.Plugins.Approval;
public static class ApprovalEndpoints
{
public static void Map(IEndpointRouteBuilder routes)
{
// GET /api/plugins/approval/entries/{entryId}/status
routes.MapGet("/entries/{entryId:guid}/status",
async (Guid entryId, IApprovalService svc) =>
{
var status = await svc.GetApprovalStatusAsync(entryId);
var approvers = await svc.GetRequiredApproversAsync(entryId);
return Results.Ok(new { status, approvers });
});
// POST /api/plugins/approval/entries/{entryId}/request
routes.MapPost("/entries/{entryId:guid}/request",
async (Guid entryId, ApprovalRequest req,
IApprovalService svc) =>
{
await svc.RequestApprovalAsync(entryId, req.ApproverIds);
return Results.Accepted();
});
// POST /api/plugins/approval/entries/{entryId}/approve
routes.MapPost("/entries/{entryId:guid}/approve",
async (Guid entryId, IApprovalService svc,
HttpContext ctx) =>
{
var userId = ctx.User.GetUserId();
await svc.ApproveAsync(entryId, userId);
return Results.Ok();
});
// POST /api/plugins/approval/entries/{entryId}/reject
routes.MapPost("/entries/{entryId:guid}/reject",
async (Guid entryId, RejectionRequest req,
IApprovalService svc, HttpContext ctx) =>
{
var userId = ctx.User.GetUserId();
await svc.RejectAsync(entryId, userId, req.Reason);
return Results.Ok();
});
}
}
public record ApprovalRequest(Guid[] ApproverIds);
public record RejectionRequest(string Reason);
Step 4: Register in Program.cs
// Program.cs
var pluginRegistry = new PluginRegistry();
builder.Services.AddSingleton<IPluginRegistry>(pluginRegistry);
// Register the approval plugin
pluginRegistry.AddPlugin<ApprovalPluginModule>(builder.Services);
// ... other plugins ...
var app = builder.Build();
// Map all plugin routes
app.MapPluginRoutes(pluginRegistry);
At this point, your backend is complete: guards are registered, endpoints are mapped, and the manifest's UiContributions will be served to the frontend via the plugins API.
Step 5: Wire Up UI Contribution Slots
The API now serves the plugin's UiContributions. The frontend loads them via the Pinia store and renders the components dynamically in the appropriate slots.
Backend Manifest: API Response:
UiContributions = { GET /api/plugins/approval/ui-contributions
"entry.toolbar.publish": → [
"ApprovalPublishButton", { slot: "entry.toolbar.publish",
"entry.sidebar.status": componentName: "ApprovalPublishButton",
"ApprovalStatusPanel", pluginId: "approval" },
"entry.approval.dialog": { slot: "entry.sidebar.status",
"ApprovalDialog", componentName: "ApprovalStatusPanel",
"hub.admin.settings": pluginId: "approval" },
"ApprovalHubSettings", ...
} ]
The host app has predefined slots where it renders contribution components:
<!-- In the entry editor view: EntryEditor.vue -->
<script setup lang="ts">
import { computed } from 'vue'
import { usePluginsStore } from '@/stores/plugins'
const store = usePluginsStore()
const props = defineProps<{ entryId: string }>()
// Load UI contributions on mount
onMounted(() => store.loadUiContributions())
const toolbarSlots = computed(() =>
store.getContributionsForSlot('entry.toolbar.publish'))
const sidebarSlots = computed(() =>
store.getContributionsForSlot('entry.sidebar.status'))
</script>
<template>
<div class="entry-editor">
<!-- Toolbar with plugin contributions -->
<div class="toolbar">
<v-btn @click="save">Save</v-btn>
<v-btn @click="publish">Publish</v-btn>
<!-- Plugin-contributed toolbar buttons -->
<component
v-for="c in toolbarSlots"
:key="c.pluginId"
:is="c.componentName"
:entry-id="props.entryId"
/>
</div>
<!-- Editor + Sidebar -->
<div class="editor-layout">
<div class="editor"><!-- TipTap --></div>
<aside class="sidebar">
<!-- Plugin-contributed sidebar panels -->
<component
v-for="c in sidebarSlots"
:key="c.pluginId"
:is="c.componentName"
:entry-id="props.entryId"
/>
</aside>
</div>
</div>
</template>
Step 6: Build Frontend Components
Create the Vue components that your manifest declared in UiContributions.
ApprovalPublishButton.vue
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useApproval } from './composables/useApproval'
const props = defineProps<{ entryId: string }>()
const { status, requestApproval, loadStatus } = useApproval(props.entryId)
const showDialog = ref(false)
onMounted(loadStatus)
</script>
<template>
<v-btn
v-if="status !== 'Approved'"
color="warning"
variant="outlined"
@click="showDialog = true"
>
<v-icon start>mdi-check-decagram</v-icon>
Request Approval
</v-btn>
<v-chip v-else color="success" variant="flat">
<v-icon start>mdi-check-circle</v-icon>
Approved
</v-chip>
</template>
ApprovalStatusPanel.vue
<script setup lang="ts">
import { onMounted } from 'vue'
import { useApproval } from './composables/useApproval'
const props = defineProps<{ entryId: string }>()
const { status, approvers, loadStatus } = useApproval(props.entryId)
onMounted(loadStatus)
</script>
<template>
<v-card variant="outlined" class="pa-4">
<v-card-title class="text-subtitle-2">Approval Status</v-card-title>
<v-chip :color="status === 'Approved' ? 'success' : 'warning'">
{{ status }}
</v-chip>
<v-list v-if="approvers.length" density="compact" class="mt-2">
<v-list-item v-for="a in approvers" :key="a.id">
<template #prepend>
<v-icon>mdi-account-check</v-icon>
</template>
{{ a.name }}
</v-list-item>
</v-list>
</v-card>
</template>
Composable: useApproval.ts
// frontend/src/components/plugins/approval/composables/useApproval.ts
import { ref } from 'vue'
import { api } from '@/api'
export function useApproval(entryId: string) {
const status = ref('Pending')
const approvers = ref<{ id: string; name: string }[]>([])
async function loadStatus() {
const res = await api.get(`/api/plugins/approval/entries/${entryId}/status`)
status.value = res.data.status
approvers.value = res.data.approvers
}
async function requestApproval(approverIds: string[]) {
await api.post(`/api/plugins/approval/entries/${entryId}/request`, {
approverIds,
})
await loadStatus()
}
async function approve() {
await api.post(`/api/plugins/approval/entries/${entryId}/approve`)
await loadStatus()
}
async function reject(reason: string) {
await api.post(`/api/plugins/approval/entries/${entryId}/reject`, {
reason,
})
await loadStatus()
}
return { status, approvers, loadStatus, requestApproval, approve, reject }
}
Step 7: Plugin Store Integration
The usePluginsStore handles the lifecycle: install → enable → load UI contributions. No extra wiring needed — the store automatically discovers your plugin's components via the API.
// In the app's initialization (App.vue or router guard)
import { usePluginsStore } from '@/stores/plugins'
const plugins = usePluginsStore()
// These calls populate the store from the backend
await plugins.loadAvailablePlugins() // GETs /api/plugins
await plugins.loadInstallations() // GETs /api/tenant/plugins
await plugins.loadUiContributions() // Merges manifest UI contributions
// Now any view can query slots:
// plugins.getContributionsForSlot('entry.toolbar.publish')
// → [{ pluginId: 'approval', slot: '...', componentName: 'ApprovalPublishButton' }]
Per-Tenant Settings
If your manifest has HasSettings = true, tenants can configure your plugin via the admin UI. Settings are stored as JSON and accessed through the installations API.
// Save settings
PUT /api/tenant/plugins/approval/settings
{
"requireMinApprovers": 2,
"autoApproveOwners": true,
"notifyOnRequest": true
}
// Read settings (included in installation response)
GET /api/tenant/plugins
→ [{
pluginId: "approval",
isEnabled: true,
settingsJson: "{\"requireMinApprovers\":2,...}"
}]
// Access settings in a guard or service
public async Task<ActionGuardResult> EvaluateAsync(
ActionGuardContext context, IServiceProvider services, CancellationToken ct)
{
var resolver = services.GetRequiredService<ITenantPluginResolver>();
var settings = await resolver.GetSettingsAsync<ApprovalSettings>(
context.TenantId, ApprovalPluginModule.PluginId, ct);
if (settings.AutoApproveOwners && context.UserId == ownerUserId)
return ActionGuardResult.Allow();
// ... normal approval logic
}
End-to-End Event Flow
Here's how events flow through a full-stack plugin when a user clicks "Publish" on an entry that requires approval:
[User clicks Publish in editor]
│
▼
Frontend: POST /api/entries/{id}/publish
│
▼
Backend: EntryService.PublishAsync()
│
▼
Backend: ActionPipeline.ExecuteAsync("Entry.Publish")
│
├─ ApprovalPublishGuard.EvaluateAsync()
│ └─ Status ≠ Approved → Deny(reasonCode: "approval.required")
│
▼
Backend: Returns 422 { reasonCode, message, metadata }
│
▼
Frontend: Axios catch → reads reasonCode === "approval.required"
│
▼
Frontend: Shows <ApprovalDialog> component (from entry.approval.dialog slot)
│
▼
[User selects approvers, clicks "Request Approval"]
│
▼
Frontend: POST /api/plugins/approval/entries/{id}/request
│
▼
Backend: ApprovalService.RequestApprovalAsync()
│
├─ Stores approval request
└─ EventHandler fires "Approval.Requested" event
└─ Sends email/Slack notifications
│
▼
[Approver approves: POST /api/plugins/approval/entries/{id}/approve]
│
▼
Backend: ApprovalService.ApproveAsync() → status = Approved
│
▼
[User retries Publish]
│
▼
Backend: ApprovalPublishGuard → Allow()
│
▼
Entry is published! ✓
Testing Full-Stack Plugins
Backend Unit Tests
[Fact]
public async Task Guard_denies_unapproved_publish()
{
var mockService = new Mock<IApprovalService>();
mockService.Setup(s => s.GetApprovalStatusAsync(It.IsAny<Guid>(), default))
.ReturnsAsync(ApprovalStatus.Pending);
var sp = new Mock<IServiceProvider>();
sp.Setup(s => s.GetService(typeof(IApprovalService)))
.Returns(mockService.Object);
var guard = new ApprovalPublishGuard();
var context = new ActionGuardContext(
ActionName: ActionNames.Entry.Publish,
TenantId: Guid.NewGuid(), UserId: Guid.NewGuid(),
EntityId: Guid.NewGuid(), Properties: new());
var result = await guard.EvaluateAsync(context, sp.Object);
Assert.False(result.IsAllowed);
Assert.Equal("approval.required", result.ReasonCode);
}
Frontend Component Tests
// __tests__/ApprovalPublishButton.spec.ts
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import ApprovalPublishButton from '../ApprovalPublishButton.vue'
describe('ApprovalPublishButton', () => {
it('shows "Request Approval" when status is Pending', async () => {
const wrapper = mount(ApprovalPublishButton, {
props: { entryId: 'test-id' },
global: {
plugins: [createTestingPinia()],
},
})
// Mock the composable response
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Request Approval')
})
it('shows "Approved" chip when status is Approved', async () => {
// ... mock approval status as Approved
expect(wrapper.text()).toContain('Approved')
})
})
Production Checklist
Before shipping a full-stack plugin, verify each of these items.
| Area | Item | Details |
|---|---|---|
| Manifest | Unique Id | Kebab-case, globally unique across all plugins |
| Manifest | SemVer version | Follow semantic versioning for updates |
| Manifest | Dependencies declared | If relying on other plugins, list in Dependencies |
| Guards | Pure evaluators | No state mutation; no external write calls |
| Guards | Fail-safe | If your guard throws, pipeline treats as Allow |
| Endpoints | Authorization | All routes auto-require auth; add extra policies for admin-only routes |
| Endpoints | Input validation | Validate request bodies; use record types with constraints |
| Tenant | Scoped correctly | All data must be queried within tenant scope; never leak cross-tenant |
| UI Slots | All components exist | Every UiContributions entry must have a registered Vue component |
| UI Slots | Graceful degradation | If the component fails to load, it shouldn't break the page |
| Frontend | i18n | All user-facing strings use $t(), keys added to locale files |
| Frontend | Responsive | Components render correctly on mobile |
| Tests | Guard unit tests | Cover allow, deny, and modification paths |
| Tests | Endpoint integration tests | Test with InMemory DB and fake tenant context |
| Tests | Component tests | Mount components with testing pinia and mock API |