From a2ae887d05c3d3f8025488b09943c6281c298d5a Mon Sep 17 00:00:00 2001 From: Samuel McAravey Date: Sat, 20 Dec 2025 21:11:19 -0800 Subject: [PATCH 01/12] Strengthen engine descriptors and typed navigation --- docs/engine-overview.md | 22 ++ .../ApiModuleServiceCollectionExtensions.cs | 2 + .../IWebhookSignatureValidator.cs | 26 ++ .../WebhookAdapterRequest.cs | 36 +++ .../WebhookAdapterResponse.cs | 22 ++ .../WebhookEngineAdapter.cs | 69 ++++++ ...groundModuleServiceCollectionExtensions.cs | 1 + .../EngineKind.cs | 31 +++ .../IEngineModule.cs | 26 ++ .../IModuleEngineDescriptor.cs | 29 +++ .../IUiEngine.cs | 31 +++ .../IWebhookEngine.cs | 27 +++ .../ModuleEngineAdapterHints.cs | 30 +++ .../ModuleEngineCapabilities.cs | 28 +++ .../ModuleEngineCompatibility.cs | 22 ++ .../ModuleEngineDescriptor.cs | 32 +++ .../ModuleEngineDiscoveryService.cs | 59 +++++ .../ModuleEngineManifest.cs | 48 ++++ .../ModuleEngineNavigationHints.cs | 21 ++ .../ModuleEngineRegistry.cs | 70 ++++++ .../ModuleEngineSchema.cs | 23 ++ .../ModuleEngineSecurity.cs | 26 ++ .../ModuleEngineWebhookMetadata.cs | 30 +++ .../ModuleNavigationToken.cs | 20 ++ .../ModuleRegistry.cs | 17 ++ .../ModuleSignatureAlgorithm.cs | 26 ++ .../NavigationTargetKind.cs | 26 ++ .../UiEngineResult.cs | 26 ++ .../WebhookOutcome.cs | 39 +++ .../WebhookOutcomeType.cs | 36 +++ .../WebhookRequest.cs | 30 +++ ...lStackModuleServiceCollectionExtensions.cs | 1 + .../UiAdapterResponse.cs | 26 ++ .../UiEngineAdapter.cs | 52 ++++ .../EngineRefactoringTests.cs | 225 ++++++++++++++++++ 35 files changed, 1235 insertions(+) create mode 100644 docs/engine-overview.md create mode 100644 src/Bravellian.Platform.Modularity.Api/IWebhookSignatureValidator.cs create mode 100644 src/Bravellian.Platform.Modularity.Api/WebhookAdapterRequest.cs create mode 100644 src/Bravellian.Platform.Modularity.Api/WebhookAdapterResponse.cs create mode 100644 src/Bravellian.Platform.Modularity.Api/WebhookEngineAdapter.cs create mode 100644 src/Bravellian.Platform.Modularity.Core/EngineKind.cs create mode 100644 src/Bravellian.Platform.Modularity.Core/IEngineModule.cs create mode 100644 src/Bravellian.Platform.Modularity.Core/IModuleEngineDescriptor.cs create mode 100644 src/Bravellian.Platform.Modularity.Core/IUiEngine.cs create mode 100644 src/Bravellian.Platform.Modularity.Core/IWebhookEngine.cs create mode 100644 src/Bravellian.Platform.Modularity.Core/ModuleEngineAdapterHints.cs create mode 100644 src/Bravellian.Platform.Modularity.Core/ModuleEngineCapabilities.cs create mode 100644 src/Bravellian.Platform.Modularity.Core/ModuleEngineCompatibility.cs create mode 100644 src/Bravellian.Platform.Modularity.Core/ModuleEngineDescriptor.cs create mode 100644 src/Bravellian.Platform.Modularity.Core/ModuleEngineDiscoveryService.cs create mode 100644 src/Bravellian.Platform.Modularity.Core/ModuleEngineManifest.cs create mode 100644 src/Bravellian.Platform.Modularity.Core/ModuleEngineNavigationHints.cs create mode 100644 src/Bravellian.Platform.Modularity.Core/ModuleEngineRegistry.cs create mode 100644 src/Bravellian.Platform.Modularity.Core/ModuleEngineSchema.cs create mode 100644 src/Bravellian.Platform.Modularity.Core/ModuleEngineSecurity.cs create mode 100644 src/Bravellian.Platform.Modularity.Core/ModuleEngineWebhookMetadata.cs create mode 100644 src/Bravellian.Platform.Modularity.Core/ModuleNavigationToken.cs create mode 100644 src/Bravellian.Platform.Modularity.Core/ModuleSignatureAlgorithm.cs create mode 100644 src/Bravellian.Platform.Modularity.Core/NavigationTargetKind.cs create mode 100644 src/Bravellian.Platform.Modularity.Core/UiEngineResult.cs create mode 100644 src/Bravellian.Platform.Modularity.Core/WebhookOutcome.cs create mode 100644 src/Bravellian.Platform.Modularity.Core/WebhookOutcomeType.cs create mode 100644 src/Bravellian.Platform.Modularity.Core/WebhookRequest.cs create mode 100644 src/Bravellian.Platform.Modularity.FullStack/UiAdapterResponse.cs create mode 100644 src/Bravellian.Platform.Modularity.FullStack/UiEngineAdapter.cs create mode 100644 tests/Bravellian.Platform.Tests/EngineRefactoringTests.cs diff --git a/docs/engine-overview.md b/docs/engine-overview.md new file mode 100644 index 00000000..f0bd384d --- /dev/null +++ b/docs/engine-overview.md @@ -0,0 +1,22 @@ +# 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`). + - Adds optional webhook metadata (`ModuleEngineWebhookMetadata`) for providers and event types. +- **Engine descriptor** (`ModuleEngineDescriptor`) 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). + +## 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. diff --git a/src/Bravellian.Platform.Modularity.Api/ApiModuleServiceCollectionExtensions.cs b/src/Bravellian.Platform.Modularity.Api/ApiModuleServiceCollectionExtensions.cs index 7f1854ca..89dfbcfe 100644 --- a/src/Bravellian.Platform.Modularity.Api/ApiModuleServiceCollectionExtensions.cs +++ b/src/Bravellian.Platform.Modularity.Api/ApiModuleServiceCollectionExtensions.cs @@ -39,6 +39,8 @@ public static IServiceCollection AddApiModuleServices( services.AddSingleton(module); } + services.AddSingleton(); + return services; } diff --git a/src/Bravellian.Platform.Modularity.Api/IWebhookSignatureValidator.cs b/src/Bravellian.Platform.Modularity.Api/IWebhookSignatureValidator.cs new file mode 100644 index 00000000..ce7dd1fb --- /dev/null +++ b/src/Bravellian.Platform.Modularity.Api/IWebhookSignatureValidator.cs @@ -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; + +/// +/// Validates webhook signatures for adapters. +/// +public interface IWebhookSignatureValidator +{ + /// + /// Validates the signature for the provided request. + /// + bool Validate(ModuleEngineSecurity security, IReadOnlyDictionary headers, string rawBody, string? providedSignature); +} diff --git a/src/Bravellian.Platform.Modularity.Api/WebhookAdapterRequest.cs b/src/Bravellian.Platform.Modularity.Api/WebhookAdapterRequest.cs new file mode 100644 index 00000000..099a7534 --- /dev/null +++ b/src/Bravellian.Platform.Modularity.Api/WebhookAdapterRequest.cs @@ -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; + +/// +/// Raw webhook request envelope understood by the transport adapters. +/// +/// Webhook provider identifier. +/// Webhook event type. +/// Raw headers supplied by the gateway. +/// Raw body text for signature validation. +/// Idempotency key supplied by provider. +/// Delivery attempt number. +/// Optional supplied signature. +/// Parsed payload DTO. +public sealed record WebhookAdapterRequest( + string Provider, + string EventType, + IReadOnlyDictionary Headers, + string RawBody, + string IdempotencyKey, + int Attempt, + string? Signature, + TPayload Payload); diff --git a/src/Bravellian.Platform.Modularity.Api/WebhookAdapterResponse.cs b/src/Bravellian.Platform.Modularity.Api/WebhookAdapterResponse.cs new file mode 100644 index 00000000..a1859e30 --- /dev/null +++ b/src/Bravellian.Platform.Modularity.Api/WebhookAdapterResponse.cs @@ -0,0 +1,22 @@ +// 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; + +/// +/// Adapter response that can be mapped to HTTP or queue responses. +/// +/// Outcome the transport should emit. +/// Optional reason for retries. +public sealed record WebhookAdapterResponse(WebhookOutcomeType Outcome, string? Reason = null); diff --git a/src/Bravellian.Platform.Modularity.Api/WebhookEngineAdapter.cs b/src/Bravellian.Platform.Modularity.Api/WebhookEngineAdapter.cs new file mode 100644 index 00000000..6db6d8d6 --- /dev/null +++ b/src/Bravellian.Platform.Modularity.Api/WebhookEngineAdapter.cs @@ -0,0 +1,69 @@ +// 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; + +/// +/// Adapter that connects webhook engines to HTTP-style transports. +/// +public sealed class WebhookEngineAdapter +{ + private readonly ModuleEngineDiscoveryService discovery; + private readonly IServiceProvider services; + private readonly IWebhookSignatureValidator signatureValidator; + + /// + /// Initializes a new instance of the class. + /// + public WebhookEngineAdapter(ModuleEngineDiscoveryService discovery, IServiceProvider services, IWebhookSignatureValidator signatureValidator) + { + this.discovery = discovery; + this.services = services; + this.signatureValidator = signatureValidator; + } + + /// + /// Dispatches a webhook request to a registered engine. + /// + public async Task DispatchAsync(WebhookAdapterRequest request, CancellationToken cancellationToken) + { + 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) + { + if (!signatureValidator.Validate(security, request.Headers, request.RawBody, request.Signature)) + { + return new WebhookAdapterResponse(WebhookOutcomeType.Retry, "Signature validation failed"); + } + } + + if (string.IsNullOrWhiteSpace(request.IdempotencyKey) && descriptor.Manifest.Security?.IdempotencyWindow is not null) + { + return new WebhookAdapterResponse(WebhookOutcomeType.Retry, "Missing idempotency key"); + } + + var typedDescriptor = descriptor as ModuleEngineDescriptor> + ?? throw new InvalidOperationException($"Engine '{descriptor.Manifest.Id}' does not implement expected webhook contract."); + + var engine = discovery.ResolveEngine(typedDescriptor, services) + ?? throw new InvalidOperationException($"Engine '{descriptor.Manifest.Id}' does not implement expected webhook contract."); + + var outcome = await engine.HandleAsync( + new WebhookRequest(request.Provider, request.EventType, request.Payload, request.IdempotencyKey, request.Attempt), + cancellationToken).ConfigureAwait(false); + + return new WebhookAdapterResponse(outcome.Outcome, outcome.Reason); + } +} diff --git a/src/Bravellian.Platform.Modularity.Core/BackgroundModuleServiceCollectionExtensions.cs b/src/Bravellian.Platform.Modularity.Core/BackgroundModuleServiceCollectionExtensions.cs index 584991cf..1fc432cd 100644 --- a/src/Bravellian.Platform.Modularity.Core/BackgroundModuleServiceCollectionExtensions.cs +++ b/src/Bravellian.Platform.Modularity.Core/BackgroundModuleServiceCollectionExtensions.cs @@ -32,6 +32,7 @@ public static IServiceCollection AddBackgroundModuleServices( ILoggerFactory? loggerFactory = null) { ModuleRegistry.InitializeModules(ModuleCategory.Background, configuration, services, loggerFactory); + services.AddSingleton(); return services; } } diff --git a/src/Bravellian.Platform.Modularity.Core/EngineKind.cs b/src/Bravellian.Platform.Modularity.Core/EngineKind.cs new file mode 100644 index 00000000..c41ae806 --- /dev/null +++ b/src/Bravellian.Platform.Modularity.Core/EngineKind.cs @@ -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; + +/// +/// Supported engine types. Engines are framework-agnostic and must not depend on transport concerns. +/// +public enum EngineKind +{ + /// + /// UI-first engines that produce view models and navigation outcomes. + /// + Ui, + + /// + /// Webhook engines that react to external callbacks. + /// + Webhook, +} diff --git a/src/Bravellian.Platform.Modularity.Core/IEngineModule.cs b/src/Bravellian.Platform.Modularity.Core/IEngineModule.cs new file mode 100644 index 00000000..f7ce6ff4 --- /dev/null +++ b/src/Bravellian.Platform.Modularity.Core/IEngineModule.cs @@ -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; + +/// +/// Marker interface for modules that expose engines. +/// +public interface IEngineModule +{ + /// + /// Provides engine descriptors for the module. + /// + IEnumerable DescribeEngines(); +} diff --git a/src/Bravellian.Platform.Modularity.Core/IModuleEngineDescriptor.cs b/src/Bravellian.Platform.Modularity.Core/IModuleEngineDescriptor.cs new file mode 100644 index 00000000..f2404736 --- /dev/null +++ b/src/Bravellian.Platform.Modularity.Core/IModuleEngineDescriptor.cs @@ -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; + +/// +/// Base abstraction for an engine descriptor. Implementations should remain transport agnostic. +/// +public interface IModuleEngineDescriptor +{ + string ModuleKey { get; } + + ModuleEngineManifest Manifest { get; } + + Type ContractType { get; } + + object Create(IServiceProvider serviceProvider); +} diff --git a/src/Bravellian.Platform.Modularity.Core/IUiEngine.cs b/src/Bravellian.Platform.Modularity.Core/IUiEngine.cs new file mode 100644 index 00000000..bbf73904 --- /dev/null +++ b/src/Bravellian.Platform.Modularity.Core/IUiEngine.cs @@ -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; + +/// +/// Generic UI engine contract that operates on DTOs and produces view models. +/// +/// Input DTO. +/// View model output. +public interface IUiEngine +{ + /// + /// Executes the engine using the provided command DTO. + /// + /// Command DTO. + /// Cancellation token. + /// A view model and any navigation tokens emitted. + Task> ExecuteAsync(TInput command, CancellationToken cancellationToken); +} diff --git a/src/Bravellian.Platform.Modularity.Core/IWebhookEngine.cs b/src/Bravellian.Platform.Modularity.Core/IWebhookEngine.cs new file mode 100644 index 00000000..0bc25cb8 --- /dev/null +++ b/src/Bravellian.Platform.Modularity.Core/IWebhookEngine.cs @@ -0,0 +1,27 @@ +// 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; + +/// +/// Webhook engine contract. Engines decide the outcome without transport coupling. +/// +/// Webhook payload type. +public interface IWebhookEngine +{ + /// + /// Handles a webhook request. + /// + Task HandleAsync(WebhookRequest request, CancellationToken cancellationToken); +} diff --git a/src/Bravellian.Platform.Modularity.Core/ModuleEngineAdapterHints.cs b/src/Bravellian.Platform.Modularity.Core/ModuleEngineAdapterHints.cs new file mode 100644 index 00000000..40868005 --- /dev/null +++ b/src/Bravellian.Platform.Modularity.Core/ModuleEngineAdapterHints.cs @@ -0,0 +1,30 @@ +// 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; + +/// +/// Adapter-level hints used by hosts to wire up engines to transports. +/// +/// True if the adapter must expose the raw request body to the engine. +/// True if the adapter must expose raw headers. +/// True if the adapter should support verification/challenge responses. +/// True if the adapter must enforce authentication. +/// True if the adapter must enforce tenancy. +public sealed record ModuleEngineAdapterHints( + bool RequiresRawRequestBody = false, + bool RequiresRawHeaders = false, + bool SupportsChallengeResponses = false, + bool RequiresAuthenticatedUser = false, + bool RequiresTenantContext = false); diff --git a/src/Bravellian.Platform.Modularity.Core/ModuleEngineCapabilities.cs b/src/Bravellian.Platform.Modularity.Core/ModuleEngineCapabilities.cs new file mode 100644 index 00000000..b355a28b --- /dev/null +++ b/src/Bravellian.Platform.Modularity.Core/ModuleEngineCapabilities.cs @@ -0,0 +1,28 @@ +// 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; + +/// +/// Declares the actions and events an engine can process. +/// +/// Named commands/actions the engine supports. +/// Events emitted by the engine. +/// Indicates async execution is supported. +/// Indicates streaming updates are supported. +public sealed record ModuleEngineCapabilities( + IReadOnlyCollection Actions, + IReadOnlyCollection Events, + bool SupportsAsync = true, + bool SupportsStreaming = false); diff --git a/src/Bravellian.Platform.Modularity.Core/ModuleEngineCompatibility.cs b/src/Bravellian.Platform.Modularity.Core/ModuleEngineCompatibility.cs new file mode 100644 index 00000000..aadfd600 --- /dev/null +++ b/src/Bravellian.Platform.Modularity.Core/ModuleEngineCompatibility.cs @@ -0,0 +1,22 @@ +// 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; + +/// +/// Compatibility metadata for engine evolution. +/// +/// Minimum host version required. +/// Human-readable notes for breaking changes. +public sealed record ModuleEngineCompatibility(string? MinHostVersion, string? BreakingChanges); diff --git a/src/Bravellian.Platform.Modularity.Core/ModuleEngineDescriptor.cs b/src/Bravellian.Platform.Modularity.Core/ModuleEngineDescriptor.cs new file mode 100644 index 00000000..0fc2c4c8 --- /dev/null +++ b/src/Bravellian.Platform.Modularity.Core/ModuleEngineDescriptor.cs @@ -0,0 +1,32 @@ +// 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; + +/// +/// Strongly typed engine descriptor registered by a module. Factories are provided by modules and consumed by adapters/hosts. +/// +/// Engine contract interface (e.g., ). +/// Module key that owns the engine. +/// Engine manifest metadata. +/// Factory that resolves the engine instance from an . +public sealed record ModuleEngineDescriptor( + string ModuleKey, + ModuleEngineManifest Manifest, + Func Factory) : IModuleEngineDescriptor where TContract : notnull +{ + public Type ContractType => typeof(TContract); + + public object Create(IServiceProvider serviceProvider) => Factory(serviceProvider)!; +} diff --git a/src/Bravellian.Platform.Modularity.Core/ModuleEngineDiscoveryService.cs b/src/Bravellian.Platform.Modularity.Core/ModuleEngineDiscoveryService.cs new file mode 100644 index 00000000..04afe20d --- /dev/null +++ b/src/Bravellian.Platform.Modularity.Core/ModuleEngineDiscoveryService.cs @@ -0,0 +1,59 @@ +// 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; + +/// +/// Engine discovery service used by adapters and hosts. +/// +public sealed class ModuleEngineDiscoveryService +{ + /// + /// Lists all engines registered by modules. + /// + public IReadOnlyCollection List() => ModuleEngineRegistry.GetEngines(); + + /// + /// Lists engines filtered by kind or feature area. + /// + public IReadOnlyCollection List(EngineKind? kind, string? featureArea = null) + { + return ModuleEngineRegistry.GetEngines() + .Where(e => (!kind.HasValue || e.Manifest.Kind == kind.Value) + && (featureArea is null || string.Equals(e.Manifest.FeatureArea, featureArea, StringComparison.OrdinalIgnoreCase))) + .ToArray(); + } + + /// + /// Resolves a webhook engine by provider and event type. + /// + public IModuleEngineDescriptor? ResolveWebhookEngine(string provider, string eventType) => ModuleEngineRegistry.FindWebhookEngine(provider, eventType); + + /// + /// Resolves an engine descriptor by module and engine identifier. + /// + public IModuleEngineDescriptor? ResolveById(string moduleKey, string engineId) => ModuleEngineRegistry.FindById(moduleKey, engineId); + + /// + /// Resolves an engine instance for a descriptor. + /// + public TContract ResolveEngine(ModuleEngineDescriptor descriptor, IServiceProvider serviceProvider) + where TContract : notnull + => descriptor.Factory(serviceProvider); + + /// + /// Resolves an engine instance for a descriptor when only the contract type is known at runtime. + /// + public object ResolveEngine(IModuleEngineDescriptor descriptor, IServiceProvider serviceProvider) => descriptor.Create(serviceProvider); +} diff --git a/src/Bravellian.Platform.Modularity.Core/ModuleEngineManifest.cs b/src/Bravellian.Platform.Modularity.Core/ModuleEngineManifest.cs new file mode 100644 index 00000000..d7a98d28 --- /dev/null +++ b/src/Bravellian.Platform.Modularity.Core/ModuleEngineManifest.cs @@ -0,0 +1,48 @@ +// 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; + +/// +/// Describes a module engine in a transport-agnostic manner. +/// +/// Engine identifier scoped to the module. +/// Contract version for the engine. +/// Human readable description. +/// Engine kind (UI or webhook). +/// Optional feature area/page grouping. +/// Action/event capabilities. +/// Input schemas. +/// Output schemas. +/// Navigation tokens understood by adapters. +/// Services the host must supply via DI. +/// Transport-level hints for adapters. +/// Security metadata, typically for webhook engines. +/// Compatibility and version notes. +/// Webhook event metadata advertised by the engine. +public sealed record ModuleEngineManifest( + string Id, + string Version, + string Description, + EngineKind Kind, + string? FeatureArea = null, + ModuleEngineCapabilities? Capabilities = null, + IReadOnlyCollection? Inputs = null, + IReadOnlyCollection? Outputs = null, + ModuleEngineNavigationHints? NavigationHints = null, + IReadOnlyCollection? RequiredServices = null, + ModuleEngineAdapterHints? AdapterHints = null, + ModuleEngineSecurity? Security = null, + ModuleEngineCompatibility? Compatibility = null, + IReadOnlyCollection? WebhookMetadata = null); diff --git a/src/Bravellian.Platform.Modularity.Core/ModuleEngineNavigationHints.cs b/src/Bravellian.Platform.Modularity.Core/ModuleEngineNavigationHints.cs new file mode 100644 index 00000000..83e663ec --- /dev/null +++ b/src/Bravellian.Platform.Modularity.Core/ModuleEngineNavigationHints.cs @@ -0,0 +1,21 @@ +// 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; + +/// +/// Navigation hints are abstract tokens adapters map to routes, dialogs, or screens. +/// +/// Well-known navigation tokens emitted by the engine. +public sealed record ModuleEngineNavigationHints(IReadOnlyCollection Tokens); diff --git a/src/Bravellian.Platform.Modularity.Core/ModuleEngineRegistry.cs b/src/Bravellian.Platform.Modularity.Core/ModuleEngineRegistry.cs new file mode 100644 index 00000000..edcce4b2 --- /dev/null +++ b/src/Bravellian.Platform.Modularity.Core/ModuleEngineRegistry.cs @@ -0,0 +1,70 @@ +// 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 System.Collections.Concurrent; + +namespace Bravellian.Platform.Modularity; + +/// +/// Registry for transport-agnostic module engines. +/// +internal static class ModuleEngineRegistry +{ + private static readonly ConcurrentDictionary> Engines = new(StringComparer.OrdinalIgnoreCase); + + public static void Register(string moduleKey, IEnumerable descriptors) + { + var list = Engines.GetOrAdd(moduleKey, _ => new List()); + lock (list) + { + foreach (var descriptor in descriptors) + { + var exists = list.Any(existing => string.Equals(existing.ModuleKey, descriptor.ModuleKey, StringComparison.OrdinalIgnoreCase) + && string.Equals(existing.Manifest.Id, descriptor.Manifest.Id, StringComparison.OrdinalIgnoreCase) + && existing.ContractType == descriptor.ContractType); + if (!exists) + { + list.Add(descriptor); + } + } + } + } + + public static IReadOnlyCollection GetEngines() + { + return Engines.Values.SelectMany(x => x).ToArray(); + } + + public static IModuleEngineDescriptor? FindWebhookEngine(string provider, string eventType) + { + return GetEngines() + .Where(e => e.Manifest.Kind == EngineKind.Webhook) + .SelectMany(descriptor => descriptor.Manifest.WebhookMetadata?.Select(meta => (descriptor, meta)) + ?? Array.Empty<(IModuleEngineDescriptor descriptor, ModuleEngineWebhookMetadata meta)>()) + .FirstOrDefault(pair => string.Equals(pair.meta.Provider, provider, StringComparison.OrdinalIgnoreCase) + && string.Equals(pair.meta.EventType, eventType, StringComparison.OrdinalIgnoreCase)) + .descriptor; + } + + public static IModuleEngineDescriptor? FindById(string moduleKey, string engineId) + { + return GetEngines().FirstOrDefault(e => string.Equals(e.ModuleKey, moduleKey, StringComparison.OrdinalIgnoreCase) + && string.Equals(e.Manifest.Id, engineId, StringComparison.OrdinalIgnoreCase)); + } + + public static void Reset() + { + Engines.Clear(); + } +} diff --git a/src/Bravellian.Platform.Modularity.Core/ModuleEngineSchema.cs b/src/Bravellian.Platform.Modularity.Core/ModuleEngineSchema.cs new file mode 100644 index 00000000..65b904b9 --- /dev/null +++ b/src/Bravellian.Platform.Modularity.Core/ModuleEngineSchema.cs @@ -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; + +/// +/// Declares schema hints for engine inputs or outputs. +/// +/// Schema name or role (e.g., "command", "payload"). +/// Associated CLR type for serialization. +/// Additional notes or validation hints. +public sealed record ModuleEngineSchema(string Name, Type ClrType, string? Notes = null); diff --git a/src/Bravellian.Platform.Modularity.Core/ModuleEngineSecurity.cs b/src/Bravellian.Platform.Modularity.Core/ModuleEngineSecurity.cs new file mode 100644 index 00000000..7f33dff7 --- /dev/null +++ b/src/Bravellian.Platform.Modularity.Core/ModuleEngineSecurity.cs @@ -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; + +/// +/// Security metadata used by adapters for validation. +/// +/// Expected signature algorithm (e.g., HMAC-SHA256). +/// Scope/identifier for retrieving secrets. +/// Optional idempotency window hint. +public sealed record ModuleEngineSecurity( + ModuleSignatureAlgorithm SignatureAlgorithm, + string SecretScope, + TimeSpan? IdempotencyWindow = null); diff --git a/src/Bravellian.Platform.Modularity.Core/ModuleEngineWebhookMetadata.cs b/src/Bravellian.Platform.Modularity.Core/ModuleEngineWebhookMetadata.cs new file mode 100644 index 00000000..3aee88c0 --- /dev/null +++ b/src/Bravellian.Platform.Modularity.Core/ModuleEngineWebhookMetadata.cs @@ -0,0 +1,30 @@ +// 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; + +/// +/// Webhook event metadata advertised by webhook engines. +/// +/// Source provider of the webhook (e.g., Postmark). +/// Event type identifier. +/// Payload schema type. +/// Services required for dispatch. +/// Retry policy hint. +public sealed record ModuleEngineWebhookMetadata( + string Provider, + string EventType, + ModuleEngineSchema PayloadSchema, + IReadOnlyCollection? RequiredServices = null, + int? Retries = null); diff --git a/src/Bravellian.Platform.Modularity.Core/ModuleNavigationToken.cs b/src/Bravellian.Platform.Modularity.Core/ModuleNavigationToken.cs new file mode 100644 index 00000000..594e8a72 --- /dev/null +++ b/src/Bravellian.Platform.Modularity.Core/ModuleNavigationToken.cs @@ -0,0 +1,20 @@ +// 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; + +/// +/// Typed navigation token an engine can emit. Adapters map these to concrete routes, dialogs, or screens. +/// +public sealed record ModuleNavigationToken(string Token, NavigationTargetKind TargetKind, string? Description = null); diff --git a/src/Bravellian.Platform.Modularity.Core/ModuleRegistry.cs b/src/Bravellian.Platform.Modularity.Core/ModuleRegistry.cs index 0d17ce53..81d065d7 100644 --- a/src/Bravellian.Platform.Modularity.Core/ModuleRegistry.cs +++ b/src/Bravellian.Platform.Modularity.Core/ModuleRegistry.cs @@ -81,6 +81,22 @@ internal static IReadOnlyCollection InitializeModules( var module = (TModule)CreateInstance(type, loggerFactory); LoadConfiguration(configuration, module, loggerFactory); RegisterInstance(module); + if (module is IEngineModule engineModule) + { + var descriptors = engineModule + .DescribeEngines() + .Select(descriptor => + { + if (!string.Equals(descriptor.ModuleKey, module.Key, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException($"Engine descriptor module key '{descriptor.ModuleKey}' must match module '{module.Key}'."); + } + + return descriptor; + }) + .ToArray(); + ModuleEngineRegistry.Register(module.Key, descriptors); + } initialized.Add(module); } @@ -122,6 +138,7 @@ internal static void Reset() } Instances.Clear(); + ModuleEngineRegistry.Reset(); } } diff --git a/src/Bravellian.Platform.Modularity.Core/ModuleSignatureAlgorithm.cs b/src/Bravellian.Platform.Modularity.Core/ModuleSignatureAlgorithm.cs new file mode 100644 index 00000000..ee42f734 --- /dev/null +++ b/src/Bravellian.Platform.Modularity.Core/ModuleSignatureAlgorithm.cs @@ -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; + +/// +/// Enumerates supported signature algorithms for webhook verification. +/// +public enum ModuleSignatureAlgorithm +{ + None = 0, + HmacSha256 = 1, + HmacSha512 = 2, + RsaSha256 = 3 +} diff --git a/src/Bravellian.Platform.Modularity.Core/NavigationTargetKind.cs b/src/Bravellian.Platform.Modularity.Core/NavigationTargetKind.cs new file mode 100644 index 00000000..ad9aade6 --- /dev/null +++ b/src/Bravellian.Platform.Modularity.Core/NavigationTargetKind.cs @@ -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; + +/// +/// Enumerates the supported navigation targets that hosts can map to runtime concepts. +/// +public enum NavigationTargetKind +{ + Route = 0, + Dialog = 1, + Component = 2, + External = 3 +} diff --git a/src/Bravellian.Platform.Modularity.Core/UiEngineResult.cs b/src/Bravellian.Platform.Modularity.Core/UiEngineResult.cs new file mode 100644 index 00000000..6b8c3315 --- /dev/null +++ b/src/Bravellian.Platform.Modularity.Core/UiEngineResult.cs @@ -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; + +/// +/// UI engine result containing the view model and optional navigation tokens/events. +/// +/// Resulting view model. +/// Navigation tokens emitted by the engine. +/// Events emitted for adapters to relay. +public sealed record UiEngineResult( + TViewModel ViewModel, + IReadOnlyCollection? Navigation = null, + IReadOnlyCollection? Events = null); diff --git a/src/Bravellian.Platform.Modularity.Core/WebhookOutcome.cs b/src/Bravellian.Platform.Modularity.Core/WebhookOutcome.cs new file mode 100644 index 00000000..6cd4d688 --- /dev/null +++ b/src/Bravellian.Platform.Modularity.Core/WebhookOutcome.cs @@ -0,0 +1,39 @@ +// 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; + +/// +/// Webhook outcome mapped to transport responses by adapters. +/// +/// Outcome type. +/// Optional reason for retries or re-queues. +/// Optional event payload to enqueue downstream. +public sealed record WebhookOutcome(WebhookOutcomeType Outcome, string? Reason = null, object? EnqueuedEvent = null) +{ + /// + /// Creates an acknowledge outcome. + /// + public static WebhookOutcome Acknowledge() => new(WebhookOutcomeType.Acknowledge); + + /// + /// Creates a retry outcome. + /// + public static WebhookOutcome Retry(string reason) => new(WebhookOutcomeType.Retry, reason); + + /// + /// Creates an enqueue outcome. + /// + public static WebhookOutcome Enqueue(object enqueued) => new(WebhookOutcomeType.EnqueueEvent, null, enqueued); +} diff --git a/src/Bravellian.Platform.Modularity.Core/WebhookOutcomeType.cs b/src/Bravellian.Platform.Modularity.Core/WebhookOutcomeType.cs new file mode 100644 index 00000000..97a490b6 --- /dev/null +++ b/src/Bravellian.Platform.Modularity.Core/WebhookOutcomeType.cs @@ -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; + +/// +/// Webhook outcome types understood by adapters. +/// +public enum WebhookOutcomeType +{ + /// + /// Acknowledge the webhook and stop retries. + /// + Acknowledge, + + /// + /// Ask the transport to retry the webhook. + /// + Retry, + + /// + /// Enqueue an event internally while acknowledging the webhook. + /// + EnqueueEvent, +} diff --git a/src/Bravellian.Platform.Modularity.Core/WebhookRequest.cs b/src/Bravellian.Platform.Modularity.Core/WebhookRequest.cs new file mode 100644 index 00000000..1d741e15 --- /dev/null +++ b/src/Bravellian.Platform.Modularity.Core/WebhookRequest.cs @@ -0,0 +1,30 @@ +// 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; + +/// +/// Incoming webhook request passed to webhook engines. +/// +/// Provider identifier. +/// Event type identifier. +/// Deserialized payload. +/// Idempotency key for replay protection. +/// Current delivery attempt. +public sealed record WebhookRequest( + string Provider, + string EventType, + TPayload Payload, + string IdempotencyKey, + int Attempt = 0); diff --git a/src/Bravellian.Platform.Modularity.FullStack/FullStackModuleServiceCollectionExtensions.cs b/src/Bravellian.Platform.Modularity.FullStack/FullStackModuleServiceCollectionExtensions.cs index d6546b4d..91da828c 100644 --- a/src/Bravellian.Platform.Modularity.FullStack/FullStackModuleServiceCollectionExtensions.cs +++ b/src/Bravellian.Platform.Modularity.FullStack/FullStackModuleServiceCollectionExtensions.cs @@ -42,6 +42,7 @@ public static IServiceCollection AddFullStackModuleServices( services.AddSingleton(module); } + services.AddSingleton(); services.AddSingleton(); return services; } diff --git a/src/Bravellian.Platform.Modularity.FullStack/UiAdapterResponse.cs b/src/Bravellian.Platform.Modularity.FullStack/UiAdapterResponse.cs new file mode 100644 index 00000000..fbd75d76 --- /dev/null +++ b/src/Bravellian.Platform.Modularity.FullStack/UiAdapterResponse.cs @@ -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; + +/// +/// Adapter response used by UI hosts. Navigation tokens are mapped to routes/pages by the host. +/// +/// View model emitted by the engine. +/// Navigation tokens for the host to interpret. +/// Domain events emitted by the engine. +public sealed record UiAdapterResponse( + TViewModel ViewModel, + IReadOnlyCollection? NavigationTargets, + IReadOnlyCollection? Events); diff --git a/src/Bravellian.Platform.Modularity.FullStack/UiEngineAdapter.cs b/src/Bravellian.Platform.Modularity.FullStack/UiEngineAdapter.cs new file mode 100644 index 00000000..c08bdfbb --- /dev/null +++ b/src/Bravellian.Platform.Modularity.FullStack/UiEngineAdapter.cs @@ -0,0 +1,52 @@ +// 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; + +/// +/// Adapter that maps UI engine contracts to host navigation tokens. +/// +public sealed class UiEngineAdapter +{ + private readonly ModuleEngineDiscoveryService discoveryService; + private readonly IServiceProvider services; + + /// + /// Initializes a new instance of the class. + /// + public UiEngineAdapter(ModuleEngineDiscoveryService discoveryService, IServiceProvider services) + { + this.discoveryService = discoveryService; + this.services = services; + } + + /// + /// Executes a UI engine and returns a transport-ready response. + /// + public async Task> ExecuteAsync(string moduleKey, string engineId, TInput command, CancellationToken cancellationToken) + { + var descriptor = discoveryService.ResolveById(moduleKey, engineId) + ?? throw new InvalidOperationException($"No UI engine registered with id '{engineId}' for module '{moduleKey}'."); + + var typedDescriptor = descriptor as ModuleEngineDescriptor> + ?? throw new InvalidOperationException($"Engine '{engineId}' does not implement the expected UI contract."); + + var engine = discoveryService.ResolveEngine(typedDescriptor, services) + ?? throw new InvalidOperationException($"Engine '{engineId}' does not implement the expected UI contract."); + + var result = await engine.ExecuteAsync(command, cancellationToken).ConfigureAwait(false); + + return new UiAdapterResponse(result.ViewModel, result.Navigation, result.Events); + } +} diff --git a/tests/Bravellian.Platform.Tests/EngineRefactoringTests.cs b/tests/Bravellian.Platform.Tests/EngineRefactoringTests.cs new file mode 100644 index 00000000..a89346c5 --- /dev/null +++ b/tests/Bravellian.Platform.Tests/EngineRefactoringTests.cs @@ -0,0 +1,225 @@ +// 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 Bravellian.Platform.Modularity; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Bravellian.Platform.Tests; + +public sealed class EngineRefactoringTests +{ + public EngineRefactoringTests() + { + ModuleEngineRegistry.Reset(); + FullStackModuleRegistry.RegisterFullStackModule(); + } + + [Fact] + public async Task Ui_engine_invocation_returns_view_model_and_navigation_tokens() + { + var provider = BuildServiceProvider(); + var adapter = new UiEngineAdapter(provider.GetRequiredService(), provider); + + var response = await adapter.ExecuteAsync("fake-module", "ui.login", new LoginCommand("admin", "pass"), CancellationToken.None); + + Assert.Equal("admin", response.ViewModel.Username); + Assert.Contains(response.NavigationTargets!, token => token.Token == "dashboard" && token.TargetKind == NavigationTargetKind.Route); + Assert.Contains("event:login", response.Events); + } + + [Fact] + public async Task Webhook_adapter_enforces_signature_and_maps_outcome() + { + var provider = BuildServiceProvider(); + var adapter = new WebhookEngineAdapter(provider.GetRequiredService(), provider, provider.GetRequiredService()); + + var request = new WebhookAdapterRequest( + "postmark", + "bounce", + new Dictionary { ["X-Signature"] = "postmark:raw-body" }, + "raw-body", + "idemp-1", + 1, + null, + new PostmarkBouncePayload("HardBounce", "Mail rejected")); + + var response = await adapter.DispatchAsync(request, CancellationToken.None); + + Assert.Equal(WebhookOutcomeType.EnqueueEvent, response.Outcome); + } + + [Fact] + public void Discovery_service_filters_engines() + { + var provider = BuildServiceProvider(); + var discovery = provider.GetRequiredService(); + + var allEngines = discovery.List(); + Assert.Contains(allEngines, e => e.Manifest.Kind == EngineKind.Ui && e.ModuleKey == "fake-module"); + + var webhookEngines = discovery.List(EngineKind.Webhook, featureArea: "Notifications"); + Assert.Contains(webhookEngines, e => e.Manifest.Id == "webhook.postmark" && e.ModuleKey == "fake-module"); + + var resolved = discovery.ResolveWebhookEngine("postmark", "bounce"); + Assert.NotNull(resolved); + Assert.Equal("fake-module", resolved!.ModuleKey); + } + + private static ServiceProvider BuildServiceProvider() + { + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddFullStackModuleServices(new ConfigurationBuilder().Build()); + services.AddSingleton(); + services.AddSingleton(); + return services.BuildServiceProvider(); + } + + private sealed class FakeEngineModule : IFullStackModule, INavigationModuleMetadata, IEngineModule + { + public string Key => "fake-module"; + + public string DisplayName => "Fake Engines"; + + public string AreaName => "Fake"; + + public string NavigationGroup => "Engines"; + + public int NavigationOrder => 0; + + public IEnumerable GetOptionalConfigurationKeys() => Array.Empty(); + + public IEnumerable GetRequiredConfigurationKeys() => Array.Empty(); + + public void LoadConfiguration(IReadOnlyDictionary required, IReadOnlyDictionary optional) + { + } + + public void AddModuleServices(IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + } + + public void RegisterHealthChecks(ModuleHealthCheckBuilder builder) + { + } + + public IEnumerable GetNavLinks() + { + yield return ModuleNavLink.Create("Home", "/", 0, null); + } + + public void ConfigureRazorPages(RazorPagesOptions options) + { + } + + public void MapApiEndpoints(Microsoft.AspNetCore.Routing.RouteGroupBuilder group) + { + } + + public IEnumerable DescribeEngines() + { + yield return new ModuleEngineDescriptor>( + Key, + new ModuleEngineManifest( + "ui.login", + "1.0", + "Login page engine", + EngineKind.Ui, + "Auth", + new ModuleEngineCapabilities(new[] { "login" }, new[] { "login.loggedIn" }, SupportsStreaming: false), + new[] { new ModuleEngineSchema("command", typeof(LoginCommand)) }, + new[] { new ModuleEngineSchema("viewModel", typeof(LoginViewModel)) }, + new ModuleEngineNavigationHints(new[] { new ModuleNavigationToken("dashboard", NavigationTargetKind.Route) }), + new[] { nameof(LoginUiEngine) }, + new ModuleEngineAdapterHints(false, false, false, false, true), + null, + new ModuleEngineCompatibility("1.0", null)), + sp => sp.GetRequiredService()); + + yield return new ModuleEngineDescriptor>( + Key, + new ModuleEngineManifest( + "webhook.postmark", + "1.0", + "Postmark bounce webhook handler", + EngineKind.Webhook, + "Notifications", + new ModuleEngineCapabilities(new[] { "handle" }, new[] { "bounce.received" }), + new[] { new ModuleEngineSchema("payload", typeof(PostmarkBouncePayload)) }, + Array.Empty(), + null, + new[] { nameof(PostmarkWebhookEngine) }, + new ModuleEngineAdapterHints(true, true, true, false, true), + new ModuleEngineSecurity(ModuleSignatureAlgorithm.HmacSha256, "postmark", TimeSpan.FromMinutes(10)), + new ModuleEngineCompatibility("1.0", "Initial"), + new[] + { + new ModuleEngineWebhookMetadata("postmark", "bounce", new ModuleEngineSchema("payload", typeof(PostmarkBouncePayload)), new[] { "logging" }, Retries: 3), + }), + sp => sp.GetRequiredService()); + } + } + + private sealed record LoginCommand(string Username, string Password); + + private sealed record LoginViewModel(string Username, bool Success); + + private sealed class LoginUiEngine : IUiEngine + { + public Task> ExecuteAsync(LoginCommand command, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(command.Username)) + { + throw new ArgumentException("Username is required", nameof(command)); + } + + var viewModel = new LoginViewModel(command.Username, true); + return Task.FromResult(new UiEngineResult( + viewModel, + new[] { new ModuleNavigationToken("dashboard", NavigationTargetKind.Route) }, + new[] { "event:login" })); + } + } + + private sealed record PostmarkBouncePayload(string Type, string Description); + + private sealed class PostmarkWebhookEngine : IWebhookEngine + { + public Task HandleAsync(WebhookRequest request, CancellationToken cancellationToken) + { + if (request.Payload.Type.Equals("HardBounce", StringComparison.OrdinalIgnoreCase)) + { + return Task.FromResult(WebhookOutcome.Enqueue(new { request.Payload.Description, request.EventType })); + } + + return Task.FromResult(WebhookOutcome.Acknowledge()); + } + } + + private sealed class TestSignatureValidator : IWebhookSignatureValidator + { + public bool Validate(ModuleEngineSecurity security, IReadOnlyDictionary headers, string rawBody, string? providedSignature) + { + headers.TryGetValue("X-Signature", out var headerSignature); + var signature = providedSignature ?? headerSignature; + return security.SignatureAlgorithm == ModuleSignatureAlgorithm.HmacSha256 + && signature == $"{security.SecretScope}:{rawBody}"; + } + } +} From 173596feaf7c0c6ebf45a8407aa86d4f9d5b3f83 Mon Sep 17 00:00:00 2001 From: Samuel McAravey Date: Sat, 20 Dec 2025 21:16:38 -0800 Subject: [PATCH 02/12] Update ModuleEngineRegistry.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../ModuleEngineRegistry.cs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/Bravellian.Platform.Modularity.Core/ModuleEngineRegistry.cs b/src/Bravellian.Platform.Modularity.Core/ModuleEngineRegistry.cs index edcce4b2..a8cbe563 100644 --- a/src/Bravellian.Platform.Modularity.Core/ModuleEngineRegistry.cs +++ b/src/Bravellian.Platform.Modularity.Core/ModuleEngineRegistry.cs @@ -43,7 +43,18 @@ public static void Register(string moduleKey, IEnumerable GetEngines() { - return Engines.Values.SelectMany(x => x).ToArray(); + // Take a thread-safe snapshot of all registered engines by copying each list under its lock. + var snapshots = new List(); + + foreach (var list in Engines.Values) + { + lock (list) + { + snapshots.Add(list.ToArray()); + } + } + + return snapshots.SelectMany(x => x).ToArray(); } public static IModuleEngineDescriptor? FindWebhookEngine(string provider, string eventType) From fa5c392850c61940c6816815c1a0987fa6026440 Mon Sep 17 00:00:00 2001 From: Samuel McAravey Date: Sat, 20 Dec 2025 21:17:00 -0800 Subject: [PATCH 03/12] Update ModuleEngineDiscoveryService.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../ModuleEngineDiscoveryService.cs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/Bravellian.Platform.Modularity.Core/ModuleEngineDiscoveryService.cs b/src/Bravellian.Platform.Modularity.Core/ModuleEngineDiscoveryService.cs index 04afe20d..59c92f40 100644 --- a/src/Bravellian.Platform.Modularity.Core/ModuleEngineDiscoveryService.cs +++ b/src/Bravellian.Platform.Modularity.Core/ModuleEngineDiscoveryService.cs @@ -50,8 +50,17 @@ public IReadOnlyCollection List(EngineKind? kind, strin /// public TContract ResolveEngine(ModuleEngineDescriptor descriptor, IServiceProvider serviceProvider) where TContract : notnull - => descriptor.Factory(serviceProvider); + { + var instance = descriptor.Factory(serviceProvider); + + if (instance is null) + { + throw new System.InvalidOperationException( + $"The factory for module engine '{descriptor.Manifest.ModuleKey}/{descriptor.Manifest.Id}' returned null."); + } + return instance; + } /// /// Resolves an engine instance for a descriptor when only the contract type is known at runtime. /// From 4c3349b940388837ec4211baaab20eeb366115c9 Mon Sep 17 00:00:00 2001 From: Samuel McAravey Date: Sat, 20 Dec 2025 21:18:07 -0800 Subject: [PATCH 04/12] Update WebhookEngineAdapter.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/Bravellian.Platform.Modularity.Api/WebhookEngineAdapter.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Bravellian.Platform.Modularity.Api/WebhookEngineAdapter.cs b/src/Bravellian.Platform.Modularity.Api/WebhookEngineAdapter.cs index 6db6d8d6..c942ddaf 100644 --- a/src/Bravellian.Platform.Modularity.Api/WebhookEngineAdapter.cs +++ b/src/Bravellian.Platform.Modularity.Api/WebhookEngineAdapter.cs @@ -57,8 +57,7 @@ public async Task DispatchAsync(WebhookAdapter var typedDescriptor = descriptor as ModuleEngineDescriptor> ?? throw new InvalidOperationException($"Engine '{descriptor.Manifest.Id}' does not implement expected webhook contract."); - var engine = discovery.ResolveEngine(typedDescriptor, services) - ?? 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(request.Provider, request.EventType, request.Payload, request.IdempotencyKey, request.Attempt), From aff8d66dc7287f28fd8ec06d185fd39c801c92f7 Mon Sep 17 00:00:00 2001 From: Samuel McAravey Date: Sat, 20 Dec 2025 21:18:35 -0800 Subject: [PATCH 05/12] Update WebhookEngineAdapter.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../WebhookEngineAdapter.cs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/Bravellian.Platform.Modularity.Api/WebhookEngineAdapter.cs b/src/Bravellian.Platform.Modularity.Api/WebhookEngineAdapter.cs index c942ddaf..aa3a1e65 100644 --- a/src/Bravellian.Platform.Modularity.Api/WebhookEngineAdapter.cs +++ b/src/Bravellian.Platform.Modularity.Api/WebhookEngineAdapter.cs @@ -41,18 +41,19 @@ public async Task DispatchAsync(WebhookAdapter 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) + if (descriptor.Manifest.Security is { } security && + !signatureValidator.Validate(security, request.Headers, request.RawBody, request.Signature)) { - if (!signatureValidator.Validate(security, request.Headers, request.RawBody, request.Signature)) - { - return new WebhookAdapterResponse(WebhookOutcomeType.Retry, "Signature validation failed"); - } + return new WebhookAdapterResponse(WebhookOutcomeType.Retry, "Signature validation failed"); } if (string.IsNullOrWhiteSpace(request.IdempotencyKey) && descriptor.Manifest.Security?.IdempotencyWindow is not null) { return new WebhookAdapterResponse(WebhookOutcomeType.Retry, "Missing idempotency key"); } + { + return new WebhookAdapterResponse(WebhookOutcomeType.Retry, "Missing idempotency key"); + } var typedDescriptor = descriptor as ModuleEngineDescriptor> ?? throw new InvalidOperationException($"Engine '{descriptor.Manifest.Id}' does not implement expected webhook contract."); From b754ca00a0c5d461ad6507b35592e1becd0b7953 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sat, 20 Dec 2025 22:45:10 -0800 Subject: [PATCH 06/12] Fix adapter error handling and remove redundant null checks (#130) --- .../WebhookEngineAdapter.cs | 5 +---- .../ModuleEngineDiscoveryService.cs | 2 +- .../UiEngineAdapter.cs | 3 +-- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/Bravellian.Platform.Modularity.Api/WebhookEngineAdapter.cs b/src/Bravellian.Platform.Modularity.Api/WebhookEngineAdapter.cs index aa3a1e65..f787dc87 100644 --- a/src/Bravellian.Platform.Modularity.Api/WebhookEngineAdapter.cs +++ b/src/Bravellian.Platform.Modularity.Api/WebhookEngineAdapter.cs @@ -44,16 +44,13 @@ public async Task DispatchAsync(WebhookAdapter if (descriptor.Manifest.Security is { } security && !signatureValidator.Validate(security, request.Headers, request.RawBody, request.Signature)) { - return new WebhookAdapterResponse(WebhookOutcomeType.Retry, "Signature validation failed"); + return new WebhookAdapterResponse(WebhookOutcomeType.Acknowledge, "Signature validation failed"); } if (string.IsNullOrWhiteSpace(request.IdempotencyKey) && descriptor.Manifest.Security?.IdempotencyWindow is not null) { return new WebhookAdapterResponse(WebhookOutcomeType.Retry, "Missing idempotency key"); } - { - return new WebhookAdapterResponse(WebhookOutcomeType.Retry, "Missing idempotency key"); - } var typedDescriptor = descriptor as ModuleEngineDescriptor> ?? throw new InvalidOperationException($"Engine '{descriptor.Manifest.Id}' does not implement expected webhook contract."); diff --git a/src/Bravellian.Platform.Modularity.Core/ModuleEngineDiscoveryService.cs b/src/Bravellian.Platform.Modularity.Core/ModuleEngineDiscoveryService.cs index 59c92f40..f2444418 100644 --- a/src/Bravellian.Platform.Modularity.Core/ModuleEngineDiscoveryService.cs +++ b/src/Bravellian.Platform.Modularity.Core/ModuleEngineDiscoveryService.cs @@ -56,7 +56,7 @@ public TContract ResolveEngine(ModuleEngineDescriptor desc if (instance is null) { throw new System.InvalidOperationException( - $"The factory for module engine '{descriptor.Manifest.ModuleKey}/{descriptor.Manifest.Id}' returned null."); + $"The factory for module engine '{descriptor.ModuleKey}/{descriptor.Manifest.Id}' returned null."); } return instance; diff --git a/src/Bravellian.Platform.Modularity.FullStack/UiEngineAdapter.cs b/src/Bravellian.Platform.Modularity.FullStack/UiEngineAdapter.cs index c08bdfbb..1945f4d4 100644 --- a/src/Bravellian.Platform.Modularity.FullStack/UiEngineAdapter.cs +++ b/src/Bravellian.Platform.Modularity.FullStack/UiEngineAdapter.cs @@ -42,8 +42,7 @@ public async Task> ExecuteAsync> ?? throw new InvalidOperationException($"Engine '{engineId}' does not implement the expected UI contract."); - var engine = discoveryService.ResolveEngine(typedDescriptor, services) - ?? throw new InvalidOperationException($"Engine '{engineId}' does not implement the expected UI contract."); + var engine = discoveryService.ResolveEngine(typedDescriptor, services); var result = await engine.ExecuteAsync(command, cancellationToken).ConfigureAwait(false); From 0273b96b86ceab86680a4a583c466d557978b22d Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 21 Dec 2025 20:40:58 -0800 Subject: [PATCH 07/12] Address code review feedback: optimize registry lookups, add input validation, improve diagnostics (#131) Co-authored-by: SamuelMcAravey <11021165+SamuelMcAravey@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- docs/engine-overview.md | 3 +- .../WebhookEngineAdapter.cs | 19 +++++- .../ModuleEngineCapabilities.cs | 2 +- .../ModuleEngineRegistry.cs | 58 ++++++++++++++++--- .../ModuleRegistry.cs | 5 +- .../WebhookRequest.cs | 2 +- .../UiEngineAdapter.cs | 10 ++++ .../EngineRefactoringTests.cs | 54 +++++++++++++++++ 8 files changed, 139 insertions(+), 14 deletions(-) diff --git a/docs/engine-overview.md b/docs/engine-overview.md index f0bd384d..e4d98ac1 100644 --- a/docs/engine-overview.md +++ b/docs/engine-overview.md @@ -7,9 +7,10 @@ This repository now exposes transport-agnostic engines that sit inside each modu - **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. If validation fails, hosts should skip registration or surface a configuration error rather than invoking the engine. - Adds optional webhook metadata (`ModuleEngineWebhookMetadata`) for providers and event types. - **Engine descriptor** (`ModuleEngineDescriptor`) 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). +- **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 diff --git a/src/Bravellian.Platform.Modularity.Api/WebhookEngineAdapter.cs b/src/Bravellian.Platform.Modularity.Api/WebhookEngineAdapter.cs index f787dc87..67996872 100644 --- a/src/Bravellian.Platform.Modularity.Api/WebhookEngineAdapter.cs +++ b/src/Bravellian.Platform.Modularity.Api/WebhookEngineAdapter.cs @@ -38,6 +38,21 @@ public WebhookEngineAdapter(ModuleEngineDiscoveryService discovery, IServiceProv /// public async Task DispatchAsync(WebhookAdapterRequest request, CancellationToken cancellationToken) { + 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)); + } + + if (string.IsNullOrWhiteSpace(request.EventType)) + { + throw new ArgumentException("EventType must be a non-empty, non-whitespace string.", nameof(request)); + } + var descriptor = discovery.ResolveWebhookEngine(request.Provider, request.EventType) ?? throw new InvalidOperationException($"No webhook engine registered for provider '{request.Provider}' and event '{request.EventType}'."); @@ -49,7 +64,9 @@ public async Task DispatchAsync(WebhookAdapter if (string.IsNullOrWhiteSpace(request.IdempotencyKey) && descriptor.Manifest.Security?.IdempotencyWindow is not null) { - return new WebhookAdapterResponse(WebhookOutcomeType.Retry, "Missing idempotency key"); + return new WebhookAdapterResponse( + WebhookOutcomeType.Retry, + "Idempotency key is required when an idempotency window is configured for this provider."); } var typedDescriptor = descriptor as ModuleEngineDescriptor> diff --git a/src/Bravellian.Platform.Modularity.Core/ModuleEngineCapabilities.cs b/src/Bravellian.Platform.Modularity.Core/ModuleEngineCapabilities.cs index b355a28b..b55c802f 100644 --- a/src/Bravellian.Platform.Modularity.Core/ModuleEngineCapabilities.cs +++ b/src/Bravellian.Platform.Modularity.Core/ModuleEngineCapabilities.cs @@ -19,7 +19,7 @@ namespace Bravellian.Platform.Modularity; /// /// Named commands/actions the engine supports. /// Events emitted by the engine. -/// Indicates async execution is supported. +/// Reserved for future use. All engines are currently async by design (Task-based). This flag may be used by hosts to differentiate execution strategies in future versions. /// Indicates streaming updates are supported. public sealed record ModuleEngineCapabilities( IReadOnlyCollection Actions, diff --git a/src/Bravellian.Platform.Modularity.Core/ModuleEngineRegistry.cs b/src/Bravellian.Platform.Modularity.Core/ModuleEngineRegistry.cs index a8cbe563..95eba10b 100644 --- a/src/Bravellian.Platform.Modularity.Core/ModuleEngineRegistry.cs +++ b/src/Bravellian.Platform.Modularity.Core/ModuleEngineRegistry.cs @@ -59,19 +59,59 @@ public static IReadOnlyCollection GetEngines() public static IModuleEngineDescriptor? FindWebhookEngine(string provider, string eventType) { - return GetEngines() - .Where(e => e.Manifest.Kind == EngineKind.Webhook) - .SelectMany(descriptor => descriptor.Manifest.WebhookMetadata?.Select(meta => (descriptor, meta)) - ?? Array.Empty<(IModuleEngineDescriptor descriptor, ModuleEngineWebhookMetadata meta)>()) - .FirstOrDefault(pair => string.Equals(pair.meta.Provider, provider, StringComparison.OrdinalIgnoreCase) - && string.Equals(pair.meta.EventType, eventType, StringComparison.OrdinalIgnoreCase)) - .descriptor; + // Avoid creating a full snapshot; search lists directly under their locks. + foreach (var list in Engines.Values) + { + lock (list) + { + foreach (var descriptor in list) + { + if (descriptor.Manifest.Kind != EngineKind.Webhook) + { + continue; + } + + var metadataCollection = descriptor.Manifest.WebhookMetadata; + if (metadataCollection == null) + { + continue; + } + + foreach (var meta in metadataCollection) + { + if (string.Equals(meta.Provider, provider, StringComparison.OrdinalIgnoreCase) + && string.Equals(meta.EventType, eventType, StringComparison.OrdinalIgnoreCase)) + { + return descriptor; + } + } + } + } + } + + return null; } public static IModuleEngineDescriptor? FindById(string moduleKey, string engineId) { - return GetEngines().FirstOrDefault(e => string.Equals(e.ModuleKey, moduleKey, StringComparison.OrdinalIgnoreCase) - && string.Equals(e.Manifest.Id, engineId, StringComparison.OrdinalIgnoreCase)); + // Narrow the lookup to the specific moduleKey instead of scanning all engines. + if (!Engines.TryGetValue(moduleKey, out var list)) + { + return null; + } + + lock (list) + { + foreach (var descriptor in list) + { + if (string.Equals(descriptor.Manifest.Id, engineId, StringComparison.OrdinalIgnoreCase)) + { + return descriptor; + } + } + } + + return null; } public static void Reset() diff --git a/src/Bravellian.Platform.Modularity.Core/ModuleRegistry.cs b/src/Bravellian.Platform.Modularity.Core/ModuleRegistry.cs index 81d065d7..e2e41876 100644 --- a/src/Bravellian.Platform.Modularity.Core/ModuleRegistry.cs +++ b/src/Bravellian.Platform.Modularity.Core/ModuleRegistry.cs @@ -89,7 +89,10 @@ internal static IReadOnlyCollection InitializeModules( { if (!string.Equals(descriptor.ModuleKey, module.Key, StringComparison.OrdinalIgnoreCase)) { - throw new InvalidOperationException($"Engine descriptor module key '{descriptor.ModuleKey}' must match module '{module.Key}'."); + throw new InvalidOperationException( + $"Engine descriptor module key '{descriptor.ModuleKey}' must match its owning module key '{module.Key}'. " + + "Engine descriptors must use their owning module's key to ensure proper isolation and discovery. " + + "Update the engine descriptor's ModuleKey to match the module's Key."); } return descriptor; diff --git a/src/Bravellian.Platform.Modularity.Core/WebhookRequest.cs b/src/Bravellian.Platform.Modularity.Core/WebhookRequest.cs index 1d741e15..c5e1d889 100644 --- a/src/Bravellian.Platform.Modularity.Core/WebhookRequest.cs +++ b/src/Bravellian.Platform.Modularity.Core/WebhookRequest.cs @@ -21,7 +21,7 @@ namespace Bravellian.Platform.Modularity; /// Event type identifier. /// Deserialized payload. /// Idempotency key for replay protection. -/// Current delivery attempt. +/// Current delivery attempt (1-based; default is 0 for backwards compatibility, but callers should use 1 for the first attempt). public sealed record WebhookRequest( string Provider, string EventType, diff --git a/src/Bravellian.Platform.Modularity.FullStack/UiEngineAdapter.cs b/src/Bravellian.Platform.Modularity.FullStack/UiEngineAdapter.cs index 1945f4d4..1a591c76 100644 --- a/src/Bravellian.Platform.Modularity.FullStack/UiEngineAdapter.cs +++ b/src/Bravellian.Platform.Modularity.FullStack/UiEngineAdapter.cs @@ -36,6 +36,16 @@ public UiEngineAdapter(ModuleEngineDiscoveryService discoveryService, IServicePr /// public async Task> ExecuteAsync(string moduleKey, string engineId, TInput command, CancellationToken cancellationToken) { + if (string.IsNullOrWhiteSpace(moduleKey)) + { + throw new ArgumentException("Module key must be a non-empty, non-whitespace string.", nameof(moduleKey)); + } + + if (string.IsNullOrWhiteSpace(engineId)) + { + throw new ArgumentException("Engine ID must be a non-empty, non-whitespace string.", nameof(engineId)); + } + var descriptor = discoveryService.ResolveById(moduleKey, engineId) ?? throw new InvalidOperationException($"No UI engine registered with id '{engineId}' for module '{moduleKey}'."); diff --git a/tests/Bravellian.Platform.Tests/EngineRefactoringTests.cs b/tests/Bravellian.Platform.Tests/EngineRefactoringTests.cs index a89346c5..5a4d93f2 100644 --- a/tests/Bravellian.Platform.Tests/EngineRefactoringTests.cs +++ b/tests/Bravellian.Platform.Tests/EngineRefactoringTests.cs @@ -62,6 +62,60 @@ public async Task Webhook_adapter_enforces_signature_and_maps_outcome() Assert.Equal(WebhookOutcomeType.EnqueueEvent, response.Outcome); } + [Fact] + public async Task Webhook_adapter_rejects_invalid_signature() + { + var provider = BuildServiceProvider(); + var adapter = new WebhookEngineAdapter(provider.GetRequiredService(), provider, provider.GetRequiredService()); + + var request = new WebhookAdapterRequest( + "postmark", + "bounce", + new Dictionary { ["X-Signature"] = "invalid-signature" }, + "raw-body", + "idemp-1", + 1, + null, + new PostmarkBouncePayload("HardBounce", "Mail rejected")); + + var response = await adapter.DispatchAsync(request, CancellationToken.None); + + Assert.Equal(WebhookOutcomeType.Acknowledge, response.Outcome); + Assert.Equal("Signature validation failed", response.Reason); + } + + [Fact] + public async Task Webhook_adapter_requires_idempotency_key_when_configured() + { + var provider = BuildServiceProvider(); + var adapter = new WebhookEngineAdapter(provider.GetRequiredService(), provider, provider.GetRequiredService()); + + var request = new WebhookAdapterRequest( + "postmark", + "bounce", + new Dictionary { ["X-Signature"] = "postmark:raw-body" }, + "raw-body", + string.Empty, // Missing idempotency key + 1, + null, + new PostmarkBouncePayload("HardBounce", "Mail rejected")); + + var response = await adapter.DispatchAsync(request, CancellationToken.None); + + Assert.Equal(WebhookOutcomeType.Retry, response.Outcome); + Assert.Contains("Idempotency key is required", response.Reason); + } + + [Fact] + public async Task Ui_engine_exception_propagates_to_adapter() + { + var provider = BuildServiceProvider(); + var adapter = new UiEngineAdapter(provider.GetRequiredService(), provider); + + await Assert.ThrowsAsync(async () => + await adapter.ExecuteAsync("fake-module", "ui.login", new LoginCommand(string.Empty, "pass"), CancellationToken.None)); + } + [Fact] public void Discovery_service_filters_engines() { From c73ba1440aa09dce4b8bfc560fada817f8781b7c Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 21 Dec 2025 20:55:25 -0800 Subject: [PATCH 08/12] Fix race condition in ModuleEngineRegistry with ReaderWriterLockSlim (#132) Co-authored-by: SamuelMcAravey <11021165+SamuelMcAravey@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- .../ModuleEngineRegistry.cs | 76 ++++++++++++------- 1 file changed, 50 insertions(+), 26 deletions(-) diff --git a/src/Bravellian.Platform.Modularity.Core/ModuleEngineRegistry.cs b/src/Bravellian.Platform.Modularity.Core/ModuleEngineRegistry.cs index 95eba10b..ed65d04c 100644 --- a/src/Bravellian.Platform.Modularity.Core/ModuleEngineRegistry.cs +++ b/src/Bravellian.Platform.Modularity.Core/ModuleEngineRegistry.cs @@ -22,12 +22,16 @@ namespace Bravellian.Platform.Modularity; internal static class ModuleEngineRegistry { private static readonly ConcurrentDictionary> Engines = new(StringComparer.OrdinalIgnoreCase); + + // ReaderWriterLockSlim protects registry operations. No disposal needed as this is a static class with application lifetime. + private static readonly ReaderWriterLockSlim RegistryLock = new(); public static void Register(string moduleKey, IEnumerable descriptors) { - var list = Engines.GetOrAdd(moduleKey, _ => new List()); - lock (list) + RegistryLock.EnterWriteLock(); + try { + var list = Engines.GetOrAdd(moduleKey, _ => new List()); foreach (var descriptor in descriptors) { var exists = list.Any(existing => string.Equals(existing.ModuleKey, descriptor.ModuleKey, StringComparison.OrdinalIgnoreCase) @@ -39,30 +43,33 @@ public static void Register(string moduleKey, IEnumerable GetEngines() { - // Take a thread-safe snapshot of all registered engines by copying each list under its lock. - var snapshots = new List(); - - foreach (var list in Engines.Values) + RegistryLock.EnterReadLock(); + try { - lock (list) - { - snapshots.Add(list.ToArray()); - } + // Take a snapshot of all registered engines. The read lock ensures no modifications occur during enumeration. + return Engines.Values.SelectMany(list => list).ToArray(); + } + finally + { + RegistryLock.ExitReadLock(); } - - return snapshots.SelectMany(x => x).ToArray(); } public static IModuleEngineDescriptor? FindWebhookEngine(string provider, string eventType) { - // Avoid creating a full snapshot; search lists directly under their locks. - foreach (var list in Engines.Values) + RegistryLock.EnterReadLock(); + try { - lock (list) + // Search for webhook engine matching the provider and event type. + foreach (var list in Engines.Values) { foreach (var descriptor in list) { @@ -87,21 +94,26 @@ public static IReadOnlyCollection GetEngines() } } } - } - return null; + return null; + } + finally + { + RegistryLock.ExitReadLock(); + } } public static IModuleEngineDescriptor? FindById(string moduleKey, string engineId) { - // Narrow the lookup to the specific moduleKey instead of scanning all engines. - if (!Engines.TryGetValue(moduleKey, out var list)) + RegistryLock.EnterReadLock(); + try { - return null; - } + // Narrow the lookup to the specific moduleKey. + if (!Engines.TryGetValue(moduleKey, out var list)) + { + return null; + } - lock (list) - { foreach (var descriptor in list) { if (string.Equals(descriptor.Manifest.Id, engineId, StringComparison.OrdinalIgnoreCase)) @@ -109,13 +121,25 @@ public static IReadOnlyCollection GetEngines() return descriptor; } } - } - return null; + return null; + } + finally + { + RegistryLock.ExitReadLock(); + } } public static void Reset() { - Engines.Clear(); + RegistryLock.EnterWriteLock(); + try + { + Engines.Clear(); + } + finally + { + RegistryLock.ExitWriteLock(); + } } } From b920e7f815856c7a370d8844a94e506a8c70e966 Mon Sep 17 00:00:00 2001 From: Samuel McAravey Date: Sun, 21 Dec 2025 21:14:16 -0800 Subject: [PATCH 09/12] Update src/Bravellian.Platform.Modularity.Api/WebhookEngineAdapter.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../WebhookEngineAdapter.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/Bravellian.Platform.Modularity.Api/WebhookEngineAdapter.cs b/src/Bravellian.Platform.Modularity.Api/WebhookEngineAdapter.cs index 67996872..d9e71c30 100644 --- a/src/Bravellian.Platform.Modularity.Api/WebhookEngineAdapter.cs +++ b/src/Bravellian.Platform.Modularity.Api/WebhookEngineAdapter.cs @@ -38,6 +38,20 @@ public WebhookEngineAdapter(ModuleEngineDiscoveryService discovery, IServiceProv /// public async Task DispatchAsync(WebhookAdapterRequest request, CancellationToken cancellationToken) { + if (request is null) + { + throw new System.ArgumentNullException(nameof(request)); + } + + if (string.IsNullOrWhiteSpace(request.Provider)) + { + throw new System.ArgumentException("Provider must be a non-empty, non-whitespace string.", nameof(request.Provider)); + } + + if (string.IsNullOrWhiteSpace(request.EventType)) + { + throw new System.ArgumentException("EventType must be a non-empty, non-whitespace string.", nameof(request.EventType)); + } if (request is null) { throw new ArgumentNullException(nameof(request)); From 7515a0ccb309a13256e92120a9f6439fe3802eca Mon Sep 17 00:00:00 2001 From: Samuel McAravey Date: Sun, 21 Dec 2025 21:15:03 -0800 Subject: [PATCH 10/12] PR fixes --- .../WebhookAdapterResponse.cs | 3 +- .../WebhookEngineAdapter.cs | 2 +- .../ModuleEngineCapabilities.cs | 2 +- .../ModuleEngineRegistry.cs | 28 ++++++++++++++++--- .../EngineRefactoringTests.cs | 8 ++++-- 5 files changed, 33 insertions(+), 10 deletions(-) diff --git a/src/Bravellian.Platform.Modularity.Api/WebhookAdapterResponse.cs b/src/Bravellian.Platform.Modularity.Api/WebhookAdapterResponse.cs index a1859e30..e7b1c46a 100644 --- a/src/Bravellian.Platform.Modularity.Api/WebhookAdapterResponse.cs +++ b/src/Bravellian.Platform.Modularity.Api/WebhookAdapterResponse.cs @@ -19,4 +19,5 @@ namespace Bravellian.Platform.Modularity; /// /// Outcome the transport should emit. /// Optional reason for retries. -public sealed record WebhookAdapterResponse(WebhookOutcomeType Outcome, string? Reason = null); +/// Optional event payload to enqueue downstream. +public sealed record WebhookAdapterResponse(WebhookOutcomeType Outcome, string? Reason = null, object? EnqueuedEvent = null); diff --git a/src/Bravellian.Platform.Modularity.Api/WebhookEngineAdapter.cs b/src/Bravellian.Platform.Modularity.Api/WebhookEngineAdapter.cs index 67996872..8617a8de 100644 --- a/src/Bravellian.Platform.Modularity.Api/WebhookEngineAdapter.cs +++ b/src/Bravellian.Platform.Modularity.Api/WebhookEngineAdapter.cs @@ -78,6 +78,6 @@ public async Task DispatchAsync(WebhookAdapter new WebhookRequest(request.Provider, request.EventType, request.Payload, request.IdempotencyKey, request.Attempt), cancellationToken).ConfigureAwait(false); - return new WebhookAdapterResponse(outcome.Outcome, outcome.Reason); + return new WebhookAdapterResponse(outcome.Outcome, outcome.Reason, outcome.EnqueuedEvent); } } diff --git a/src/Bravellian.Platform.Modularity.Core/ModuleEngineCapabilities.cs b/src/Bravellian.Platform.Modularity.Core/ModuleEngineCapabilities.cs index b55c802f..8e9980f4 100644 --- a/src/Bravellian.Platform.Modularity.Core/ModuleEngineCapabilities.cs +++ b/src/Bravellian.Platform.Modularity.Core/ModuleEngineCapabilities.cs @@ -19,7 +19,7 @@ namespace Bravellian.Platform.Modularity; /// /// Named commands/actions the engine supports. /// Events emitted by the engine. -/// Reserved for future use. All engines are currently async by design (Task-based). This flag may be used by hosts to differentiate execution strategies in future versions. +/// Reserved for future use. All engines are currently Task-based; adapters ignore this flag today, so hosts should treat it as informational until sync execution is introduced. /// Indicates streaming updates are supported. public sealed record ModuleEngineCapabilities( IReadOnlyCollection Actions, diff --git a/src/Bravellian.Platform.Modularity.Core/ModuleEngineRegistry.cs b/src/Bravellian.Platform.Modularity.Core/ModuleEngineRegistry.cs index ed65d04c..4a54441d 100644 --- a/src/Bravellian.Platform.Modularity.Core/ModuleEngineRegistry.cs +++ b/src/Bravellian.Platform.Modularity.Core/ModuleEngineRegistry.cs @@ -25,6 +25,7 @@ internal static class ModuleEngineRegistry // ReaderWriterLockSlim protects registry operations. No disposal needed as this is a static class with application lifetime. private static readonly ReaderWriterLockSlim RegistryLock = new(); + private static IModuleEngineDescriptor[]? CachedSnapshot; public static void Register(string moduleKey, IEnumerable descriptors) { @@ -42,6 +43,8 @@ public static void Register(string moduleKey, IEnumerable GetEngines() { - RegistryLock.EnterReadLock(); + RegistryLock.EnterUpgradeableReadLock(); try { - // Take a snapshot of all registered engines. The read lock ensures no modifications occur during enumeration. - return Engines.Values.SelectMany(list => list).ToArray(); + if (CachedSnapshot is { } cached) + { + return cached; + } + + RegistryLock.EnterWriteLock(); + try + { + // Snapshot the engine lists while holding the lock to avoid races with registry mutations. + var lists = Engines.Values.ToArray(); + cached = lists.SelectMany(list => list).ToArray(); + CachedSnapshot = cached; + return cached; + } + finally + { + RegistryLock.ExitWriteLock(); + } } finally { - RegistryLock.ExitReadLock(); + RegistryLock.ExitUpgradeableReadLock(); } } @@ -136,6 +155,7 @@ public static void Reset() try { Engines.Clear(); + CachedSnapshot = null; } finally { diff --git a/tests/Bravellian.Platform.Tests/EngineRefactoringTests.cs b/tests/Bravellian.Platform.Tests/EngineRefactoringTests.cs index 5a4d93f2..35999dde 100644 --- a/tests/Bravellian.Platform.Tests/EngineRefactoringTests.cs +++ b/tests/Bravellian.Platform.Tests/EngineRefactoringTests.cs @@ -84,8 +84,10 @@ public async Task Webhook_adapter_rejects_invalid_signature() Assert.Equal("Signature validation failed", response.Reason); } - [Fact] - public async Task Webhook_adapter_requires_idempotency_key_when_configured() + [Theory] + [InlineData("")] + [InlineData(" ")] + public async Task Webhook_adapter_requires_idempotency_key_when_configured(string idempotencyKey) { var provider = BuildServiceProvider(); var adapter = new WebhookEngineAdapter(provider.GetRequiredService(), provider, provider.GetRequiredService()); @@ -95,7 +97,7 @@ public async Task Webhook_adapter_requires_idempotency_key_when_configured() "bounce", new Dictionary { ["X-Signature"] = "postmark:raw-body" }, "raw-body", - string.Empty, // Missing idempotency key + idempotencyKey, 1, null, new PostmarkBouncePayload("HardBounce", "Mail rejected")); From 1046c60bacfdb7495f3e7f9ad0ed504a2866fa37 Mon Sep 17 00:00:00 2001 From: Samuel McAravey Date: Sun, 21 Dec 2025 21:53:05 -0800 Subject: [PATCH 11/12] More cleanup --- docs/engine-overview.md | 2 +- .../WebhookEngineAdapter.cs | 89 ++++++-- .../IModuleEngineDescriptor.cs | 2 +- .../IRequiredServiceValidator.cs | 26 +++ .../ModuleEngineCapabilities.cs | 2 +- .../ModuleEngineDescriptor.cs | 2 +- .../ModuleEngineDiscoveryService.cs | 23 ++- .../ModuleEngineRegistry.cs | 111 ++++++---- .../UiEngineResult.cs | 4 +- .../WebhookRequest.cs | 2 +- .../UiEngineAdapter.cs | 42 +++- .../EngineRefactoringTests.cs | 79 ++++++- .../Modularity/ModuleSystemTests.cs | 194 ++++++++++++++++++ .../RequiredServiceValidationTests.cs | 192 +++++++++++++++++ 14 files changed, 699 insertions(+), 71 deletions(-) create mode 100644 src/Bravellian.Platform.Modularity.Core/IRequiredServiceValidator.cs create mode 100644 tests/Bravellian.Platform.Tests/Modularity/RequiredServiceValidationTests.cs diff --git a/docs/engine-overview.md b/docs/engine-overview.md index e4d98ac1..b55fafac 100644 --- a/docs/engine-overview.md +++ b/docs/engine-overview.md @@ -7,7 +7,7 @@ This repository now exposes transport-agnostic engines that sit inside each modu - **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. If validation fails, hosts should skip registration or surface a configuration error rather than invoking the engine. + - 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`) 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. diff --git a/src/Bravellian.Platform.Modularity.Api/WebhookEngineAdapter.cs b/src/Bravellian.Platform.Modularity.Api/WebhookEngineAdapter.cs index 426d12e1..c99a58d7 100644 --- a/src/Bravellian.Platform.Modularity.Api/WebhookEngineAdapter.cs +++ b/src/Bravellian.Platform.Modularity.Api/WebhookEngineAdapter.cs @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +using Microsoft.Extensions.DependencyInjection; + namespace Bravellian.Platform.Modularity; /// @@ -38,20 +40,6 @@ public WebhookEngineAdapter(ModuleEngineDiscoveryService discovery, IServiceProv /// public async Task DispatchAsync(WebhookAdapterRequest request, CancellationToken cancellationToken) { - if (request is null) - { - throw new System.ArgumentNullException(nameof(request)); - } - - if (string.IsNullOrWhiteSpace(request.Provider)) - { - throw new System.ArgumentException("Provider must be a non-empty, non-whitespace string.", nameof(request.Provider)); - } - - if (string.IsNullOrWhiteSpace(request.EventType)) - { - throw new System.ArgumentException("EventType must be a non-empty, non-whitespace string.", nameof(request.EventType)); - } if (request is null) { throw new ArgumentNullException(nameof(request)); @@ -59,12 +47,12 @@ public async Task DispatchAsync(WebhookAdapter if (string.IsNullOrWhiteSpace(request.Provider)) { - throw new ArgumentException("Provider must be a non-empty, non-whitespace string.", nameof(request)); + 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)); + throw new ArgumentException("EventType must be a non-empty, non-whitespace string.", nameof(request.EventType)); } var descriptor = discovery.ResolveWebhookEngine(request.Provider, request.EventType) @@ -73,7 +61,9 @@ public async Task DispatchAsync(WebhookAdapter if (descriptor.Manifest.Security is { } security && !signatureValidator.Validate(security, request.Headers, request.RawBody, request.Signature)) { - return new WebhookAdapterResponse(WebhookOutcomeType.Acknowledge, "Signature validation failed"); + return new WebhookAdapterResponse( + WebhookOutcomeType.Acknowledge, + $"Signature validation failed. Expected algorithm: {security.SignatureAlgorithm}."); } if (string.IsNullOrWhiteSpace(request.IdempotencyKey) && descriptor.Manifest.Security?.IdempotencyWindow is not null) @@ -83,6 +73,8 @@ public async Task DispatchAsync(WebhookAdapter "Idempotency key is required when an idempotency window is configured for this provider."); } + ValidateRequiredServices(descriptor, request.Provider, request.EventType); + var typedDescriptor = descriptor as ModuleEngineDescriptor> ?? throw new InvalidOperationException($"Engine '{descriptor.Manifest.Id}' does not implement expected webhook contract."); @@ -94,4 +86,67 @@ public async Task DispatchAsync(WebhookAdapter return new WebhookAdapterResponse(outcome.Outcome, outcome.Reason, outcome.EnqueuedEvent); } + + private void ValidateRequiredServices(IModuleEngineDescriptor descriptor, string provider, string eventType) + { + var requiredServices = new HashSet(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."); + } + } + + var validator = services.GetService(); + 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(); + if (missing.Count > 0) + { + throw new InvalidOperationException( + $"Engine '{descriptor.ModuleKey}/{descriptor.Manifest.Id}' is missing required services: {string.Join(", ", missing)}."); + } + } } diff --git a/src/Bravellian.Platform.Modularity.Core/IModuleEngineDescriptor.cs b/src/Bravellian.Platform.Modularity.Core/IModuleEngineDescriptor.cs index f2404736..f5fba09c 100644 --- a/src/Bravellian.Platform.Modularity.Core/IModuleEngineDescriptor.cs +++ b/src/Bravellian.Platform.Modularity.Core/IModuleEngineDescriptor.cs @@ -25,5 +25,5 @@ public interface IModuleEngineDescriptor Type ContractType { get; } - object Create(IServiceProvider serviceProvider); + object? Create(IServiceProvider serviceProvider); } diff --git a/src/Bravellian.Platform.Modularity.Core/IRequiredServiceValidator.cs b/src/Bravellian.Platform.Modularity.Core/IRequiredServiceValidator.cs new file mode 100644 index 00000000..0e843570 --- /dev/null +++ b/src/Bravellian.Platform.Modularity.Core/IRequiredServiceValidator.cs @@ -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; + +/// +/// Validates that required engine services are available for a host. +/// +public interface IRequiredServiceValidator +{ + /// + /// Returns the subset of required services that are missing. + /// + IReadOnlyCollection GetMissingServices(IReadOnlyCollection requiredServices); +} diff --git a/src/Bravellian.Platform.Modularity.Core/ModuleEngineCapabilities.cs b/src/Bravellian.Platform.Modularity.Core/ModuleEngineCapabilities.cs index 8e9980f4..0eb9d378 100644 --- a/src/Bravellian.Platform.Modularity.Core/ModuleEngineCapabilities.cs +++ b/src/Bravellian.Platform.Modularity.Core/ModuleEngineCapabilities.cs @@ -19,7 +19,7 @@ namespace Bravellian.Platform.Modularity; /// /// Named commands/actions the engine supports. /// Events emitted by the engine. -/// Reserved for future use. All engines are currently Task-based; adapters ignore this flag today, so hosts should treat it as informational until sync execution is introduced. +/// Reserved for future use. Engines are currently Task-based and adapters ignore this flag; it exists to allow hosts to distinguish async vs. sync execution if synchronous engines are introduced later. /// Indicates streaming updates are supported. public sealed record ModuleEngineCapabilities( IReadOnlyCollection Actions, diff --git a/src/Bravellian.Platform.Modularity.Core/ModuleEngineDescriptor.cs b/src/Bravellian.Platform.Modularity.Core/ModuleEngineDescriptor.cs index 0fc2c4c8..225ae28e 100644 --- a/src/Bravellian.Platform.Modularity.Core/ModuleEngineDescriptor.cs +++ b/src/Bravellian.Platform.Modularity.Core/ModuleEngineDescriptor.cs @@ -28,5 +28,5 @@ public sealed record ModuleEngineDescriptor( { public Type ContractType => typeof(TContract); - public object Create(IServiceProvider serviceProvider) => Factory(serviceProvider)!; + public object? Create(IServiceProvider serviceProvider) => Factory(serviceProvider); } diff --git a/src/Bravellian.Platform.Modularity.Core/ModuleEngineDiscoveryService.cs b/src/Bravellian.Platform.Modularity.Core/ModuleEngineDiscoveryService.cs index f2444418..03ed483a 100644 --- a/src/Bravellian.Platform.Modularity.Core/ModuleEngineDiscoveryService.cs +++ b/src/Bravellian.Platform.Modularity.Core/ModuleEngineDiscoveryService.cs @@ -51,6 +51,11 @@ public IReadOnlyCollection List(EngineKind? kind, strin public TContract ResolveEngine(ModuleEngineDescriptor descriptor, IServiceProvider serviceProvider) where TContract : notnull { + if (serviceProvider is null) + { + throw new ArgumentNullException(nameof(serviceProvider)); + } + var instance = descriptor.Factory(serviceProvider); if (instance is null) @@ -64,5 +69,21 @@ public TContract ResolveEngine(ModuleEngineDescriptor desc /// /// Resolves an engine instance for a descriptor when only the contract type is known at runtime. /// - public object ResolveEngine(IModuleEngineDescriptor descriptor, IServiceProvider serviceProvider) => descriptor.Create(serviceProvider); + public object ResolveEngine(IModuleEngineDescriptor descriptor, IServiceProvider serviceProvider) + { + if (serviceProvider is null) + { + throw new ArgumentNullException(nameof(serviceProvider)); + } + + var instance = descriptor.Create(serviceProvider); + + if (instance is null) + { + throw new InvalidOperationException( + $"The factory for module engine '{descriptor.ModuleKey}/{descriptor.Manifest.Id}' returned null."); + } + + return instance; + } } diff --git a/src/Bravellian.Platform.Modularity.Core/ModuleEngineRegistry.cs b/src/Bravellian.Platform.Modularity.Core/ModuleEngineRegistry.cs index 4a54441d..bc0720e9 100644 --- a/src/Bravellian.Platform.Modularity.Core/ModuleEngineRegistry.cs +++ b/src/Bravellian.Platform.Modularity.Core/ModuleEngineRegistry.cs @@ -23,18 +23,18 @@ internal static class ModuleEngineRegistry { private static readonly ConcurrentDictionary> Engines = new(StringComparer.OrdinalIgnoreCase); - // ReaderWriterLockSlim protects registry operations. No disposal needed as this is a static class with application lifetime. - private static readonly ReaderWriterLockSlim RegistryLock = new(); + // Single lock protects registry operations without disposal requirements. + private static readonly object RegistryLock = new(); private static IModuleEngineDescriptor[]? CachedSnapshot; public static void Register(string moduleKey, IEnumerable descriptors) { - RegistryLock.EnterWriteLock(); - try + lock (RegistryLock) { var list = Engines.GetOrAdd(moduleKey, _ => new List()); foreach (var descriptor in descriptors) { + ValidateWebhookMetadataUniqueness(descriptor); var exists = list.Any(existing => string.Equals(existing.ModuleKey, descriptor.ModuleKey, StringComparison.OrdinalIgnoreCase) && string.Equals(existing.Manifest.Id, descriptor.Manifest.Id, StringComparison.OrdinalIgnoreCase) && existing.ContractType == descriptor.ContractType); @@ -46,46 +46,85 @@ public static void Register(string moduleKey, IEnumerable(new WebhookMetadataKeyComparer()); + + foreach (var entry in metadata) + { + if (!seen.Add((entry.Provider, entry.EventType))) + { + throw new InvalidOperationException( + $"Engine '{descriptor.ModuleKey}/{descriptor.Manifest.Id}' declares duplicate webhook metadata for provider '{entry.Provider}' and event '{entry.EventType}'."); + } + + foreach (var existingList in Engines.Values) + { + foreach (var existing in existingList) + { + if (existing.Manifest.WebhookMetadata is null) + { + continue; + } + + foreach (var existingEntry in existing.Manifest.WebhookMetadata) + { + if (string.Equals(existingEntry.Provider, entry.Provider, StringComparison.OrdinalIgnoreCase) + && string.Equals(existingEntry.EventType, entry.EventType, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Webhook provider '{entry.Provider}' and event '{entry.EventType}' are already handled by engine '{existing.ModuleKey}/{existing.Manifest.Id}'."); + } + } + } + } + } + } + + private sealed class WebhookMetadataKeyComparer : IEqualityComparer<(string Provider, string EventType)> + { + public bool Equals((string Provider, string EventType) x, (string Provider, string EventType) y) + { + return string.Equals(x.Provider, y.Provider, StringComparison.OrdinalIgnoreCase) + && string.Equals(x.EventType, y.EventType, StringComparison.OrdinalIgnoreCase); + } + + public int GetHashCode((string Provider, string EventType) obj) + { + return HashCode.Combine( + StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Provider ?? string.Empty), + StringComparer.OrdinalIgnoreCase.GetHashCode(obj.EventType ?? string.Empty)); } } public static IReadOnlyCollection GetEngines() { - RegistryLock.EnterUpgradeableReadLock(); - try + lock (RegistryLock) { if (CachedSnapshot is { } cached) { return cached; } - RegistryLock.EnterWriteLock(); - try - { - // Snapshot the engine lists while holding the lock to avoid races with registry mutations. - var lists = Engines.Values.ToArray(); - cached = lists.SelectMany(list => list).ToArray(); - CachedSnapshot = cached; - return cached; - } - finally - { - RegistryLock.ExitWriteLock(); - } - } - finally - { - RegistryLock.ExitUpgradeableReadLock(); + // Snapshot the engine lists while holding the lock to avoid races with registry mutations. + var lists = Engines.Values.ToArray(); + cached = lists.SelectMany(list => list).ToArray(); + CachedSnapshot = cached; + return cached; } } public static IModuleEngineDescriptor? FindWebhookEngine(string provider, string eventType) { - RegistryLock.EnterReadLock(); - try + lock (RegistryLock) { // Search for webhook engine matching the provider and event type. foreach (var list in Engines.Values) @@ -116,16 +155,11 @@ public static IReadOnlyCollection GetEngines() return null; } - finally - { - RegistryLock.ExitReadLock(); - } } public static IModuleEngineDescriptor? FindById(string moduleKey, string engineId) { - RegistryLock.EnterReadLock(); - try + lock (RegistryLock) { // Narrow the lookup to the specific moduleKey. if (!Engines.TryGetValue(moduleKey, out var list)) @@ -143,23 +177,14 @@ public static IReadOnlyCollection GetEngines() return null; } - finally - { - RegistryLock.ExitReadLock(); - } } public static void Reset() { - RegistryLock.EnterWriteLock(); - try + lock (RegistryLock) { Engines.Clear(); CachedSnapshot = null; } - finally - { - RegistryLock.ExitWriteLock(); - } } } diff --git a/src/Bravellian.Platform.Modularity.Core/UiEngineResult.cs b/src/Bravellian.Platform.Modularity.Core/UiEngineResult.cs index 6b8c3315..d415bce7 100644 --- a/src/Bravellian.Platform.Modularity.Core/UiEngineResult.cs +++ b/src/Bravellian.Platform.Modularity.Core/UiEngineResult.cs @@ -18,9 +18,9 @@ namespace Bravellian.Platform.Modularity; /// UI engine result containing the view model and optional navigation tokens/events. /// /// Resulting view model. -/// Navigation tokens emitted by the engine. +/// Navigation tokens emitted by the engine. /// Events emitted for adapters to relay. public sealed record UiEngineResult( TViewModel ViewModel, - IReadOnlyCollection? Navigation = null, + IReadOnlyCollection? NavigationTargets = null, IReadOnlyCollection? Events = null); diff --git a/src/Bravellian.Platform.Modularity.Core/WebhookRequest.cs b/src/Bravellian.Platform.Modularity.Core/WebhookRequest.cs index c5e1d889..8f8bd6ed 100644 --- a/src/Bravellian.Platform.Modularity.Core/WebhookRequest.cs +++ b/src/Bravellian.Platform.Modularity.Core/WebhookRequest.cs @@ -21,7 +21,7 @@ namespace Bravellian.Platform.Modularity; /// Event type identifier. /// Deserialized payload. /// Idempotency key for replay protection. -/// Current delivery attempt (1-based; default is 0 for backwards compatibility, but callers should use 1 for the first attempt). +/// Current delivery attempt; 0 means unknown, values >= 1 are attempt counts (first attempt is 1). public sealed record WebhookRequest( string Provider, string EventType, diff --git a/src/Bravellian.Platform.Modularity.FullStack/UiEngineAdapter.cs b/src/Bravellian.Platform.Modularity.FullStack/UiEngineAdapter.cs index 1a591c76..69c5a93f 100644 --- a/src/Bravellian.Platform.Modularity.FullStack/UiEngineAdapter.cs +++ b/src/Bravellian.Platform.Modularity.FullStack/UiEngineAdapter.cs @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +using Microsoft.Extensions.DependencyInjection; + namespace Bravellian.Platform.Modularity; /// @@ -46,9 +48,16 @@ public async Task> ExecuteAsync> ?? throw new InvalidOperationException($"Engine '{engineId}' does not implement the expected UI contract."); @@ -56,6 +65,37 @@ public async Task> ExecuteAsync(result.ViewModel, result.Navigation, result.Events); + return new UiAdapterResponse(result.ViewModel, result.NavigationTargets, result.Events); + } + + private void ValidateRequiredServices(IModuleEngineDescriptor descriptor, IReadOnlyCollection? requiredServices) + { + if (requiredServices is null || 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."); + } + } + + var validator = services.GetService(); + 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) ?? Array.Empty(); + if (missing.Count > 0) + { + throw new InvalidOperationException( + $"Engine '{descriptor.ModuleKey}/{descriptor.Manifest.Id}' is missing required services: {string.Join(", ", missing)}."); + } } } diff --git a/tests/Bravellian.Platform.Tests/EngineRefactoringTests.cs b/tests/Bravellian.Platform.Tests/EngineRefactoringTests.cs index 35999dde..8d406938 100644 --- a/tests/Bravellian.Platform.Tests/EngineRefactoringTests.cs +++ b/tests/Bravellian.Platform.Tests/EngineRefactoringTests.cs @@ -81,7 +81,7 @@ public async Task Webhook_adapter_rejects_invalid_signature() var response = await adapter.DispatchAsync(request, CancellationToken.None); Assert.Equal(WebhookOutcomeType.Acknowledge, response.Outcome); - Assert.Equal("Signature validation failed", response.Reason); + Assert.Contains("Signature validation failed", response.Reason); } [Theory] @@ -108,6 +108,46 @@ public async Task Webhook_adapter_requires_idempotency_key_when_configured(strin Assert.Contains("Idempotency key is required", response.Reason); } + [Fact] + public async Task Webhook_adapter_throws_when_engine_is_not_registered() + { + var provider = BuildServiceProvider(); + var adapter = new WebhookEngineAdapter(provider.GetRequiredService(), provider, provider.GetRequiredService()); + + var request = new WebhookAdapterRequest( + "missing", + "event", + new Dictionary { ["X-Signature"] = "missing:raw-body" }, + "raw-body", + "idemp-1", + 1, + null, + new PostmarkBouncePayload("HardBounce", "Mail rejected")); + + var ex = await Assert.ThrowsAsync(() => adapter.DispatchAsync(request, CancellationToken.None)); + Assert.Contains("No webhook engine registered", ex.Message); + } + + [Fact] + public async Task Webhook_adapter_throws_when_engine_contract_is_mismatched() + { + var provider = BuildServiceProvider(); + var adapter = new WebhookEngineAdapter(provider.GetRequiredService(), provider, provider.GetRequiredService()); + + var request = new WebhookAdapterRequest( + "postmark", + "bounce", + new Dictionary { ["X-Signature"] = "postmark:raw-body" }, + "raw-body", + "idemp-1", + 1, + null, + new OtherWebhookPayload("wrong")); + + var ex = await Assert.ThrowsAsync(() => adapter.DispatchAsync(request, CancellationToken.None)); + Assert.Contains("does not implement expected webhook contract", ex.Message); + } + [Fact] public async Task Ui_engine_exception_propagates_to_adapter() { @@ -118,6 +158,30 @@ await Assert.ThrowsAsync(async () => await adapter.ExecuteAsync("fake-module", "ui.login", new LoginCommand(string.Empty, "pass"), CancellationToken.None)); } + [Fact] + public async Task Ui_adapter_throws_when_engine_is_not_registered() + { + var provider = BuildServiceProvider(); + var adapter = new UiEngineAdapter(provider.GetRequiredService(), provider); + + var ex = await Assert.ThrowsAsync(async () => + await adapter.ExecuteAsync("fake-module", "ui.missing", new LoginCommand("admin", "pass"), CancellationToken.None)); + + Assert.Contains("No UI engine registered", ex.Message); + } + + [Fact] + public async Task Ui_adapter_throws_when_engine_contract_is_mismatched() + { + var provider = BuildServiceProvider(); + var adapter = new UiEngineAdapter(provider.GetRequiredService(), provider); + + var ex = await Assert.ThrowsAsync(async () => + await adapter.ExecuteAsync("fake-module", "webhook.postmark", new LoginCommand("admin", "pass"), CancellationToken.None)); + + Assert.Contains("does not implement the expected UI contract", ex.Message); + } + [Fact] public void Discovery_service_filters_engines() { @@ -139,6 +203,7 @@ private static ServiceProvider BuildServiceProvider() { var services = new ServiceCollection(); services.AddSingleton(); + services.AddSingleton(); services.AddFullStackModuleServices(new ConfigurationBuilder().Build()); services.AddSingleton(); services.AddSingleton(); @@ -216,7 +281,7 @@ public IEnumerable DescribeEngines() "Postmark bounce webhook handler", EngineKind.Webhook, "Notifications", - new ModuleEngineCapabilities(new[] { "handle" }, new[] { "bounce.received" }), + new ModuleEngineCapabilities(new[] { "handle" }, new[] { "bounce.received" }, SupportsStreaming: false), new[] { new ModuleEngineSchema("payload", typeof(PostmarkBouncePayload)) }, Array.Empty(), null, @@ -255,6 +320,8 @@ public Task> ExecuteAsync(LoginCommand command, C private sealed record PostmarkBouncePayload(string Type, string Description); + private sealed record OtherWebhookPayload(string Value); + private sealed class PostmarkWebhookEngine : IWebhookEngine { public Task HandleAsync(WebhookRequest request, CancellationToken cancellationToken) @@ -278,4 +345,12 @@ public bool Validate(ModuleEngineSecurity security, IReadOnlyDictionary GetMissingServices(IReadOnlyCollection requiredServices) + { + return Array.Empty(); + } + } } diff --git a/tests/Bravellian.Platform.Tests/Modularity/ModuleSystemTests.cs b/tests/Bravellian.Platform.Tests/Modularity/ModuleSystemTests.cs index 47eebf91..345e4311 100644 --- a/tests/Bravellian.Platform.Tests/Modularity/ModuleSystemTests.cs +++ b/tests/Bravellian.Platform.Tests/Modularity/ModuleSystemTests.cs @@ -146,6 +146,37 @@ public void Module_keys_with_slashes_are_rejected() ex.Message.ShouldContain("cannot contain slashes"); } + [Fact] + public void Engine_descriptor_module_key_must_match_module_key() + { + BackgroundModuleRegistry.Reset(); + ApiModuleRegistry.RegisterApiModule(); + + var configuration = new ConfigurationBuilder().Build(); + var services = new ServiceCollection(); + + var ex = Should.Throw(() => services.AddApiModuleServices(configuration, NullLoggerFactory.Instance)); + ex.Message.ShouldContain("Engine descriptor module key"); + ex.Message.ShouldContain("module-alpha"); + ex.Message.ShouldContain("module-beta"); + } + + [Fact] + public void Duplicate_webhook_provider_event_pairs_are_rejected() + { + BackgroundModuleRegistry.Reset(); + ApiModuleRegistry.RegisterApiModule(); + ApiModuleRegistry.RegisterApiModule(); + + var configuration = new ConfigurationBuilder().Build(); + var services = new ServiceCollection(); + + var ex = Should.Throw(() => services.AddApiModuleServices(configuration, NullLoggerFactory.Instance)); + ex.Message.ShouldContain("postmark"); + ex.Message.ShouldContain("bounce"); + ex.Message.ShouldContain("already handled"); + } + [Fact] public void Duplicate_module_type_registrations_in_same_category_are_idempotent() { @@ -235,6 +266,169 @@ public void MapApiEndpoints(RouteGroupBuilder group) } } + private sealed class ModuleWithMismatchedEngineDescriptor : IApiModule, IEngineModule + { + public string Key => "module-alpha"; + + public string DisplayName => "Module With Mismatched Descriptor"; + + public IEnumerable GetRequiredConfigurationKeys() => Array.Empty(); + + public IEnumerable GetOptionalConfigurationKeys() => Array.Empty(); + + public void LoadConfiguration(IReadOnlyDictionary required, IReadOnlyDictionary optional) + { + } + + public void AddModuleServices(IServiceCollection services) + { + } + + public void RegisterHealthChecks(ModuleHealthCheckBuilder builder) + { + } + + public void MapApiEndpoints(RouteGroupBuilder group) + { + } + + public IEnumerable DescribeEngines() + { + yield return new ModuleEngineDescriptor>( + "module-beta", + new ModuleEngineManifest( + "ui.dummy", + "1.0", + "Dummy UI engine", + EngineKind.Ui), + _ => new DummyUiEngine()); + } + } + + private sealed record DummyCommand; + + private sealed record DummyViewModel; + + private sealed class DummyUiEngine : IUiEngine + { + public Task> ExecuteAsync(DummyCommand command, CancellationToken cancellationToken) + { + return Task.FromResult(new UiEngineResult(new DummyViewModel())); + } + } + + private sealed class WebhookModuleOne : IApiModule, IEngineModule + { + public string Key => "webhook-module-one"; + + public string DisplayName => "Webhook Module One"; + + public IEnumerable GetRequiredConfigurationKeys() => Array.Empty(); + + public IEnumerable GetOptionalConfigurationKeys() => Array.Empty(); + + public void LoadConfiguration(IReadOnlyDictionary required, IReadOnlyDictionary optional) + { + } + + public void AddModuleServices(IServiceCollection services) + { + services.AddSingleton(); + } + + public void RegisterHealthChecks(ModuleHealthCheckBuilder builder) + { + } + + public void MapApiEndpoints(RouteGroupBuilder group) + { + } + + public IEnumerable DescribeEngines() + { + yield return new ModuleEngineDescriptor>( + Key, + new ModuleEngineManifest( + "webhook.one", + "1.0", + "Webhook engine one", + EngineKind.Webhook, + WebhookMetadata: new[] + { + new ModuleEngineWebhookMetadata( + "postmark", + "bounce", + new ModuleEngineSchema("payload", typeof(DummyWebhookPayload))), + }), + sp => sp.GetRequiredService()); + } + } + + private sealed class WebhookModuleTwo : IApiModule, IEngineModule + { + public string Key => "webhook-module-two"; + + public string DisplayName => "Webhook Module Two"; + + public IEnumerable GetRequiredConfigurationKeys() => Array.Empty(); + + public IEnumerable GetOptionalConfigurationKeys() => Array.Empty(); + + public void LoadConfiguration(IReadOnlyDictionary required, IReadOnlyDictionary optional) + { + } + + public void AddModuleServices(IServiceCollection services) + { + services.AddSingleton(); + } + + public void RegisterHealthChecks(ModuleHealthCheckBuilder builder) + { + } + + public void MapApiEndpoints(RouteGroupBuilder group) + { + } + + public IEnumerable DescribeEngines() + { + yield return new ModuleEngineDescriptor>( + Key, + new ModuleEngineManifest( + "webhook.two", + "1.0", + "Webhook engine two", + EngineKind.Webhook, + WebhookMetadata: new[] + { + new ModuleEngineWebhookMetadata( + "postmark", + "bounce", + new ModuleEngineSchema("payload", typeof(DummyWebhookPayload))), + }), + sp => sp.GetRequiredService()); + } + } + + private sealed record DummyWebhookPayload(string Id); + + private sealed class WebhookEngineOne : IWebhookEngine + { + public Task HandleAsync(WebhookRequest request, CancellationToken cancellationToken) + { + return Task.FromResult(WebhookOutcome.Acknowledge()); + } + } + + private sealed class WebhookEngineTwo : IWebhookEngine + { + public Task HandleAsync(WebhookRequest request, CancellationToken cancellationToken) + { + return Task.FromResult(WebhookOutcome.Acknowledge()); + } + } + private sealed class SampleFullStackModule : IFullStackModule, INavigationModuleMetadata { internal const string RequiredKey = "fullstack:required"; diff --git a/tests/Bravellian.Platform.Tests/Modularity/RequiredServiceValidationTests.cs b/tests/Bravellian.Platform.Tests/Modularity/RequiredServiceValidationTests.cs new file mode 100644 index 00000000..4d0576bd --- /dev/null +++ b/tests/Bravellian.Platform.Tests/Modularity/RequiredServiceValidationTests.cs @@ -0,0 +1,192 @@ +// 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 Bravellian.Platform.Modularity; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; + +namespace Bravellian.Platform.Tests.Modularity; + +[Collection("ModuleRegistryTests")] +public sealed class RequiredServiceValidationTests +{ + [Fact] + public async Task Ui_adapter_requires_required_service_validator_when_engine_declares_required_services() + { + ModuleRegistry.Reset(); + ApiModuleRegistry.RegisterApiModule(); + + var services = new ServiceCollection(); + services.AddApiModuleServices(new ConfigurationBuilder().Build(), NullLoggerFactory.Instance); + + using var provider = services.BuildServiceProvider(); + var adapter = new UiEngineAdapter(provider.GetRequiredService(), provider); + + var ex = await Should.ThrowAsync(async () => + await adapter.ExecuteAsync( + "required-service-module", + "ui.required", + new DummyCommand(), + CancellationToken.None)); + + ex.Message.ShouldContain(nameof(IRequiredServiceValidator)); + } + + [Fact] + public async Task Ui_adapter_throws_when_required_services_are_missing() + { + ModuleRegistry.Reset(); + ApiModuleRegistry.RegisterApiModule(); + + var services = new ServiceCollection(); + services.AddSingleton(new TestRequiredServiceValidator(Array.Empty())); + services.AddApiModuleServices(new ConfigurationBuilder().Build(), NullLoggerFactory.Instance); + + using var provider = services.BuildServiceProvider(); + var adapter = new UiEngineAdapter(provider.GetRequiredService(), provider); + + var ex = await Should.ThrowAsync(async () => + await adapter.ExecuteAsync( + "required-service-module", + "ui.required", + new DummyCommand(), + CancellationToken.None)); + + ex.Message.ShouldContain("missing required services"); + ex.Message.ShouldContain("cache"); + } + + [Fact] + public async Task Ui_adapter_executes_when_required_services_are_satisfied() + { + ModuleRegistry.Reset(); + ApiModuleRegistry.RegisterApiModule(); + + var services = new ServiceCollection(); + services.AddSingleton(new TestRequiredServiceValidator(new[] { "cache", "telemetry" })); + services.AddApiModuleServices(new ConfigurationBuilder().Build(), NullLoggerFactory.Instance); + + using var provider = services.BuildServiceProvider(); + var adapter = new UiEngineAdapter(provider.GetRequiredService(), provider); + + var response = await adapter.ExecuteAsync( + "required-service-module", + "ui.required", + new DummyCommand(), + CancellationToken.None); + + response.ViewModel.Value.ShouldBe("ok"); + } + + [Fact] + public void Discovery_service_resolve_engine_throws_when_factory_returns_null() + { + var discovery = new ModuleEngineDiscoveryService(); + + var descriptor = new ModuleEngineDescriptor>( + "null-module", + new ModuleEngineManifest( + "ui.null", + "1.0", + "Null engine factory", + EngineKind.Ui), + _ => null!); + + var services = new ServiceCollection().BuildServiceProvider(); + + var ex = Should.Throw(() => discovery.ResolveEngine(descriptor, services)); + ex.Message.ShouldContain("returned null"); + ex.Message.ShouldContain("null-module/ui.null"); + } + + private sealed class RequiredServiceModule : IApiModule, IEngineModule + { + public string Key => "required-service-module"; + + public string DisplayName => "Required Service Module"; + + public IEnumerable GetRequiredConfigurationKeys() => Array.Empty(); + + public IEnumerable GetOptionalConfigurationKeys() => Array.Empty(); + + public void LoadConfiguration(IReadOnlyDictionary required, IReadOnlyDictionary optional) + { + } + + public void AddModuleServices(IServiceCollection services) + { + services.AddSingleton(); + } + + public void RegisterHealthChecks(ModuleHealthCheckBuilder builder) + { + } + + public void MapApiEndpoints(RouteGroupBuilder group) + { + } + + public IEnumerable DescribeEngines() + { + yield return new ModuleEngineDescriptor>( + Key, + new ModuleEngineManifest( + "ui.required", + "1.0", + "Engine that requires host services", + EngineKind.Ui, + RequiredServices: new[] { "cache", "telemetry" }), + sp => sp.GetRequiredService()); + } + } + + private sealed record DummyCommand; + + private sealed record DummyViewModel(string Value); + + private sealed class DummyUiEngine : IUiEngine + { + public Task> ExecuteAsync(DummyCommand command, CancellationToken cancellationToken) + { + return Task.FromResult(new UiEngineResult(new DummyViewModel("ok"))); + } + } + + private sealed class TestRequiredServiceValidator : IRequiredServiceValidator + { + private readonly HashSet available; + + public TestRequiredServiceValidator(IEnumerable available) + { + this.available = new HashSet(available, StringComparer.OrdinalIgnoreCase); + } + + public IReadOnlyCollection GetMissingServices(IReadOnlyCollection requiredServices) + { + var missing = new List(); + foreach (var service in requiredServices) + { + if (!available.Contains(service)) + { + missing.Add(service); + } + } + + return missing; + } + } +} From c3ba12361b11fd1822edac85707da083ba13c6d0 Mon Sep 17 00:00:00 2001 From: Samuel McAravey Date: Sun, 21 Dec 2025 21:59:29 -0800 Subject: [PATCH 12/12] Build fix --- src/Bravellian.Platform.Modularity.Core/ModuleEngineRegistry.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Bravellian.Platform.Modularity.Core/ModuleEngineRegistry.cs b/src/Bravellian.Platform.Modularity.Core/ModuleEngineRegistry.cs index bc0720e9..150c28a1 100644 --- a/src/Bravellian.Platform.Modularity.Core/ModuleEngineRegistry.cs +++ b/src/Bravellian.Platform.Modularity.Core/ModuleEngineRegistry.cs @@ -24,7 +24,7 @@ internal static class ModuleEngineRegistry private static readonly ConcurrentDictionary> Engines = new(StringComparer.OrdinalIgnoreCase); // Single lock protects registry operations without disposal requirements. - private static readonly object RegistryLock = new(); + private static readonly Lock RegistryLock = new(); private static IModuleEngineDescriptor[]? CachedSnapshot; public static void Register(string moduleKey, IEnumerable descriptors)