From 6dbbcba97602b7a373a75fa6a0b2e73940fcb6cb Mon Sep 17 00:00:00 2001 From: Samuel McAravey Date: Sat, 20 Dec 2025 19:31:08 -0800 Subject: [PATCH] Add engine contracts, adapters, and discovery --- .../ApiModuleServiceCollectionExtensions.cs | 2 + .../IWebhookSignatureValidator.cs | 26 ++ .../WebhookAdapterRequest.cs | 36 +++ .../WebhookAdapterResponse.cs | 22 ++ .../WebhookEngineAdapter.cs | 66 ++++++ ...groundModuleServiceCollectionExtensions.cs | 1 + .../EngineKind.cs | 31 +++ .../IEngineModule.cs | 26 ++ .../IUiEngine.cs | 31 +++ .../IWebhookEngine.cs | 27 +++ .../ModuleEngineAdapterHints.cs | 30 +++ .../ModuleEngineCapabilities.cs | 28 +++ .../ModuleEngineCompatibility.cs | 22 ++ .../ModuleEngineDescriptor.cs | 28 +++ .../ModuleEngineDiscoveryService.cs | 52 ++++ .../ModuleEngineManifest.cs | 48 ++++ .../ModuleEngineNavigationHints.cs | 21 ++ .../ModuleEngineRegistry.cs | 61 +++++ .../ModuleEngineSchema.cs | 23 ++ .../ModuleEngineSecurity.cs | 26 ++ .../ModuleEngineWebhookMetadata.cs | 30 +++ .../ModuleRegistry.cs | 8 + .../UiEngineResult.cs | 26 ++ .../WebhookOutcome.cs | 39 +++ .../WebhookOutcomeType.cs | 36 +++ .../WebhookRequest.cs | 30 +++ ...lStackModuleServiceCollectionExtensions.cs | 1 + .../UiAdapterResponse.cs | 26 ++ .../UiEngineAdapter.cs | 49 ++++ .../EngineRefactoringTests.cs | 222 ++++++++++++++++++ 30 files changed, 1074 insertions(+) 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/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/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/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..4443fbaa --- /dev/null +++ b/src/Bravellian.Platform.Modularity.Api/WebhookEngineAdapter.cs @@ -0,0 +1,66 @@ +// 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 engine = discovery.ResolveEngine(descriptor, services) as IWebhookEngine + ?? 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..a26945ea --- /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/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..a4365fe9 --- /dev/null +++ b/src/Bravellian.Platform.Modularity.Core/ModuleEngineDescriptor.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; + +/// +/// Engine descriptor registered by a module. Factories are provided by modules and consumed by adapters. +/// +/// Module key that owns the engine. +/// Engine manifest metadata. +/// Engine contract interface (e.g., IUiEngine<,> or IWebhookEngine<>). +/// Factory that resolves the engine instance from an . +public sealed record ModuleEngineDescriptor( + string ModuleKey, + ModuleEngineManifest Manifest, + Type ContractType, + Func Factory); diff --git a/src/Bravellian.Platform.Modularity.Core/ModuleEngineDiscoveryService.cs b/src/Bravellian.Platform.Modularity.Core/ModuleEngineDiscoveryService.cs new file mode 100644 index 00000000..fc8baf2b --- /dev/null +++ b/src/Bravellian.Platform.Modularity.Core/ModuleEngineDiscoveryService.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; + +/// +/// 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 ModuleEngineDescriptor? ResolveWebhookEngine(string provider, string eventType) => ModuleEngineRegistry.FindWebhookEngine(provider, eventType); + + /// + /// Resolves an engine descriptor by module and engine identifier. + /// + public ModuleEngineDescriptor? ResolveById(string moduleKey, string engineId) => ModuleEngineRegistry.FindById(moduleKey, engineId); + + /// + /// Resolves an engine instance for a descriptor. + /// + public object ResolveEngine(ModuleEngineDescriptor descriptor, IServiceProvider serviceProvider) => descriptor.Factory(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..87770359 --- /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, + IReadOnlyCollection? 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..9de9105c --- /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..864d224a --- /dev/null +++ b/src/Bravellian.Platform.Modularity.Core/ModuleEngineRegistry.cs @@ -0,0 +1,61 @@ +// 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) + { + list.AddRange(descriptors); + } + } + + public static IReadOnlyCollection GetEngines() + { + return Engines.Values.SelectMany(x => x).ToArray(); + } + + public static ModuleEngineDescriptor? 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<(ModuleEngineDescriptor descriptor, ModuleEngineWebhookMetadata meta)>()) + .FirstOrDefault(pair => string.Equals(pair.meta.Provider, provider, StringComparison.OrdinalIgnoreCase) + && string.Equals(pair.meta.EventType, eventType, StringComparison.OrdinalIgnoreCase)) + .descriptor; + } + + public static ModuleEngineDescriptor? 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..ec70d273 --- /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( + string 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/ModuleRegistry.cs b/src/Bravellian.Platform.Modularity.Core/ModuleRegistry.cs index 0d17ce53..8d5239f7 100644 --- a/src/Bravellian.Platform.Modularity.Core/ModuleRegistry.cs +++ b/src/Bravellian.Platform.Modularity.Core/ModuleRegistry.cs @@ -81,6 +81,13 @@ 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 => descriptor with { ModuleKey = module.Key }) + .ToArray(); + ModuleEngineRegistry.Register(module.Key, descriptors); + } initialized.Add(module); } @@ -122,6 +129,7 @@ internal static void Reset() } Instances.Clear(); + ModuleEngineRegistry.Reset(); } } diff --git a/src/Bravellian.Platform.Modularity.Core/UiEngineResult.cs b/src/Bravellian.Platform.Modularity.Core/UiEngineResult.cs new file mode 100644 index 00000000..b421cd7e --- /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..9337992d --- /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..94c7332b --- /dev/null +++ b/src/Bravellian.Platform.Modularity.FullStack/UiEngineAdapter.cs @@ -0,0 +1,49 @@ +// 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 engine = discoveryService.ResolveEngine(descriptor, services) as IUiEngine + ?? 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..6f289e10 --- /dev/null +++ b/tests/Bravellian.Platform.Tests/EngineRefactoringTests.cs @@ -0,0 +1,222 @@ +// 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() + { + 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("route:dashboard", response.NavigationTargets); + 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.True(allEngines.Any(e => e.Manifest.Kind == EngineKind.Ui)); + + var webhookEngines = discovery.List(EngineKind.Webhook); + Assert.Single(webhookEngines); + + 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[] { "route:dashboard" }, + new[] { nameof(LoginUiEngine) }, + new ModuleEngineAdapterHints(false, false, false, false, true), + null, + new ModuleEngineCompatibility("1.0", null)), + typeof(IUiEngine), + 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(), + Array.Empty(), + new[] { nameof(PostmarkWebhookEngine) }, + new ModuleEngineAdapterHints(true, true, true, false, true), + new ModuleEngineSecurity("HMAC-SHA256", "postmark", TimeSpan.FromMinutes(10)), + new ModuleEngineCompatibility("1.0", "Initial"), + new[] + { + new ModuleEngineWebhookMetadata("postmark", "bounce", new ModuleEngineSchema("payload", typeof(PostmarkBouncePayload)), new[] { "logging" }, Retries: 3), + }), + typeof(IWebhookEngine), + 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[] { "route:dashboard" }, 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 signature == $"{security.SecretScope}:{rawBody}"; + } + } +}