-
Notifications
You must be signed in to change notification settings - Fork 1
Strengthen engine descriptors and typed navigation #129
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a2ae887
173596f
fa5c392
4c3349b
aff8d66
b754ca0
0273b96
fbbd42c
c73ba14
b920e7f
7515a0c
ef11890
1046c60
c3ba123
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| # Engine contracts overview | ||
|
|
||
| This repository now exposes transport-agnostic engines that sit inside each module. Engines describe their contracts in a manifest and can be surfaced through adapters when a host wants to expose UI or webhook behaviors. | ||
|
|
||
| ## Core building blocks | ||
|
|
||
| - **Engine manifest** (`ModuleEngineManifest`) | ||
| - Identifies the engine (`Id`, `Version`, `Description`, `Kind`, optional `FeatureArea`). | ||
| - Declares capabilities, schemas, security, compatibility expectations, and navigation hints via strongly typed records/enums (`ModuleEngineNavigationHints`, `ModuleNavigationToken`, `NavigationTargetKind`, `ModuleSignatureAlgorithm`). | ||
| - Declares `RequiredServices` – a list of logical capabilities (for example `IApiClient`, `ICache`, `ITelemetry`) that must be provided by the host for the engine to run. Hosts are expected to validate that every required service can be satisfied before executing an engine, typically by mapping each required service identifier to a concrete implementation in their DI container or adapter configuration. Adapters enforce this validation by consulting `IRequiredServiceValidator`; hosts should register a validator or surface a configuration error when validation fails. | ||
| - Adds optional webhook metadata (`ModuleEngineWebhookMetadata`) for providers and event types. | ||
| - **Engine descriptor** (`ModuleEngineDescriptor<TContract>`) wraps the manifest with a strongly typed factory so hosts can resolve engines without `object` casts. | ||
| - **Discovery** (`ModuleEngineDiscoveryService`/`ModuleEngineRegistry`) stores descriptors and supports filtering by kind/feature or matching webhook provider + event type. Hosts can either enumerate engines (dynamic) or resolve known descriptors directly (static, isolation-focused deployments), and use the exposed manifests (including `RequiredServices`) to ensure all dependencies are available before wiring engines into request or worker pipelines. | ||
|
|
||
| ## Adapter roles | ||
|
|
||
| Adapters remain optional: they map transport concerns to engines while keeping the engines themselves unaware of ASP.NET, MVC, or Razor. Reference adapters include: | ||
| - `UiEngineAdapter` → executes an `IUiEngine` and surfaces `UiAdapterResponse` with typed navigation tokens. | ||
| - `WebhookEngineAdapter` → validates signatures, enforces idempotency hints, and dispatches to `IWebhookEngine` instances. | ||
|
|
||
| ## Versioning and isolation | ||
|
|
||
| Each engine version is tracked in its manifest. Because descriptors are strongly typed and module-owned, a module can expose multiple UI or webhook engines while remaining an isolation boundary. Hosts that do not require dynamic discovery can register known descriptors directly and resolve them through the discovery service using the explicit generic overloads. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| // Copyright (c) Bravellian | ||
| // | ||
| // Licensed under the Apache License, Version 2.0 (the "License"); | ||
| // you may not use this file except in compliance with the License. | ||
| // You may obtain a copy of the License at | ||
| // | ||
| // http://www.apache.org/licenses/LICENSE-2.0 | ||
| // | ||
| // Unless required by applicable law or agreed to in writing, software | ||
| // distributed under the License is distributed on an "AS IS" BASIS, | ||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| // See the License for the specific language governing permissions and | ||
| // limitations under the License. | ||
|
|
||
| namespace Bravellian.Platform.Modularity; | ||
|
|
||
| /// <summary> | ||
| /// Validates webhook signatures for adapters. | ||
| /// </summary> | ||
| public interface IWebhookSignatureValidator | ||
| { | ||
| /// <summary> | ||
| /// Validates the signature for the provided request. | ||
| /// </summary> | ||
| bool Validate(ModuleEngineSecurity security, IReadOnlyDictionary<string, string> headers, string rawBody, string? providedSignature); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| // Copyright (c) Bravellian | ||
| // | ||
| // Licensed under the Apache License, Version 2.0 (the "License"); | ||
| // you may not use this file except in compliance with the License. | ||
| // You may obtain a copy of the License at | ||
| // | ||
| // http://www.apache.org/licenses/LICENSE-2.0 | ||
| // | ||
| // Unless required by applicable law or agreed to in writing, software | ||
| // distributed under the License is distributed on an "AS IS" BASIS, | ||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| // See the License for the specific language governing permissions and | ||
| // limitations under the License. | ||
|
|
||
| namespace Bravellian.Platform.Modularity; | ||
|
|
||
| /// <summary> | ||
| /// Raw webhook request envelope understood by the transport adapters. | ||
| /// </summary> | ||
| /// <param name="Provider">Webhook provider identifier.</param> | ||
| /// <param name="EventType">Webhook event type.</param> | ||
| /// <param name="Headers">Raw headers supplied by the gateway.</param> | ||
| /// <param name="RawBody">Raw body text for signature validation.</param> | ||
| /// <param name="IdempotencyKey">Idempotency key supplied by provider.</param> | ||
| /// <param name="Attempt">Delivery attempt number.</param> | ||
| /// <param name="Signature">Optional supplied signature.</param> | ||
| /// <param name="Payload">Parsed payload DTO.</param> | ||
| public sealed record WebhookAdapterRequest<TPayload>( | ||
| string Provider, | ||
| string EventType, | ||
| IReadOnlyDictionary<string, string> Headers, | ||
| string RawBody, | ||
| string IdempotencyKey, | ||
| int Attempt, | ||
| string? Signature, | ||
| TPayload Payload); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| // Copyright (c) Bravellian | ||
| // | ||
| // Licensed under the Apache License, Version 2.0 (the "License"); | ||
| // you may not use this file except in compliance with the License. | ||
| // You may obtain a copy of the License at | ||
| // | ||
| // http://www.apache.org/licenses/LICENSE-2.0 | ||
| // | ||
| // Unless required by applicable law or agreed to in writing, software | ||
| // distributed under the License is distributed on an "AS IS" BASIS, | ||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| // See the License for the specific language governing permissions and | ||
| // limitations under the License. | ||
|
|
||
| namespace Bravellian.Platform.Modularity; | ||
|
|
||
| /// <summary> | ||
| /// Adapter response that can be mapped to HTTP or queue responses. | ||
| /// </summary> | ||
| /// <param name="Outcome">Outcome the transport should emit.</param> | ||
| /// <param name="Reason">Optional reason for retries.</param> | ||
| /// <param name="EnqueuedEvent">Optional event payload to enqueue downstream.</param> | ||
| public sealed record WebhookAdapterResponse(WebhookOutcomeType Outcome, string? Reason = null, object? EnqueuedEvent = null); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,152 @@ | ||
| // Copyright (c) Bravellian | ||
| // | ||
| // Licensed under the Apache License, Version 2.0 (the "License"); | ||
| // you may not use this file except in compliance with the License. | ||
| // You may obtain a copy of the License at | ||
| // | ||
| // http://www.apache.org/licenses/LICENSE-2.0 | ||
| // | ||
| // Unless required by applicable law or agreed to in writing, software | ||
| // distributed under the License is distributed on an "AS IS" BASIS, | ||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| // See the License for the specific language governing permissions and | ||
| // limitations under the License. | ||
|
|
||
| using Microsoft.Extensions.DependencyInjection; | ||
|
|
||
| namespace Bravellian.Platform.Modularity; | ||
|
|
||
| /// <summary> | ||
| /// Adapter that connects webhook engines to HTTP-style transports. | ||
| /// </summary> | ||
| public sealed class WebhookEngineAdapter | ||
| { | ||
| private readonly ModuleEngineDiscoveryService discovery; | ||
| private readonly IServiceProvider services; | ||
| private readonly IWebhookSignatureValidator signatureValidator; | ||
|
|
||
| /// <summary> | ||
| /// Initializes a new instance of the <see cref="WebhookEngineAdapter"/> class. | ||
| /// </summary> | ||
| public WebhookEngineAdapter(ModuleEngineDiscoveryService discovery, IServiceProvider services, IWebhookSignatureValidator signatureValidator) | ||
| { | ||
| this.discovery = discovery; | ||
| this.services = services; | ||
| this.signatureValidator = signatureValidator; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Dispatches a webhook request to a registered engine. | ||
| /// </summary> | ||
| public async Task<WebhookAdapterResponse> DispatchAsync<TPayload>(WebhookAdapterRequest<TPayload> request, CancellationToken cancellationToken) | ||
| { | ||
| 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)); | ||
| } | ||
|
|
||
|
SamuelMcAravey marked this conversation as resolved.
SamuelMcAravey marked this conversation as resolved.
|
||
| var descriptor = discovery.ResolveWebhookEngine(request.Provider, request.EventType) | ||
| ?? throw new InvalidOperationException($"No webhook engine registered for provider '{request.Provider}' and event '{request.EventType}'."); | ||
|
|
||
| if (descriptor.Manifest.Security is { } security && | ||
| !signatureValidator.Validate(security, request.Headers, request.RawBody, request.Signature)) | ||
| { | ||
| return new WebhookAdapterResponse( | ||
| WebhookOutcomeType.Acknowledge, | ||
| $"Signature validation failed. Expected algorithm: {security.SignatureAlgorithm}."); | ||
| } | ||
|
|
||
| if (string.IsNullOrWhiteSpace(request.IdempotencyKey) && descriptor.Manifest.Security?.IdempotencyWindow is not null) | ||
|
SamuelMcAravey marked this conversation as resolved.
|
||
| { | ||
| return new WebhookAdapterResponse( | ||
| WebhookOutcomeType.Retry, | ||
| "Idempotency key is required when an idempotency window is configured for this provider."); | ||
| } | ||
|
|
||
| ValidateRequiredServices(descriptor, request.Provider, request.EventType); | ||
|
|
||
| var typedDescriptor = descriptor as ModuleEngineDescriptor<IWebhookEngine<TPayload>> | ||
| ?? throw new InvalidOperationException($"Engine '{descriptor.Manifest.Id}' does not implement expected webhook contract."); | ||
|
|
||
| var engine = discovery.ResolveEngine(typedDescriptor, services); | ||
|
|
||
| var outcome = await engine.HandleAsync( | ||
| new WebhookRequest<TPayload>(request.Provider, request.EventType, request.Payload, request.IdempotencyKey, request.Attempt), | ||
| cancellationToken).ConfigureAwait(false); | ||
|
|
||
| return new WebhookAdapterResponse(outcome.Outcome, outcome.Reason, outcome.EnqueuedEvent); | ||
| } | ||
|
|
||
| private void ValidateRequiredServices(IModuleEngineDescriptor descriptor, string provider, string eventType) | ||
| { | ||
| var requiredServices = new HashSet<string>(StringComparer.OrdinalIgnoreCase); | ||
|
|
||
| if (descriptor.Manifest.RequiredServices is { Count: > 0 } manifestRequired) | ||
| { | ||
| foreach (var service in manifestRequired) | ||
| { | ||
| requiredServices.Add(service); | ||
| } | ||
| } | ||
|
|
||
| if (descriptor.Manifest.WebhookMetadata is { } metadata) | ||
| { | ||
| foreach (var entry in metadata) | ||
| { | ||
| if (!string.Equals(entry.Provider, provider, StringComparison.OrdinalIgnoreCase) | ||
| || !string.Equals(entry.EventType, eventType, StringComparison.OrdinalIgnoreCase)) | ||
| { | ||
| continue; | ||
| } | ||
|
|
||
| if (entry.RequiredServices is null) | ||
| { | ||
| continue; | ||
| } | ||
|
|
||
| foreach (var service in entry.RequiredServices) | ||
| { | ||
| requiredServices.Add(service); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if (requiredServices.Count == 0) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| foreach (var service in requiredServices) | ||
| { | ||
| if (string.IsNullOrWhiteSpace(service)) | ||
| { | ||
| throw new InvalidOperationException( | ||
| $"Engine '{descriptor.ModuleKey}/{descriptor.Manifest.Id}' declares an empty required service identifier."); | ||
| } | ||
| } | ||
|
Comment on lines
+129
to
+136
|
||
|
|
||
| var validator = services.GetService<IRequiredServiceValidator>(); | ||
| if (validator is null) | ||
| { | ||
| throw new InvalidOperationException( | ||
| $"Engine '{descriptor.ModuleKey}/{descriptor.Manifest.Id}' declares required services but no {nameof(IRequiredServiceValidator)} is registered."); | ||
| } | ||
|
|
||
| var missing = validator.GetMissingServices(requiredServices.ToArray()) ?? Array.Empty<string>(); | ||
| if (missing.Count > 0) | ||
| { | ||
| throw new InvalidOperationException( | ||
| $"Engine '{descriptor.ModuleKey}/{descriptor.Manifest.Id}' is missing required services: {string.Join(", ", missing)}."); | ||
| } | ||
| } | ||
|
SamuelMcAravey marked this conversation as resolved.
SamuelMcAravey marked this conversation as resolved.
Comment on lines
+129
to
+151
|
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| // Copyright (c) Bravellian | ||
| // | ||
| // Licensed under the Apache License, Version 2.0 (the "License"); | ||
| // you may not use this file except in compliance with the License. | ||
| // You may obtain a copy of the License at | ||
| // | ||
| // http://www.apache.org/licenses/LICENSE-2.0 | ||
| // | ||
| // Unless required by applicable law or agreed to in writing, software | ||
| // distributed under the License is distributed on an "AS IS" BASIS, | ||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| // See the License for the specific language governing permissions and | ||
| // limitations under the License. | ||
|
|
||
| namespace Bravellian.Platform.Modularity; | ||
|
|
||
| /// <summary> | ||
| /// Supported engine types. Engines are framework-agnostic and must not depend on transport concerns. | ||
| /// </summary> | ||
| public enum EngineKind | ||
| { | ||
| /// <summary> | ||
| /// UI-first engines that produce view models and navigation outcomes. | ||
| /// </summary> | ||
| Ui, | ||
|
|
||
| /// <summary> | ||
| /// Webhook engines that react to external callbacks. | ||
| /// </summary> | ||
| Webhook, | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| // Copyright (c) Bravellian | ||
| // | ||
| // Licensed under the Apache License, Version 2.0 (the "License"); | ||
| // you may not use this file except in compliance with the License. | ||
| // You may obtain a copy of the License at | ||
| // | ||
| // http://www.apache.org/licenses/LICENSE-2.0 | ||
| // | ||
| // Unless required by applicable law or agreed to in writing, software | ||
| // distributed under the License is distributed on an "AS IS" BASIS, | ||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| // See the License for the specific language governing permissions and | ||
| // limitations under the License. | ||
|
|
||
| namespace Bravellian.Platform.Modularity; | ||
|
|
||
| /// <summary> | ||
| /// Marker interface for modules that expose engines. | ||
| /// </summary> | ||
| public interface IEngineModule | ||
| { | ||
| /// <summary> | ||
| /// Provides engine descriptors for the module. | ||
| /// </summary> | ||
| IEnumerable<IModuleEngineDescriptor> DescribeEngines(); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| // Copyright (c) Bravellian | ||
| // | ||
| // Licensed under the Apache License, Version 2.0 (the "License"); | ||
| // you may not use this file except in compliance with the License. | ||
| // You may obtain a copy of the License at | ||
| // | ||
| // http://www.apache.org/licenses/LICENSE-2.0 | ||
| // | ||
| // Unless required by applicable law or agreed to in writing, software | ||
| // distributed under the License is distributed on an "AS IS" BASIS, | ||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| // See the License for the specific language governing permissions and | ||
| // limitations under the License. | ||
|
|
||
| namespace Bravellian.Platform.Modularity; | ||
|
|
||
| /// <summary> | ||
| /// Base abstraction for an engine descriptor. Implementations should remain transport agnostic. | ||
| /// </summary> | ||
| public interface IModuleEngineDescriptor | ||
| { | ||
| string ModuleKey { get; } | ||
|
|
||
| ModuleEngineManifest Manifest { get; } | ||
|
|
||
| Type ContractType { get; } | ||
|
|
||
| object? Create(IServiceProvider serviceProvider); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| // Copyright (c) Bravellian | ||
| // | ||
| // Licensed under the Apache License, Version 2.0 (the "License"); | ||
| // you may not use this file except in compliance with the License. | ||
| // You may obtain a copy of the License at | ||
| // | ||
| // http://www.apache.org/licenses/LICENSE-2.0 | ||
| // | ||
| // Unless required by applicable law or agreed to in writing, software | ||
| // distributed under the License is distributed on an "AS IS" BASIS, | ||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| // See the License for the specific language governing permissions and | ||
| // limitations under the License. | ||
|
|
||
| namespace Bravellian.Platform.Modularity; | ||
|
|
||
| /// <summary> | ||
| /// Validates that required engine services are available for a host. | ||
| /// </summary> | ||
| public interface IRequiredServiceValidator | ||
| { | ||
| /// <summary> | ||
| /// Returns the subset of required services that are missing. | ||
| /// </summary> | ||
| IReadOnlyCollection<string> GetMissingServices(IReadOnlyCollection<string> requiredServices); | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.