diff --git a/docs/engine-overview.md b/docs/engine-overview.md new file mode 100644 index 00000000..b55fafac --- /dev/null +++ b/docs/engine-overview.md @@ -0,0 +1,23 @@ +# Engine contracts overview + +This repository now exposes transport-agnostic engines that sit inside each module. Engines describe their contracts in a manifest and can be surfaced through adapters when a host wants to expose UI or webhook behaviors. + +## Core building blocks + +- **Engine manifest** (`ModuleEngineManifest`) + - Identifies the engine (`Id`, `Version`, `Description`, `Kind`, optional `FeatureArea`). + - Declares capabilities, schemas, security, compatibility expectations, and navigation hints via strongly typed records/enums (`ModuleEngineNavigationHints`, `ModuleNavigationToken`, `NavigationTargetKind`, `ModuleSignatureAlgorithm`). + - Declares `RequiredServices` – a list of logical capabilities (for example `IApiClient`, `ICache`, `ITelemetry`) that must be provided by the host for the engine to run. Hosts are expected to validate that every required service can be satisfied before executing an engine, typically by mapping each required service identifier to a concrete implementation in their DI container or adapter configuration. Adapters enforce this validation by consulting `IRequiredServiceValidator`; hosts should register a validator or surface a configuration error when validation fails. + - Adds optional webhook metadata (`ModuleEngineWebhookMetadata`) for providers and event types. +- **Engine descriptor** (`ModuleEngineDescriptor`) wraps the manifest with a strongly typed factory so hosts can resolve engines without `object` casts. +- **Discovery** (`ModuleEngineDiscoveryService`/`ModuleEngineRegistry`) stores descriptors and supports filtering by kind/feature or matching webhook provider + event type. Hosts can either enumerate engines (dynamic) or resolve known descriptors directly (static, isolation-focused deployments), and use the exposed manifests (including `RequiredServices`) to ensure all dependencies are available before wiring engines into request or worker pipelines. + +## Adapter roles + +Adapters remain optional: they map transport concerns to engines while keeping the engines themselves unaware of ASP.NET, MVC, or Razor. Reference adapters include: +- `UiEngineAdapter` → executes an `IUiEngine` and surfaces `UiAdapterResponse` with typed navigation tokens. +- `WebhookEngineAdapter` → validates signatures, enforces idempotency hints, and dispatches to `IWebhookEngine` instances. + +## Versioning and isolation + +Each engine version is tracked in its manifest. Because descriptors are strongly typed and module-owned, a module can expose multiple UI or webhook engines while remaining an isolation boundary. Hosts that do not require dynamic discovery can register known descriptors directly and resolve them through the discovery service using the explicit generic overloads. 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..e7b1c46a --- /dev/null +++ b/src/Bravellian.Platform.Modularity.Api/WebhookAdapterResponse.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; + +/// +/// Adapter response that can be mapped to HTTP or queue responses. +/// +/// Outcome the transport should emit. +/// Optional reason for retries. +/// 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 new file mode 100644 index 00000000..c99a58d7 --- /dev/null +++ b/src/Bravellian.Platform.Modularity.Api/WebhookEngineAdapter.cs @@ -0,0 +1,152 @@ +// Copyright (c) Bravellian +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Microsoft.Extensions.DependencyInjection; + +namespace Bravellian.Platform.Modularity; + +/// +/// 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) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + if (string.IsNullOrWhiteSpace(request.Provider)) + { + throw new ArgumentException("Provider must be a non-empty, non-whitespace string.", nameof(request.Provider)); + } + + if (string.IsNullOrWhiteSpace(request.EventType)) + { + throw new ArgumentException("EventType must be a non-empty, non-whitespace string.", nameof(request.EventType)); + } + + var descriptor = discovery.ResolveWebhookEngine(request.Provider, request.EventType) + ?? throw new InvalidOperationException($"No webhook engine registered for provider '{request.Provider}' and event '{request.EventType}'."); + + if (descriptor.Manifest.Security is { } security && + !signatureValidator.Validate(security, request.Headers, request.RawBody, request.Signature)) + { + return new WebhookAdapterResponse( + WebhookOutcomeType.Acknowledge, + $"Signature validation failed. Expected algorithm: {security.SignatureAlgorithm}."); + } + + if (string.IsNullOrWhiteSpace(request.IdempotencyKey) && descriptor.Manifest.Security?.IdempotencyWindow is not null) + { + return new WebhookAdapterResponse( + WebhookOutcomeType.Retry, + "Idempotency key is required when an idempotency window is configured for this provider."); + } + + ValidateRequiredServices(descriptor, request.Provider, request.EventType); + + var typedDescriptor = descriptor as ModuleEngineDescriptor> + ?? 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), + cancellationToken).ConfigureAwait(false); + + return new WebhookAdapterResponse(outcome.Outcome, outcome.Reason, outcome.EnqueuedEvent); + } + + private void ValidateRequiredServices(IModuleEngineDescriptor descriptor, string provider, string eventType) + { + var requiredServices = new HashSet(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/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..f5fba09c --- /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/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/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..0eb9d378 --- /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. +/// 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, + 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..225ae28e --- /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..03ed483a --- /dev/null +++ b/src/Bravellian.Platform.Modularity.Core/ModuleEngineDiscoveryService.cs @@ -0,0 +1,89 @@ +// 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 + { + if (serviceProvider is null) + { + throw new ArgumentNullException(nameof(serviceProvider)); + } + + var instance = descriptor.Factory(serviceProvider); + + if (instance is null) + { + throw new System.InvalidOperationException( + $"The factory for module engine '{descriptor.ModuleKey}/{descriptor.Manifest.Id}' returned null."); + } + + return instance; + } + /// + /// Resolves an engine instance for a descriptor when only the contract type is known at runtime. + /// + 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/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..150c28a1 --- /dev/null +++ b/src/Bravellian.Platform.Modularity.Core/ModuleEngineRegistry.cs @@ -0,0 +1,190 @@ +// 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); + + // Single lock protects registry operations without disposal requirements. + private static readonly Lock RegistryLock = new(); + private static IModuleEngineDescriptor[]? CachedSnapshot; + + public static void Register(string moduleKey, IEnumerable descriptors) + { + 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); + if (!exists) + { + list.Add(descriptor); + } + } + + CachedSnapshot = null; + } + } + + private static void ValidateWebhookMetadataUniqueness(IModuleEngineDescriptor descriptor) + { + var metadata = descriptor.Manifest.WebhookMetadata; + if (metadata is null) + { + return; + } + + var seen = new HashSet<(string Provider, string EventType)>(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() + { + lock (RegistryLock) + { + if (CachedSnapshot is { } cached) + { + return cached; + } + + // 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) + { + lock (RegistryLock) + { + // Search for webhook engine matching the provider and event type. + foreach (var list in Engines.Values) + { + 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) + { + lock (RegistryLock) + { + // Narrow the lookup to the specific moduleKey. + if (!Engines.TryGetValue(moduleKey, out var list)) + { + return null; + } + + foreach (var descriptor in list) + { + if (string.Equals(descriptor.Manifest.Id, engineId, StringComparison.OrdinalIgnoreCase)) + { + return descriptor; + } + } + + return null; + } + } + + public static void Reset() + { + lock (RegistryLock) + { + Engines.Clear(); + CachedSnapshot = null; + } + } +} 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..e2e41876 100644 --- a/src/Bravellian.Platform.Modularity.Core/ModuleRegistry.cs +++ b/src/Bravellian.Platform.Modularity.Core/ModuleRegistry.cs @@ -81,6 +81,25 @@ 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 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; + }) + .ToArray(); + ModuleEngineRegistry.Register(module.Key, descriptors); + } initialized.Add(module); } @@ -122,6 +141,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..d415bce7 --- /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? NavigationTargets = 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..8f8bd6ed --- /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; 0 means unknown, values >= 1 are attempt counts (first attempt is 1). +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..69c5a93f --- /dev/null +++ b/src/Bravellian.Platform.Modularity.FullStack/UiEngineAdapter.cs @@ -0,0 +1,101 @@ +// Copyright (c) Bravellian +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +using Microsoft.Extensions.DependencyInjection; + +namespace Bravellian.Platform.Modularity; + +/// +/// 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) + { + 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)); + } + + if (command is null) + { + throw new ArgumentNullException(nameof(command)); + } + + var descriptor = discoveryService.ResolveById(moduleKey, engineId) + ?? throw new InvalidOperationException($"No UI engine registered with id '{engineId}' for module '{moduleKey}'."); + + ValidateRequiredServices(descriptor, descriptor.Manifest.RequiredServices); + + var typedDescriptor = descriptor as ModuleEngineDescriptor> + ?? 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); + + 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 new file mode 100644 index 00000000..8d406938 --- /dev/null +++ b/tests/Bravellian.Platform.Tests/EngineRefactoringTests.cs @@ -0,0 +1,356 @@ +// 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 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.Contains("Signature validation failed", response.Reason); + } + + [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()); + + var request = new WebhookAdapterRequest( + "postmark", + "bounce", + new Dictionary { ["X-Signature"] = "postmark:raw-body" }, + "raw-body", + idempotencyKey, + 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 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() + { + 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 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() + { + 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.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" }, SupportsStreaming: false), + 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 record OtherWebhookPayload(string Value); + + 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}"; + } + } + + private sealed class TestRequiredServiceValidator : IRequiredServiceValidator + { + public IReadOnlyCollection 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; + } + } +}