Skip to content
Merged
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
23 changes: 23 additions & 0 deletions docs/engine-overview.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Engine contracts overview

This repository now exposes transport-agnostic engines that sit inside each module. Engines describe their contracts in a manifest and can be surfaced through adapters when a host wants to expose UI or webhook behaviors.

## Core building blocks

- **Engine manifest** (`ModuleEngineManifest`)
- Identifies the engine (`Id`, `Version`, `Description`, `Kind`, optional `FeatureArea`).
- Declares capabilities, schemas, security, compatibility expectations, and navigation hints via strongly typed records/enums (`ModuleEngineNavigationHints`, `ModuleNavigationToken`, `NavigationTargetKind`, `ModuleSignatureAlgorithm`).
- Declares `RequiredServices` – a list of logical capabilities (for example `IApiClient`, `ICache`, `ITelemetry`) that must be provided by the host for the engine to run. Hosts are expected to validate that every required service can be satisfied before executing an engine, typically by mapping each required service identifier to a concrete implementation in their DI container or adapter configuration. Adapters enforce this validation by consulting `IRequiredServiceValidator`; hosts should register a validator or surface a configuration error when validation fails.
- Adds optional webhook metadata (`ModuleEngineWebhookMetadata`) for providers and event types.
- **Engine descriptor** (`ModuleEngineDescriptor<TContract>`) wraps the manifest with a strongly typed factory so hosts can resolve engines without `object` casts.
- **Discovery** (`ModuleEngineDiscoveryService`/`ModuleEngineRegistry`) stores descriptors and supports filtering by kind/feature or matching webhook provider + event type. Hosts can either enumerate engines (dynamic) or resolve known descriptors directly (static, isolation-focused deployments), and use the exposed manifests (including `RequiredServices`) to ensure all dependencies are available before wiring engines into request or worker pipelines.

## Adapter roles

Adapters remain optional: they map transport concerns to engines while keeping the engines themselves unaware of ASP.NET, MVC, or Razor. Reference adapters include:
- `UiEngineAdapter` → executes an `IUiEngine` and surfaces `UiAdapterResponse` with typed navigation tokens.
- `WebhookEngineAdapter` → validates signatures, enforces idempotency hints, and dispatches to `IWebhookEngine` instances.

## Versioning and isolation

Each engine version is tracked in its manifest. Because descriptors are strongly typed and module-owned, a module can expose multiple UI or webhook engines while remaining an isolation boundary. Hosts that do not require dynamic discovery can register known descriptors directly and resolve them through the discovery service using the explicit generic overloads.
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ public static IServiceCollection AddApiModuleServices(
services.AddSingleton(module);
}

services.AddSingleton<ModuleEngineDiscoveryService>();

return services;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Copyright (c) Bravellian
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

namespace Bravellian.Platform.Modularity;

/// <summary>
/// Validates webhook signatures for adapters.
/// </summary>
public interface IWebhookSignatureValidator
{
/// <summary>
/// Validates the signature for the provided request.
/// </summary>
bool Validate(ModuleEngineSecurity security, IReadOnlyDictionary<string, string> headers, string rawBody, string? providedSignature);
}
36 changes: 36 additions & 0 deletions src/Bravellian.Platform.Modularity.Api/WebhookAdapterRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Copyright (c) Bravellian
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

namespace Bravellian.Platform.Modularity;

/// <summary>
/// Raw webhook request envelope understood by the transport adapters.
/// </summary>
/// <param name="Provider">Webhook provider identifier.</param>
/// <param name="EventType">Webhook event type.</param>
/// <param name="Headers">Raw headers supplied by the gateway.</param>
/// <param name="RawBody">Raw body text for signature validation.</param>
/// <param name="IdempotencyKey">Idempotency key supplied by provider.</param>
/// <param name="Attempt">Delivery attempt number.</param>
/// <param name="Signature">Optional supplied signature.</param>
/// <param name="Payload">Parsed payload DTO.</param>
public sealed record WebhookAdapterRequest<TPayload>(
string Provider,
string EventType,
IReadOnlyDictionary<string, string> Headers,
string RawBody,
string IdempotencyKey,
int Attempt,
string? Signature,
TPayload Payload);
23 changes: 23 additions & 0 deletions src/Bravellian.Platform.Modularity.Api/WebhookAdapterResponse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Copyright (c) Bravellian
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

namespace Bravellian.Platform.Modularity;

