diff --git a/skills/firely-server-plugin/SKILL.md b/skills/firely-server-plugin/SKILL.md new file mode 100644 index 0000000..23b6cfe --- /dev/null +++ b/skills/firely-server-plugin/SKILL.md @@ -0,0 +1,667 @@ +--- +name: firely-server-plugin +description: Firely Server plugin developer. Use when building, debugging, or reviewing a custom plugin for Firely Server (formerly Vonk): custom FHIR operations, middleware, or capability statement extensions. Covers project setup, the configuration/service/contributor pattern, IVonkContext pipeline wiring, and xUnit testing with VonkTestContext. Does NOT cover repository facades — those follow a different pattern and belong in a dedicated skill. +--- + +# Firely Server Plugin Skill + +Expert guidance for building custom plugins for [Firely Server](https://docs.fire.ly/projects/Firely-Server/en/latest/). This skill sets up projects correctly, implements the configuration/service/contributor pattern, and writes testable plugin code that integrates cleanly with the server pipeline. + +> **Authoritative source:** Always refer to the [Firely Server Programming API Reference](https://docs.fire.ly/projects/Firely-Server/en/latest/reference/reference.html) for interface details. Do not invent interface members, method signatures, or NuGet package names — look them up when uncertain. + +--- + +## What is a Firely Server Plugin? + +A plugin is a .NET class library that Firely Server loads at startup. It registers services into the ASP.NET Core DI container and hooks into the request processing pipeline. Plugins can: + +- Add custom FHIR operations (e.g. `$my-operation`) +- Intercept any interaction with pre- and post-handlers +- Expose non-FHIR HTTP endpoints via raw ASP.NET Core middleware +- Extend the server's CapabilityStatement + +Plugins drop into Firely Server's `plugins/` folder. No server recompilation is needed. + +--- + +## Project Setup + +### Class library project file + +Target `net8.0`. Reference `Vonk.Core` — it pulls in everything needed. Pin the version that matches the Firely Server release you are targeting. + +```xml + + + + + net8.0 + disable + MyCompany.Vonk.Plugin.MyOperation + MyCompany.Vonk.Plugin.MyOperation + + + + + + +``` + +### Shared props file (pin NuGet versions once) + +```xml + + + + 6.7.1 + + +``` + +### Test project file + +```xml + + + + + net8.0 + disable + false + + + + + + + + + + + + +``` + +Make internals visible to the test project and Moq: + +```csharp +// Properties/AssemblyInfo.cs (in the main project) +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("MyCompany.Vonk.Plugin.MyOperation.Tests")] +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] // required by Moq +``` + +--- + +## Core Concepts + +### IVonkContext + +Every handler receives an `IVonkContext`. Destructure it immediately: + +```csharp +var (request, args, response) = vonkContext.Parts(); +``` + +| Part | What it gives you | +|---|---| +| `request` | HTTP method, path, interaction type, payload | +| `args` | Parsed FHIR/path/query arguments | +| `response` | HttpResult code, Payload (IResource), Outcome (OperationOutcome issues) | + +Always call `vonkContext.Arguments.Handled()` at the start of your handler to tell the pipeline the arguments have been consumed. + +### VonkInteraction + +Controls which HTTP interactions your handler matches: + +| Value | Matches | +|---|---| +| `instance_custom` | `GET [base]/ResourceType/id/$operation` | +| `type_custom` | `POST [base]/ResourceType/$operation` | +| `all_custom` | Both of the above | +| `instance_read`, `type_search`, etc. | Standard FHIR interactions | + +### Pipeline order + +`[VonkConfiguration(order: N)]` controls load order. Lower numbers run first. Check the [available plugins list](https://docs.fire.ly/projects/Firely-Server/en/latest/) for reserved ranges. Use a value in the 4500–5000 range for typical custom operations to run after built-in infrastructure. + +--- + +## Plugin Anatomy — Four Files + +A plugin typically consists of four files: + +``` +MyCompany.Vonk.Plugin.MyOperation/ +├── MyOperationConfiguration.cs ← DI + pipeline wiring +├── MyOperationService.cs ← Main handler business logic +├── MyOperationPreService.cs ← Pre-handler logic (optional, separate service) +├── MyOperationConformanceContributor.cs ← CapabilityStatement entry +└── Properties/AssemblyInfo.cs ← InternalsVisibleTo +``` + +--- + +## Implementation + +### 1. Configuration class + +Registers services and wires the handler into the pipeline. Must be `public static` with a `[VonkConfiguration]` attribute. + +```csharp +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Vonk.Core.Context; +using Vonk.Core.Metadata; +using Vonk.Core.Pluggability; +using Vonk.Core.Pluggability.ContextAware; + +namespace MyCompany.Vonk.Plugin.MyOperation; + +[VonkConfiguration(order: 4700)] // Choose an order that does not conflict with existing plugins +public static class MyOperationConfiguration +{ + public static IServiceCollection ConfigureServices(IServiceCollection services) + { + services.TryAddScoped(); + services.TryAddScoped(); // register pre-handler service separately + services.TryAddContextAware( + ServiceLifetime.Transient); + return services; + } + + public static IApplicationBuilder Configure(IApplicationBuilder builder) + { + // Pre-handler: runs before the main handler (in its own dedicated service) + builder + .OnCustomInteraction(VonkInteraction.all_custom, "my-operation") + .PreHandleAsyncWith((svc, ctx) => svc.PrepareAsync(ctx)); + + // Main handler for GET on instance level + builder + .OnCustomInteraction(VonkInteraction.instance_custom, "my-operation") + .AndMethod("GET") + .HandleAsyncWith((svc, ctx) => svc.ExecuteAsync(ctx)); + + // Main handler for POST on type level + builder + .OnCustomInteraction(VonkInteraction.type_custom, "my-operation") + .AndMethod("POST") + .HandleAsyncWith((svc, ctx) => svc.ExecuteAsync(ctx)); + + return builder; + } +} +``` + +**Restrict to specific resource types** by chaining `.AndResourceTypes(new[] { "Patient", "Observation" })` before `.HandleAsyncWith`. + +**Post-handlers** use `.PostHandleAsyncWith`. Register them before the main handler in the `Configure` method. + +### 2. Service class + +Contains all business logic. Inject repository interfaces and loggers through the constructor. + +```csharp +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Vonk.Core.Context; +using Vonk.Core.Repository; +using Vonk.Core.Support; +using static Vonk.Core.Context.VonkOutcome; +using Task = System.Threading.Tasks.Task; + +namespace MyCompany.Vonk.Plugin.MyOperation; + +internal class MyOperationService +{ + private readonly ISearchRepository _searchRepository; + private readonly ILogger _logger; + + public MyOperationService(ISearchRepository searchRepository, ILogger logger) + { + Check.NotNull(searchRepository, nameof(searchRepository)); + Check.NotNull(logger, nameof(logger)); + _searchRepository = searchRepository; + _logger = logger; + } + + public async Task ExecuteAsync(IVonkContext vonkContext) + { + var (request, args, response) = vonkContext.Parts(); + vonkContext.Arguments.Handled(); + + var resourceId = args.ResourceIdArgument().ArgumentValue; + _logger.LogDebug("$my-operation called on {ResourceId}", resourceId); + + // --- your business logic here --- + + response.HttpResult = StatusCodes.Status200OK; + response.Outcome.AddIssue( + IssueSeverity.Information, + IssueType.Informational, + diagnostics: $"$my-operation executed for {resourceId}"); + } + +} +``` + +**Key repository interfaces:** + +| Interface | Purpose | +|---|---| +| `ISearchRepository` | Read resources by key or search query | +| `IResourceChangeRepository` | Create, update, delete resources | +| `IStructureDefinitionSummaryProvider` | Resolve schema/profile info | + +**Reading a resource from the database:** + +```csharp +var searchOptions = SearchOptions.Latest(vonkContext.ServerBase, VonkInteraction.instance_read) + .WithAuthorization(vonkContext); +var resource = await _searchRepository.GetByKey(ResourceKey.Parse("Patient/123"), searchOptions); +``` + +**Constructing a FHIR resource:** + +When you need to build a FHIR resource from scratch (rather than reading one from the repository), add `Vonk.Fhir.R4` to the main project. It transitively brings in the Firely .NET SDK R4 model classes and adds the `ToIResource()` extension method that converts a POCO to the `IResource` type Firely Server works with. + +Add to the main project's `.csproj`: +```xml + +``` + +Then construct the resource and convert it: + +```csharp +using Hl7.Fhir.Model; // Patient, HumanName, ResourceReference, etc. +using Vonk.Fhir.R4; // ToIResource() extension method + +// ... + +var patient = new Patient +{ + Id = "example", + Name = [new HumanName { Family = "Smith", Given = ["John"] }], + BirthDateElement = new Date("1990-01-01"), +}; + +var resource = patient.ToIResource(); // IResource — ready to assign to response.Payload +``` + +Use `Vonk.Fhir.R3` or `Vonk.Fhir.R5` instead when targeting those FHIR versions. If a single plugin must support multiple versions, use C# `extern alias` to disambiguate the POCO namespaces (both are `Hl7.Fhir.Model`). + +**Setting a resource as the response payload:** + +```csharp +response.Payload = myResource; +response.HttpResult = StatusCodes.Status200OK; +``` + +**Accessing the HttpContext from a service:** + +Use the `HttpContext()` extension method on `IVonkContext` (namespace `Vonk.Core.Context`) when you need raw HTTP access inside a regular service handler: + +```csharp +using Vonk.Core.Context; + +// Inside your handler method: +var httpContext = vonkContext.HttpContext(); // returns Microsoft.AspNetCore.Http.HttpContext +var authHeader = httpContext.Request.Headers["Authorization"].FirstOrDefault(); +``` + +> **Warning:** `HttpContext()` throws `InvalidOperationException` if no HTTP context exists for the current invocation — for example, when the server processes a message internally rather than from an HTTP request. Only call it when you are certain the interaction originates from an HTTP request, or guard it accordingly. + +**Reporting errors:** + +```csharp +response.HttpResult = StatusCodes.Status400BadRequest; +response.Outcome.AddIssue(VonkIssue.INVALID_REQUEST, "Parameter 'id' is missing."); +return; +``` + +**Reading a POST body (Parameters resource):** + +`ToTypedElement` requires an `IStructureDefinitionSummaryProvider` to resolve schema information. Inject it alongside other dependencies: + +```csharp +// In the service constructor: +private readonly IStructureDefinitionSummaryProvider _schemaProvider; + +public MyOperationService( + ISearchRepository searchRepository, + IStructureDefinitionSummaryProvider schemaProvider, + ILogger logger) +{ + Check.NotNull(schemaProvider, nameof(schemaProvider)); + _schemaProvider = schemaProvider; + // ... +} +``` + +Then use it to parse the payload: + +```csharp +if (request.GetRequiredPayload(response, out var payload)) +{ + var parameters = payload.ToTypedElement(_schemaProvider); + var idParam = parameters.Select("children().where($this.name = 'id')").FirstOrDefault(); + var id = idParam?.ChildString("value"); +} +``` + +`IStructureDefinitionSummaryProvider` is registered by Firely Server and resolved from DI automatically, for the FHIR version in the current request. + +### 3. Pre-handler service class (optional) + +Pre-handlers run before the main handler — useful for validation, logging, or enriching the context. Give them their own service class so they can have independent dependencies and stay focused. + +```csharp +using Microsoft.Extensions.Logging; +using Vonk.Core.Context; +using Vonk.Core.Support; +using Task = System.Threading.Tasks.Task; + +namespace MyCompany.Vonk.Plugin.MyOperation; + +internal class MyOperationPreService +{ + private readonly ILogger _logger; + + public MyOperationPreService(ILogger logger) + { + Check.NotNull(logger, nameof(logger)); + _logger = logger; + } + + public async Task PrepareAsync(IVonkContext vonkContext) + { + _logger.LogDebug("Pre-handler for $my-operation"); + // Validate arguments, reject early, or attach items to HttpContext + await Task.CompletedTask; + } +} +``` + +### 4. Conformance contributor + +Makes the operation appear in the CapabilityStatement hosted at `GET [base]/metadata`. Decorated with `[ContextAware]` to scope it to specific FHIR versions. + +```csharp +using Microsoft.Extensions.Options; +using Vonk.Core.Common; +using Vonk.Core.Context; +using Vonk.Core.Context.Guards; +using Vonk.Core.Metadata; +using Vonk.Core.Model.Capability; +using Vonk.Core.Pluggability.ContextAware; +using Vonk.Core.Support; + +namespace MyCompany.Vonk.Plugin.MyOperation; + +[ContextAware(InformationModels = new[] { VonkConstants.Model.FhirR4 })] +internal class MyOperationConformanceContributor : ICapabilityStatementContributor +{ + private const string OperationName = "my-operation"; + private readonly SupportedInteractionOptions _supportedInteractions; + + public MyOperationConformanceContributor(IOptions options) + { + Check.NotNull(options, nameof(options)); + _supportedInteractions = options.Value; + } + + public void ContributeToCapabilityStatement(ICapabilityStatementBuilder builder) + { + Check.NotNull(builder, nameof(builder)); + if (_supportedInteractions.SupportsOperation(VonkInteraction.all_custom, OperationName)) + { + builder.UseRestComponentEditor(rce => + rce.AddOperation(OperationName, "https://example.com/fhir/OperationDefinition/my-operation")); + } + } +} +``` + +Support both R3 and R4 by adding both constants to the array: +```csharp +[ContextAware(InformationModels = new[] { VonkConstants.Model.FhirR3, VonkConstants.Model.FhirR4 })] +``` + +--- + +## Raw ASP.NET Core Middleware (Optional) + +Use middleware when you need direct `HttpContext` access — for example, to handle a non-FHIR content type or to inspect raw request headers before the FHIR pipeline runs. + +You can bridge back to the Vonk pipeline from inside middleware using `httpContext.Vonk()` (namespace `Vonk.Core.Context.Http`), which returns the `IVonkContext` for the current request. This lets you read or modify FHIR context without leaving middleware. + +```csharp +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Vonk.Core.Context; +using Vonk.Core.Context.Http; +using Vonk.Core.Support; + +namespace MyCompany.Vonk.Plugin.MyOperation; + +internal class MyRawMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + + public MyRawMiddleware(RequestDelegate next, ILogger logger) + { + Check.NotNull(next, nameof(next)); + Check.NotNull(logger, nameof(logger)); + _next = next; + _logger = logger; + } + + public async Task Invoke(HttpContext httpContext) + { + if (httpContext.Request.ContentType?.Contains("application/x-my-format") == true) + { + httpContext.Response.StatusCode = StatusCodes.Status200OK; + await httpContext.Response.WriteAsync("Handled by raw middleware"); + return; + } + + // Access IVonkContext from HttpContext when you need both raw HTTP and FHIR context + var (request, args, response) = httpContext.Vonk().Parts(); + _logger.LogDebug("Middleware sees interaction {Interaction}", request.Interaction); + + await _next(httpContext); + } +} +``` + +Register it in a separate configuration class with a lower order (so it runs before FHIR parsing): + +```csharp +[VonkConfiguration(order: 1200)] +public static class MyRawMiddlewareConfiguration +{ + public static IServiceCollection ConfigureServices(IServiceCollection services) => services; + + public static IApplicationBuilder Configure(IApplicationBuilder builder) + => builder.UseMiddleware(); +} +``` + +--- + +## Testing + +`Vonk.UnitTests.Framework` is an internal Firely package and is **not published to NuGet**. Create the two helper files below in your test project — they are small and self-contained. The originals can also be found in the [Example Plugin repository](https://github.com/FirelyTeam/Vonk.Plugin.ExampleOperation). + +### Test helper: VonkTestContext + +Create `Helpers/VonkTestContext.cs` in your test project: + +```csharp +using System.Collections.Generic; +using Vonk.Core.Common; +using Vonk.Core.Context; + +namespace MyCompany.Vonk.Plugin.MyOperation.Tests.Helpers; + +public class VonkTestContext : VonkBaseContext +{ + public VonkTestContext(string informationModel = VonkConstants.Model.FhirR4) : base() + { + TestRequest = new VonkTestRequest(); + TestArguments = new ArgumentCollection(); + TestResponse = new VonkTestResponse(); + InformationModel = informationModel; + } + + public VonkTestContext(VonkInteraction interaction, string informationModel = VonkConstants.Model.FhirR4) + : this(informationModel) + { + TestRequest.Interaction = interaction; + } + + public VonkTestRequest TestRequest { get { return _vonkRequest as VonkTestRequest; } set { _vonkRequest = value; } } + public VonkTestResponse TestResponse { get { return _vonkResponse as VonkTestResponse; } set { _vonkResponse = value; } } + public IArgumentCollection TestArguments { get { return _vonkArguments.Arguments; } set { _vonkArguments = new VonkTestArguments(value); } } +} + +public class VonkTestRequest : IVonkRequest +{ + public string Path { get; set; } + public string Method { get; set; } + public string CustomOperation { get; set; } + public VonkInteraction Interaction { get; set; } + public RequestPayload Payload { get; set; } +} + +public class VonkTestArguments : IVonkArguments +{ + private IArgumentCollection _arguments; + public VonkTestArguments(IArgumentCollection args) { _arguments = args; } + public IArgumentCollection Arguments => _arguments; +} + +public class VonkTestResponse : IVonkResponse +{ + public Dictionary Headers { get; set; } = new(); + public int HttpResult { get; set; } + public VonkOutcome Outcome { get; set; } = new VonkOutcome(); + public IResource Payload { get; set; } +} +``` + +### Test helper: LoggerUtils + +Create `Helpers/LoggerUtils.cs` in your test project: + +```csharp +using Microsoft.Extensions.Logging; +using Moq; + +namespace MyCompany.Vonk.Plugin.MyOperation.Tests.Helpers; + +public static class LoggerUtils +{ + public static ILogger Logger() where T : class + => LoggerMock().Object; + + public static Mock> LoggerMock() where T : class + => new Mock>(); +} +``` + +### Writing a test + +```csharp +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Moq; +using Vonk.Core.Context; +using Vonk.Core.Repository; +using Xunit; +using MyCompany.Vonk.Plugin.MyOperation.Tests.Helpers; + +namespace MyCompany.Vonk.Plugin.MyOperation.Tests; + +public class MyOperationServiceTests +{ + private readonly MyOperationService _service; + private readonly Mock _searchMock = new(); + + public MyOperationServiceTests() + { + _service = new MyOperationService( + _searchMock.Object, + LoggerUtils.Logger()); + } + + [Fact] + public async Task Execute_InstanceGET_ReturnsOk() + { + var context = new VonkTestContext(VonkInteraction.instance_custom); + context.Arguments.AddArguments(new[] + { + new Argument(ArgumentSource.Path, ArgumentNames.resourceType, "Patient"), + new Argument(ArgumentSource.Path, ArgumentNames.resourceId, "p1"), + }); + context.TestRequest.CustomOperation = "my-operation"; + context.TestRequest.Method = "GET"; + + await _service.ExecuteAsync(context); + + context.Response.HttpResult.Should().Be(StatusCodes.Status200OK); + } +} +``` + +**Mock a repository result:** + +```csharp +_searchMock + .Setup(r => r.GetByKey(It.Is(k => k.ResourceId == "p1"), It.IsAny())) + .ReturnsAsync(patient); +``` + +**Assert an error response:** + +```csharp +context.Response.HttpResult.Should().Be(StatusCodes.Status400BadRequest); +context.Response.Outcome.Issues.Should().ContainSingle(); +``` + +--- + +## Deployment + +1. Build in `Release` configuration: `dotnet publish -c Release` +2. Copy the output DLL (and its dependencies not already in Firely Server) into the `plugins/` folder next to `Firely.Server.dll`. +3. Restart Firely Server — it scans `plugins/` at startup. +4. Verify the operation appears in `GET [base]/metadata` under `rest[0].operation`. + +Firely Server logs which plugins it loads at startup (look for `[VonkConfiguration]` log lines at `Information` level). + +--- + +## Common Mistakes + +| Mistake | Fix | +|---|---| +| Forgetting `vonkContext.Arguments.Handled()` | The pipeline treats the request as unhandled and may return a 404 or fall through to the next handler | +| Setting response after `return` statement without a status | Always set `response.HttpResult` before returning | +| Registering services as `Singleton` when they depend on scoped `ISearchRepository` | Use `Scoped` lifetime for your service; `ISearchRepository` is scoped | +| Not calling `Check.NotNull` on constructor parameters | Vonk convention; use it instead of `ArgumentNullException.ThrowIfNull` | +| Choosing an `order` that conflicts with a built-in plugin | Check the Firely Server documentation for reserved order ranges | +| Missing `InternalsVisibleTo` for Moq | Add `[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")]` in `AssemblyInfo.cs` | + +--- + +## Reference + +- [Firely Server Programming API Reference](https://docs.fire.ly/projects/Firely-Server/en/latest/reference/reference.html) +- [Example Plugin (GitHub)](https://github.com/FirelyTeam/Vonk.Plugin.ExampleOperation) +- [Document Operation Plugin (GitHub)](https://github.com/FirelyTeam/Vonk.Plugin.DocumentOperation)