Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions docs/plugin-type-catalog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
![WebExpress](https://raw.githubusercontent.com/webexpress-framework/.github/main/docs/assets/img/banner.png)

# Plugin type catalog

An application built on WebExpress often has extension points of its own: export formats, automation actions, integrations with other systems. Each of them asks the same question the framework asks for its pages, fragments and health checks: *which plugin contributes which implementation?* `PluginTypeCatalog<TItem>` (namespace `WebExpress.WebCore.WebPlugin`) answers it, so a manager does not have to scan the plugin assemblies, follow the plugin and application events and keep track of who provides what by hand.

The catalog is a building block, not a manager and not a base class. A manager holds one, or several, as a field and delegates to it.

## When to use it

Use a catalog when the implementations of an extension point are **types in plugins** that should be found without any registration: a plugin ships a `public sealed` class, and the extension point knows it once the plugin is loaded. Do not use it for UI building blocks - fragments carry a section and a scope and are rendered by the `FragmentManager` - nor for configuration entries, which are no types (for those, see `Register` below).

## The convention

A type of a plugin's assembly is an item when it is

- a class that is `public` at top level - a nested type is not, because `Type.IsPublic` is false for it,
- `sealed`, not abstract and not an open generic,
- assignable to the contract (by default `TItem`),
- and, without a factory, has a public parameterless constructor.

A type that misses any of these is skipped **silently**, the way a page or a fragment that does not follow its convention is. A plugin should therefore have a test that every item it ships follows the convention - otherwise an item is simply missing in a running host.

A type that is found but cannot be used - its constructor throws, it names no key, a second type of the same plugin uses the same key, or the assembly loads only in part - is written to the server log as a warning and skipped. The other items of the plugin are still collected.

## Keys and ownership

Every item has a key, answered by the function the catalog is created with. Keys are trimmed and compared without case.

Several plugins may offer an item under the same key - an add-on replacing a standard implementation, for instance. The catalog keeps every offer in the order the plugins arrived, and the item **in use** under a key is the latest offer. When a plugin leaves, it takes only what it offered:

- a key it still provides falls back to the latest remaining offer of another plugin, and is removed only when nobody offers it any more;
- a key another plugin took over is left alone.

`Register(item)` files an item that is no type of its own - one per configuration entry, or a stand-in in a test. It competes like an offer (the latest offer wins), but it belongs to no plugin: no plugin removal takes it away, only `Unregister(key)` does, and the catalog never disposes it. An item the catalog constructed itself is disposed when it leaves the catalog, if it implements `IDisposable`.

## Following a host

`Attach(componentHub, httpServerContext)` does both passes a manager needs, because managers are built before all plugins have arrived:

1. it collects the plugins already loaded (`IPluginManager.Plugins`), and
2. it subscribes to `AddPlugin` and `RemovePlugin` for the ones that come and go later.

Collecting a plugin twice does nothing, so the two passes never double an item. `Dispose()` unsubscribes, raises `Removed` for every item in use and disposes the items it built. Without a plugin manager - in a unit test - `Collect(assembly)` collects one assembly; its items belong to no plugin.

## Reading and change signalling

- `Find(key)`, `Contains(key)` and `OwnerOf(key)` answer the item in use under a key and the plugin that provides it (`null` for a registered item).
- `Items` is a snapshot of the items in use, in the order their offers arrived.
- `Version` moves whenever an item comes, goes or is replaced - a cached picker or menu compares it instead of the items.
- `Added` and `Removed` report each change, carrying the key, the item and the plugin. The leaving item is reported before the arriving one, and both events are raised after the catalog released its lock, so a handler may call back into it.

The catalog is thread-safe; types are scanned and items constructed outside its lock.

## Items bound to an application

The framework's own extension points are scoped to an application: a health check, for example, is bound once per plugin and per application the plugin is associated with. A catalog created with `PluginTypeBinding.Application` does the same. It also follows `AddApplication` and `RemoveApplication`, keeps a key per application, and offers `Find(application, key)`, `GetItems(application)`, `Collect(plugin, applications)`, `Withdraw(application)` and `Register(application, item)`. A catalog bound per plugin - the default, and the right choice for most application extension points - pays nothing for this.

A factory builds items that need their context, or that wrap the discovered type instead of being it. It receives a `PluginTypeOrigin` (type, plugin, application); answering `null` declines the type. The `HealthManager` uses one to wrap each `IHealth` type in a binding that activates the component only when it is checked.

## Example

An application defines an extension point - every `public sealed IExportFormat` of any plugin:

```csharp
public interface IExportFormat
{
string Key { get; }

byte[] Export(IEnumerable<object> rows);
}

public sealed class ExportFormatManager : IExportFormatManager
{
private readonly PluginTypeCatalog<IExportFormat> _formats = new(x => x.Key);

private ExportFormatManager(IComponentHub componentHub, IHttpServerContext httpServerContext)
{
_formats.Attach(componentHub, httpServerContext);
}

public IReadOnlyList<IExportFormat> Formats => _formats.Items;

public IExportFormat Get(string key) => _formats.Find(key);

public void Dispose() => _formats.Dispose();
}
```

A plugin contributes a format by shipping a class - nothing else:

```csharp
public sealed class CsvExportFormat : IExportFormat
{
public string Key => "csv";

public byte[] Export(IEnumerable<object> rows) => ...;
}
```

And it tests that its items are found:

```csharp
[Fact]
public void EveryExportFormatIsFindable()
{
using var catalog = new PluginTypeCatalog<IExportFormat>(x => x.Key);
catalog.Collect(typeof(CsvExportFormat).Assembly);

Assert.NotNull(catalog.Find("csv"));
}
```

## Candidates in the framework

The `HealthManager` discovers its checks through a catalog bound per application. The other managers that scan plugin assemblies still carry their own discovery and are candidates for the same move: `MetricsManager`, `JobManager`, `ThemeManager`, `SettingPageManager`, `FragmentManager`, `PageManager`, `RestApiManager`, `IncludeManager`, `EventManager`, `SocketManager` and `StatusPageManager`. Those that file their items by attributes and routes rather than by a key (pages, REST APIs) need more than a catalog and may keep their own bookkeeping.
2 changes: 2 additions & 0 deletions docs/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,7 @@
href: user-guide.md
- name: Health Checks
href: https://github.com/webexpress-framework/WebExpress/blob/main/docs/development_guide.md#health-model
- name: Plugin Type Catalog
href: plugin-type-catalog.md
- name: Tutorials
href: tutorials.md
3 changes: 3 additions & 0 deletions docs/tutorials.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,8 @@ Begin with our basic tutorial:

This tutorial will guide you through the initial steps of creating and running your first `WebExpress` application.

# Contributing an item from a plugin
An application can open an extension point to other plugins without any registration: it defines an interface and collects its implementations with a `PluginTypeCatalog<TItem>`, and a plugin contributes one by shipping a `public sealed` class with a parameterless constructor. The [Plugin type catalog](plugin-type-catalog.md) page walks through an export-format example - the manager, a contributed format and the test that keeps it findable.

Stay tuned for more exciting and educational tutorials to help you unlock the full potential of `WebExpress`. Happy coding and
best of luck with your projects!
4 changes: 4 additions & 0 deletions docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ We hope you enjoy using `WebExpress.WebCore` and find it valuable for your proje

The container health endpoint is documented in the [Health model](https://github.com/webexpress-framework/WebExpress/blob/main/docs/development_guide.md#health-model) section of the Development Guide. WebCore provides `/health` globally, and applications contribute critical dependency checks as public sealed `IHealth` components in `WebExpress.WebCore.WebHealth`. The `HealthManager` discovers these components through the plugin and application lifecycle. The guide includes the component model, a database example, the HTTP contract, and Docker and Kubernetes probe configuration.

## Plugin type catalog

An application's own extension points - every `public sealed` implementation of an interface any plugin ships - are collected by `PluginTypeCatalog<TItem>` in `WebExpress.WebCore.WebPlugin`, the building block the `HealthManager` uses for its checks. A manager holds it as a field: it sweeps the loaded plugins, follows the ones arriving and leaving, keeps track of which plugin provides which key and falls back to another plugin's offer when the provider leaves. The [Plugin type catalog](plugin-type-catalog.md) page describes the convention, the ownership rules, the binding per application and an example.

## Metrics

The Prometheus endpoint is documented in the [Metrics model](https://github.com/webexpress-framework/WebExpress/blob/main/docs/development_guide.md#metrics-model) section of the Development Guide. WebCore provides `/metrics` globally with request, error, login, active user, process, and runtime metrics. Applications record their own values with `MetricCounter`, `MetricGauge`, and `MetricHistogram` instruments, or report them at scrape time as public sealed `IMetric` components in `WebExpress.WebCore.WebMetrics`. The `MetricsManager` discovers these components through the plugin and application lifecycle and labels every series with its application. The guide includes an LDAP example, the list of framework metrics, the HTTP contract, and Prometheus, Kubernetes, and alerting configuration.
Expand Down
10 changes: 9 additions & 1 deletion src/WebExpress.WebCore.Test/Fixture/AuthenticationFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ internal sealed class AuthenticationFixture : IDisposable
internal IdentityManager Manager => (IdentityManager)Hub.IdentityManager;
internal TestClock Clock { get; } = new();

/// <summary>
/// Gets the application groups an <see cref="Instance"/> created afterwards is configured with.
/// </summary>
internal Dictionary<string, string[]> Groups { get; } = [];

/// <summary>
/// Creates an isolated signing authority and durable token directory for each test.
/// </summary>
Expand Down Expand Up @@ -63,7 +68,10 @@ internal IdentityManager Instance()
/// <returns>The configuration containing the authentication section.</returns>
private IConfigurationRoot Configuration()
{
return new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string>
var groups = Groups.SelectMany(group => group.Value.Select((id, index) =>
new KeyValuePair<string, string>($"WebExpress:Authentication:ApplicationGroups:{group.Key}:{index}", id)));

return new ConfigurationBuilder().AddInMemoryCollection(groups).AddInMemoryCollection(new Dictionary<string, string>
{
["WebExpress:Authentication:Issuer"] = Settings.Issuer,
["WebExpress:Authentication:Audience"] = Settings.Audience,
Expand Down
1 change: 1 addition & 0 deletions src/WebExpress.WebCore.Test/Fixture/UnitTestFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ public static WebMessage.HttpContext CreateHttpContextMock(string content = "")

var requestFeature = new HttpRequestFeature
{
Scheme = "http",
Headers = new HeaderDictionary
{
["Host"] = "localhost",
Expand Down
Loading
Loading