/// <summary>
/// Adapter response that can be mapped to HTTP or queue responses.
/// </summary>
/// <param name="Outcome">Outcome the transport should emit.</param>
/// <param name="Reason">Optional reason for retries.</param>
/// <param name="EnqueuedEvent">Optional event payload to enqueue downstream.</param>
public sealed record WebhookAdapterResponse(WebhookOutcomeType Outcome, string? Reason = null, object? EnqueuedEvent = null);
152 changes: 152 additions & 0 deletions src/Bravellian.Platform.Modularity.Api/WebhookEngineAdapter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
// Copyright (c) Bravellian
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

using Microsoft.Extensions.DependencyInjection;

namespace Bravellian.Platform.Modularity;

/// <summary>
/// Adapter that connects webhook engines to HTTP-style transports.
/// </summary>
public sealed class WebhookEngineAdapter
{
private readonly ModuleEngineDiscoveryService discovery;
private readonly IServiceProvider services;
private readonly IWebhookSignatureValidator signatureValidator;

/// <summary>
/// Initializes a new instance of the <see cref="WebhookEngineAdapter"/> class.
/// </summary>
public WebhookEngineAdapter(ModuleEngineDiscoveryService discovery, IServiceProvider services, IWebhookSignatureValidator signatureValidator)
{
this.discovery = discovery;
this.services = services;
this.signatureValidator = signatureValidator;
}

/// <summary>
/// Dispatches a webhook request to a registered engine.
/// </summary>
public async Task<WebhookAdapterResponse> DispatchAsync<TPayload>(WebhookAdapterRequest<TPayload> request, CancellationToken cancellationToken)
{
Comment thread
SamuelMcAravey marked this conversation as resolved.
if (request is null)
{
throw new ArgumentNullException(nameof(request));
}

if (string.IsNullOrWhiteSpace(request.Provider))
{
throw new ArgumentException("Provider must be a non-empty, non-whitespace string.", nameof(request.Provider));
}

if (string.IsNullOrWhiteSpace(request.EventType))
{
throw new ArgumentException("EventType must be a non-empty, non-whitespace string.", nameof(request.EventType));
}

Comment thread
SamuelMcAravey marked this conversation as resolved.
Comment thread
SamuelMcAravey marked this conversation as resolved.
var descriptor = discovery.ResolveWebhookEngine(request.Provider, request.EventType)
?? throw new InvalidOperationException($"No webhook engine registered for provider '{request.Provider}' and event '{request.EventType}'.");

if (descriptor.Manifest.Security is { } security &&
!signatureValidator.Validate(security, request.Headers, request.RawBody, request.Signature))
{
return new WebhookAdapterResponse(
WebhookOutcomeType.Acknowledge,
$"Signature validation failed. Expected algorithm: {security.SignatureAlgorithm}.");
}

if (string.IsNullOrWhiteSpace(request.IdempotencyKey) && descriptor.Manifest.Security?.IdempotencyWindow is not null)
Comment thread
SamuelMcAravey marked this conversation as resolved.
{
return new WebhookAdapterResponse(
WebhookOutcomeType.Retry,
"Idempotency key is required when an idempotency window is configured for this provider.");
}

ValidateRequiredServices(descriptor, request.Provider, request.EventType);

var typedDescriptor = descriptor as ModuleEngineDescriptor<IWebhookEngine<TPayload>>
?? throw new InvalidOperationException($"Engine '{descriptor.Manifest.Id}' does not implement expected webhook contract.");

var engine = discovery.ResolveEngine(typedDescriptor, services);

var outcome = await engine.HandleAsync(
new WebhookRequest<TPayload>(request.Provider, request.EventType, request.Payload, request.IdempotencyKey, request.Attempt),
cancellationToken).ConfigureAwait(false);

return new WebhookAdapterResponse(outcome.Outcome, outcome.Reason, outcome.EnqueuedEvent);
}

private void ValidateRequiredServices(IModuleEngineDescriptor descriptor, string provider, string eventType)
{
var requiredServices = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

if (descriptor.Manifest.RequiredServices is { Count: > 0 } manifestRequired)
{
foreach (var service in manifestRequired)
{
requiredServices.Add(service);
}
}

if (descriptor.Manifest.WebhookMetadata is { } metadata)
{
foreach (var entry in metadata)
{
if (!string.Equals(entry.Provider, provider, StringComparison.OrdinalIgnoreCase)
|| !string.Equals(entry.EventType, eventType, StringComparison.OrdinalIgnoreCase))
{
continue;
}

if (entry.RequiredServices is null)
{
continue;
}

foreach (var service in entry.RequiredServices)
{
requiredServices.Add(service);
}
}
}

if (requiredServices.Count == 0)
{
return;
}

foreach (var service in requiredServices)
{
if (string.IsNullOrWhiteSpace(service))
{
throw new InvalidOperationException(
$"Engine '{descriptor.ModuleKey}/{descriptor.Manifest.Id}' declares an empty required service identifier.");
}
}
Comment on lines +129 to +136

Copilot AI Dec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This foreach loop implicitly filters its target sequence - consider filtering the sequence explicitly using '.Where(...)'.

Copilot uses AI. Check for mistakes.

var validator = services.GetService<IRequiredServiceValidator>();
if (validator is null)
{
throw new InvalidOperationException(
$"Engine '{descriptor.ModuleKey}/{descriptor.Manifest.Id}' declares required services but no {nameof(IRequiredServiceValidator)} is registered.");
}

var missing = validator.GetMissingServices(requiredServices.ToArray()) ?? Array.Empty<string>();
if (missing.Count > 0)
{
throw new InvalidOperationException(
$"Engine '{descriptor.ModuleKey}/{descriptor.Manifest.Id}' is missing required services: {string.Join(", ", missing)}.");
}
}
Comment thread
SamuelMcAravey marked this conversation as resolved.
Comment thread
SamuelMcAravey marked this conversation as resolved.
Comment on lines +129 to +151

Copilot AI Dec 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ValidateRequiredServices method has code duplication with the UiEngineAdapter's ValidateRequiredServices method. Lines 129-150 in WebhookEngineAdapter are nearly identical to lines 78-99 in UiEngineAdapter. The only difference is the WebhookEngineAdapter has additional logic to aggregate required services from webhook metadata. Consider extracting the common validation logic into a shared helper method to improve maintainability and reduce duplication.

Copilot uses AI. Check for mistakes.
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ public static IServiceCollection AddBackgroundModuleServices(
ILoggerFactory? loggerFactory = null)
{
ModuleRegistry.InitializeModules<IBackgroundModule>(ModuleCategory.Background, configuration, services, loggerFactory);
services.AddSingleton<ModuleEngineDiscoveryService>();
return services;
}
}
31 changes: 31 additions & 0 deletions src/Bravellian.Platform.Modularity.Core/EngineKind.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Copyright (c) Bravellian
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

namespace Bravellian.Platform.Modularity;

/// <summary>
/// Supported engine types. Engines are framework-agnostic and must not depend on transport concerns.
/// </summary>
public enum EngineKind
{
/// <summary>
/// UI-first engines that produce view models and navigation outcomes.
/// </summary>
Ui,

/// <summary>
/// Webhook engines that react to external callbacks.
/// </summary>
Webhook,
}
26 changes: 26 additions & 0 deletions src/Bravellian.Platform.Modularity.Core/IEngineModule.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Copyright (c) Bravellian
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

namespace Bravellian.Platform.Modularity;

/// <summary>
/// Marker interface for modules that expose engines.
/// </summary>
public interface IEngineModule
{
/// <summary>
/// Provides engine descriptors for the module.
/// </summary>
IEnumerable<IModuleEngineDescriptor> DescribeEngines();
}
29 changes: 29 additions & 0 deletions src/Bravellian.Platform.Modularity.Core/IModuleEngineDescriptor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Copyright (c) Bravellian
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

namespace Bravellian.Platform.Modularity;

/// <summary>
/// Base abstraction for an engine descriptor. Implementations should remain transport agnostic.
/// </summary>
public interface IModuleEngineDescriptor
{
string ModuleKey { get; }

ModuleEngineManifest Manifest { get; }

Type ContractType { get; }

object? Create(IServiceProvider serviceProvider);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Copyright (c) Bravellian
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

namespace Bravellian.Platform.Modularity;

/// <summary>
/// Validates that required engine services are available for a host.
/// </summary>
public interface IRequiredServiceValidator
{
/// <summary>
/// Returns the subset of required services that are missing.
/// </summary>
IReadOnlyCollection<string> GetMissingServices(IReadOnlyCollection<string> requiredServices);
}
Loading
Loading