Phase 8: Production readiness — failover tests, security hardening, sandboxing, deployment docs
- WP-1-3: Central/site failover + dual-node recovery tests (17 tests) - WP-4: Performance testing framework for target scale (7 tests) - WP-5: Security hardening (LDAPS, JWT key length, no secrets in logs) (11 tests) - WP-6: Script sandboxing adversarial tests (28 tests, all forbidden APIs) - WP-7: Recovery drill test scaffolds (5 tests) - WP-8: Observability validation (structured logs, correlation IDs, metrics) (6 tests) - WP-9: Message contract compatibility (forward/backward compat) (18 tests) - WP-10: Deployment packaging (installation guide, production checklist, topology) - WP-11: Operational runbooks (failover, troubleshooting, maintenance) 92 new tests, all passing. Zero warnings.
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
using System.Data.Common;
|
||||
|
||||
namespace ScadaLink.Commons.Interfaces.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for database access from scripts.
|
||||
/// Implemented by ExternalSystemGateway, consumed by ScriptRuntimeContext.
|
||||
/// </summary>
|
||||
public interface IDatabaseGateway
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns an ADO.NET DbConnection (typically SqlConnection) from the named connection.
|
||||
/// Connection pooling is managed by the underlying provider.
|
||||
/// Caller is responsible for disposing.
|
||||
/// </summary>
|
||||
Task<DbConnection> GetConnectionAsync(
|
||||
string connectionName,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Submits a SQL write to the store-and-forward engine for reliable delivery.
|
||||
/// </summary>
|
||||
Task CachedWriteAsync(
|
||||
string connectionName,
|
||||
string sql,
|
||||
IReadOnlyDictionary<string, object?>? parameters = null,
|
||||
string? originInstanceName = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace ScadaLink.Commons.Interfaces.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for invoking external system HTTP APIs.
|
||||
/// Implemented by ExternalSystemGateway, consumed by ScriptRuntimeContext.
|
||||
/// </summary>
|
||||
public interface IExternalSystemClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Synchronous call to an external system. All failures returned to caller.
|
||||
/// </summary>
|
||||
Task<ExternalCallResult> CallAsync(
|
||||
string systemName,
|
||||
string methodName,
|
||||
IReadOnlyDictionary<string, object?>? parameters = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Attempt immediate delivery; on transient failure, hand to S&F engine.
|
||||
/// Permanent failures returned to caller.
|
||||
/// </summary>
|
||||
Task<ExternalCallResult> CachedCallAsync(
|
||||
string systemName,
|
||||
string methodName,
|
||||
IReadOnlyDictionary<string, object?>? parameters = null,
|
||||
string? originInstanceName = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of an external system call.
|
||||
/// </summary>
|
||||
public record ExternalCallResult(
|
||||
bool Success,
|
||||
string? ResponseJson,
|
||||
string? ErrorMessage,
|
||||
bool WasBuffered = false);
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace ScadaLink.Commons.Interfaces.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves an instance unique name to its site identifier.
|
||||
/// Used by Inbound API's Route.To() to determine which site to route requests to.
|
||||
/// </summary>
|
||||
public interface IInstanceLocator
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves the site identifier for a given instance unique name.
|
||||
/// Returns null if the instance is not found.
|
||||
/// </summary>
|
||||
Task<string?> GetSiteIdForInstanceAsync(
|
||||
string instanceUniqueName,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace ScadaLink.Commons.Interfaces.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for sending notifications.
|
||||
/// Implemented by NotificationService, consumed by ScriptRuntimeContext.
|
||||
/// </summary>
|
||||
public interface INotificationDeliveryService
|
||||
{
|
||||
/// <summary>
|
||||
/// Sends a notification to a named list. Transient failures go to S&F.
|
||||
/// Permanent failures returned to caller.
|
||||
/// </summary>
|
||||
Task<NotificationResult> SendAsync(
|
||||
string listName,
|
||||
string subject,
|
||||
string message,
|
||||
string? originInstanceName = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a notification send attempt.
|
||||
/// </summary>
|
||||
public record NotificationResult(
|
||||
bool Success,
|
||||
string? ErrorMessage,
|
||||
bool WasBuffered = false);
|
||||
@@ -0,0 +1,59 @@
|
||||
namespace ScadaLink.Commons.Messages.InboundApi;
|
||||
|
||||
/// <summary>
|
||||
/// Request routed from Inbound API to a site to invoke a script on an instance.
|
||||
/// Used by Route.To("instanceCode").Call("scriptName", params).
|
||||
/// </summary>
|
||||
public record RouteToCallRequest(
|
||||
string CorrelationId,
|
||||
string InstanceUniqueName,
|
||||
string ScriptName,
|
||||
IReadOnlyDictionary<string, object?>? Parameters,
|
||||
DateTimeOffset Timestamp);
|
||||
|
||||
/// <summary>
|
||||
/// Response from a Route.To() call.
|
||||
/// </summary>
|
||||
public record RouteToCallResponse(
|
||||
string CorrelationId,
|
||||
bool Success,
|
||||
object? ReturnValue,
|
||||
string? ErrorMessage,
|
||||
DateTimeOffset Timestamp);
|
||||
|
||||
/// <summary>
|
||||
/// Request to read attribute(s) from a remote instance.
|
||||
/// </summary>
|
||||
public record RouteToGetAttributesRequest(
|
||||
string CorrelationId,
|
||||
string InstanceUniqueName,
|
||||
IReadOnlyList<string> AttributeNames,
|
||||
DateTimeOffset Timestamp);
|
||||
|
||||
/// <summary>
|
||||
/// Response containing attribute values from a remote instance.
|
||||
/// </summary>
|
||||
public record RouteToGetAttributesResponse(
|
||||
string CorrelationId,
|
||||
IReadOnlyDictionary<string, object?> Values,
|
||||
bool Success,
|
||||
string? ErrorMessage,
|
||||
DateTimeOffset Timestamp);
|
||||
|
||||
/// <summary>
|
||||
/// Request to write attribute(s) on a remote instance.
|
||||
/// </summary>
|
||||
public record RouteToSetAttributesRequest(
|
||||
string CorrelationId,
|
||||
string InstanceUniqueName,
|
||||
IReadOnlyDictionary<string, string> AttributeValues,
|
||||
DateTimeOffset Timestamp);
|
||||
|
||||
/// <summary>
|
||||
/// Response confirming attribute writes on a remote instance.
|
||||
/// </summary>
|
||||
public record RouteToSetAttributesResponse(
|
||||
string CorrelationId,
|
||||
bool Success,
|
||||
string? ErrorMessage,
|
||||
DateTimeOffset Timestamp);
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace ScadaLink.Commons.Messages.Instance;
|
||||
|
||||
/// <summary>
|
||||
/// Batch request to get multiple attribute values from an Instance Actor.
|
||||
/// Used by Route.To().GetAttributes() in Inbound API.
|
||||
/// </summary>
|
||||
public record GetAttributesBatchRequest(
|
||||
string CorrelationId,
|
||||
string InstanceUniqueName,
|
||||
IReadOnlyList<string> AttributeNames,
|
||||
DateTimeOffset Timestamp);
|
||||
|
||||
/// <summary>
|
||||
/// Batch response containing multiple attribute values.
|
||||
/// </summary>
|
||||
public record GetAttributesBatchResponse(
|
||||
string CorrelationId,
|
||||
string InstanceUniqueName,
|
||||
IReadOnlyDictionary<string, object?> Values,
|
||||
bool Success,
|
||||
string? ErrorMessage,
|
||||
DateTimeOffset Timestamp);
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace ScadaLink.Commons.Messages.Instance;
|
||||
|
||||
/// <summary>
|
||||
/// Batch command to set multiple attribute values on an Instance Actor.
|
||||
/// Used by Route.To().SetAttributes() in Inbound API.
|
||||
/// </summary>
|
||||
public record SetAttributesBatchCommand(
|
||||
string CorrelationId,
|
||||
string InstanceUniqueName,
|
||||
IReadOnlyDictionary<string, string> AttributeValues,
|
||||
DateTimeOffset Timestamp);
|
||||
|
||||
/// <summary>
|
||||
/// Batch response confirming multiple attribute writes.
|
||||
/// </summary>
|
||||
public record SetAttributesBatchResponse(
|
||||
string CorrelationId,
|
||||
string InstanceUniqueName,
|
||||
bool Success,
|
||||
string? ErrorMessage,
|
||||
DateTimeOffset Timestamp);
|
||||
@@ -5,6 +5,7 @@ using ScadaLink.Commons.Messages.Artifacts;
|
||||
using ScadaLink.Commons.Messages.DebugView;
|
||||
using ScadaLink.Commons.Messages.Deployment;
|
||||
using ScadaLink.Commons.Messages.Health;
|
||||
using ScadaLink.Commons.Messages.InboundApi;
|
||||
using ScadaLink.Commons.Messages.Integration;
|
||||
using ScadaLink.Commons.Messages.Lifecycle;
|
||||
using ScadaLink.Commons.Messages.RemoteQuery;
|
||||
@@ -143,6 +144,32 @@ public class CommunicationService
|
||||
|
||||
// ── Pattern 8: Heartbeat (site→central, Tell) ──
|
||||
// Heartbeats are received by central, not sent. No method needed here.
|
||||
|
||||
// ── Inbound API Cross-Site Routing (WP-4) ──
|
||||
|
||||
public async Task<RouteToCallResponse> RouteToCallAsync(
|
||||
string siteId, RouteToCallRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var envelope = new SiteEnvelope(siteId, request);
|
||||
return await GetActor().Ask<RouteToCallResponse>(
|
||||
envelope, _options.IntegrationTimeout, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<RouteToGetAttributesResponse> RouteToGetAttributesAsync(
|
||||
string siteId, RouteToGetAttributesRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var envelope = new SiteEnvelope(siteId, request);
|
||||
return await GetActor().Ask<RouteToGetAttributesResponse>(
|
||||
envelope, _options.IntegrationTimeout, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<RouteToSetAttributesResponse> RouteToSetAttributesAsync(
|
||||
string siteId, RouteToSetAttributesRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var envelope = new SiteEnvelope(siteId, request);
|
||||
return await GetActor().Ask<RouteToSetAttributesResponse>(
|
||||
envelope, _options.IntegrationTimeout, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ScadaLink.Commons.Entities.ExternalSystems;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase.Repositories;
|
||||
|
||||
public class ExternalSystemRepository : IExternalSystemRepository
|
||||
{
|
||||
private readonly ScadaLinkDbContext _context;
|
||||
|
||||
public ExternalSystemRepository(ScadaLinkDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<ExternalSystemDefinition?> GetExternalSystemByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<ExternalSystemDefinition>().FindAsync(new object[] { id }, cancellationToken);
|
||||
|
||||
public async Task<IReadOnlyList<ExternalSystemDefinition>> GetAllExternalSystemsAsync(CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<ExternalSystemDefinition>().ToListAsync(cancellationToken);
|
||||
|
||||
public async Task AddExternalSystemAsync(ExternalSystemDefinition definition, CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<ExternalSystemDefinition>().AddAsync(definition, cancellationToken);
|
||||
|
||||
public Task UpdateExternalSystemAsync(ExternalSystemDefinition definition, CancellationToken cancellationToken = default)
|
||||
{ _context.Set<ExternalSystemDefinition>().Update(definition); return Task.CompletedTask; }
|
||||
|
||||
public async Task DeleteExternalSystemAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await GetExternalSystemByIdAsync(id, cancellationToken);
|
||||
if (entity != null) _context.Set<ExternalSystemDefinition>().Remove(entity);
|
||||
}
|
||||
|
||||
public async Task<ExternalSystemMethod?> GetExternalSystemMethodByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<ExternalSystemMethod>().FindAsync(new object[] { id }, cancellationToken);
|
||||
|
||||
public async Task<IReadOnlyList<ExternalSystemMethod>> GetMethodsByExternalSystemIdAsync(int externalSystemId, CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<ExternalSystemMethod>().Where(m => m.ExternalSystemDefinitionId == externalSystemId).ToListAsync(cancellationToken);
|
||||
|
||||
public async Task AddExternalSystemMethodAsync(ExternalSystemMethod method, CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<ExternalSystemMethod>().AddAsync(method, cancellationToken);
|
||||
|
||||
public Task UpdateExternalSystemMethodAsync(ExternalSystemMethod method, CancellationToken cancellationToken = default)
|
||||
{ _context.Set<ExternalSystemMethod>().Update(method); return Task.CompletedTask; }
|
||||
|
||||
public async Task DeleteExternalSystemMethodAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await GetExternalSystemMethodByIdAsync(id, cancellationToken);
|
||||
if (entity != null) _context.Set<ExternalSystemMethod>().Remove(entity);
|
||||
}
|
||||
|
||||
public async Task<DatabaseConnectionDefinition?> GetDatabaseConnectionByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<DatabaseConnectionDefinition>().FindAsync(new object[] { id }, cancellationToken);
|
||||
|
||||
public async Task<IReadOnlyList<DatabaseConnectionDefinition>> GetAllDatabaseConnectionsAsync(CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<DatabaseConnectionDefinition>().ToListAsync(cancellationToken);
|
||||
|
||||
public async Task AddDatabaseConnectionAsync(DatabaseConnectionDefinition definition, CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<DatabaseConnectionDefinition>().AddAsync(definition, cancellationToken);
|
||||
|
||||
public Task UpdateDatabaseConnectionAsync(DatabaseConnectionDefinition definition, CancellationToken cancellationToken = default)
|
||||
{ _context.Set<DatabaseConnectionDefinition>().Update(definition); return Task.CompletedTask; }
|
||||
|
||||
public async Task DeleteDatabaseConnectionAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await GetDatabaseConnectionByIdAsync(id, cancellationToken);
|
||||
if (entity != null) _context.Set<DatabaseConnectionDefinition>().Remove(entity);
|
||||
}
|
||||
|
||||
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
=> await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ScadaLink.Commons.Entities.InboundApi;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase.Repositories;
|
||||
|
||||
public class InboundApiRepository : IInboundApiRepository
|
||||
{
|
||||
private readonly ScadaLinkDbContext _context;
|
||||
|
||||
public InboundApiRepository(ScadaLinkDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<ApiKey?> GetApiKeyByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<ApiKey>().FindAsync(new object[] { id }, cancellationToken);
|
||||
|
||||
public async Task<IReadOnlyList<ApiKey>> GetAllApiKeysAsync(CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<ApiKey>().ToListAsync(cancellationToken);
|
||||
|
||||
public async Task<ApiKey?> GetApiKeyByValueAsync(string keyValue, CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<ApiKey>().FirstOrDefaultAsync(k => k.KeyValue == keyValue, cancellationToken);
|
||||
|
||||
public async Task AddApiKeyAsync(ApiKey apiKey, CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<ApiKey>().AddAsync(apiKey, cancellationToken);
|
||||
|
||||
public Task UpdateApiKeyAsync(ApiKey apiKey, CancellationToken cancellationToken = default)
|
||||
{ _context.Set<ApiKey>().Update(apiKey); return Task.CompletedTask; }
|
||||
|
||||
public async Task DeleteApiKeyAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await GetApiKeyByIdAsync(id, cancellationToken);
|
||||
if (entity != null) _context.Set<ApiKey>().Remove(entity);
|
||||
}
|
||||
|
||||
public async Task<ApiMethod?> GetApiMethodByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<ApiMethod>().FindAsync(new object[] { id }, cancellationToken);
|
||||
|
||||
public async Task<IReadOnlyList<ApiMethod>> GetAllApiMethodsAsync(CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<ApiMethod>().ToListAsync(cancellationToken);
|
||||
|
||||
public async Task<ApiMethod?> GetMethodByNameAsync(string name, CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<ApiMethod>().FirstOrDefaultAsync(m => m.Name == name, cancellationToken);
|
||||
|
||||
public async Task<IReadOnlyList<ApiKey>> GetApprovedKeysForMethodAsync(int methodId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var method = await _context.Set<ApiMethod>().FindAsync(new object[] { methodId }, cancellationToken);
|
||||
if (method?.ApprovedApiKeyIds == null)
|
||||
return new List<ApiKey>();
|
||||
|
||||
var keyIds = method.ApprovedApiKeyIds.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(s => int.TryParse(s.Trim(), out var id) ? id : -1)
|
||||
.Where(id => id > 0)
|
||||
.ToList();
|
||||
|
||||
return await _context.Set<ApiKey>().Where(k => keyIds.Contains(k.Id)).ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task AddApiMethodAsync(ApiMethod method, CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<ApiMethod>().AddAsync(method, cancellationToken);
|
||||
|
||||
public Task UpdateApiMethodAsync(ApiMethod method, CancellationToken cancellationToken = default)
|
||||
{ _context.Set<ApiMethod>().Update(method); return Task.CompletedTask; }
|
||||
|
||||
public async Task DeleteApiMethodAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await GetApiMethodByIdAsync(id, cancellationToken);
|
||||
if (entity != null) _context.Set<ApiMethod>().Remove(entity);
|
||||
}
|
||||
|
||||
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
=> await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ScadaLink.Commons.Entities.Notifications;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase.Repositories;
|
||||
|
||||
public class NotificationRepository : INotificationRepository
|
||||
{
|
||||
private readonly ScadaLinkDbContext _context;
|
||||
|
||||
public NotificationRepository(ScadaLinkDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<NotificationList?> GetNotificationListByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<NotificationList>().FindAsync(new object[] { id }, cancellationToken);
|
||||
|
||||
public async Task<IReadOnlyList<NotificationList>> GetAllNotificationListsAsync(CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<NotificationList>().ToListAsync(cancellationToken);
|
||||
|
||||
public async Task<NotificationList?> GetListByNameAsync(string name, CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<NotificationList>().FirstOrDefaultAsync(l => l.Name == name, cancellationToken);
|
||||
|
||||
public async Task AddNotificationListAsync(NotificationList list, CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<NotificationList>().AddAsync(list, cancellationToken);
|
||||
|
||||
public Task UpdateNotificationListAsync(NotificationList list, CancellationToken cancellationToken = default)
|
||||
{ _context.Set<NotificationList>().Update(list); return Task.CompletedTask; }
|
||||
|
||||
public async Task DeleteNotificationListAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await GetNotificationListByIdAsync(id, cancellationToken);
|
||||
if (entity != null) _context.Set<NotificationList>().Remove(entity);
|
||||
}
|
||||
|
||||
public async Task<NotificationRecipient?> GetRecipientByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<NotificationRecipient>().FindAsync(new object[] { id }, cancellationToken);
|
||||
|
||||
public async Task<IReadOnlyList<NotificationRecipient>> GetRecipientsByListIdAsync(int notificationListId, CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<NotificationRecipient>().Where(r => r.NotificationListId == notificationListId).ToListAsync(cancellationToken);
|
||||
|
||||
public async Task AddRecipientAsync(NotificationRecipient recipient, CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<NotificationRecipient>().AddAsync(recipient, cancellationToken);
|
||||
|
||||
public Task UpdateRecipientAsync(NotificationRecipient recipient, CancellationToken cancellationToken = default)
|
||||
{ _context.Set<NotificationRecipient>().Update(recipient); return Task.CompletedTask; }
|
||||
|
||||
public async Task DeleteRecipientAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await GetRecipientByIdAsync(id, cancellationToken);
|
||||
if (entity != null) _context.Set<NotificationRecipient>().Remove(entity);
|
||||
}
|
||||
|
||||
public async Task<SmtpConfiguration?> GetSmtpConfigurationByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<SmtpConfiguration>().FindAsync(new object[] { id }, cancellationToken);
|
||||
|
||||
public async Task<IReadOnlyList<SmtpConfiguration>> GetAllSmtpConfigurationsAsync(CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<SmtpConfiguration>().ToListAsync(cancellationToken);
|
||||
|
||||
public async Task AddSmtpConfigurationAsync(SmtpConfiguration configuration, CancellationToken cancellationToken = default)
|
||||
=> await _context.Set<SmtpConfiguration>().AddAsync(configuration, cancellationToken);
|
||||
|
||||
public Task UpdateSmtpConfigurationAsync(SmtpConfiguration configuration, CancellationToken cancellationToken = default)
|
||||
{ _context.Set<SmtpConfiguration>().Update(configuration); return Task.CompletedTask; }
|
||||
|
||||
public async Task DeleteSmtpConfigurationAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await GetSmtpConfigurationByIdAsync(id, cancellationToken);
|
||||
if (entity != null) _context.Set<SmtpConfiguration>().Remove(entity);
|
||||
}
|
||||
|
||||
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
=> await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves instance unique names to site identifiers using the configuration database.
|
||||
/// </summary>
|
||||
public class InstanceLocator : IInstanceLocator
|
||||
{
|
||||
private readonly ScadaLinkDbContext _context;
|
||||
|
||||
public InstanceLocator(ScadaLinkDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<string?> GetSiteIdForInstanceAsync(
|
||||
string instanceUniqueName,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var instance = await _context.Set<Commons.Entities.Instances.Instance>()
|
||||
.FirstOrDefaultAsync(i => i.UniqueName == instanceUniqueName, cancellationToken);
|
||||
|
||||
if (instance == null)
|
||||
return null;
|
||||
|
||||
var site = await _context.Set<Commons.Entities.Sites.Site>()
|
||||
.FindAsync(new object[] { instance.SiteId }, cancellationToken);
|
||||
|
||||
return site?.SiteIdentifier;
|
||||
}
|
||||
}
|
||||
98
src/ScadaLink.ExternalSystemGateway/DatabaseGateway.cs
Normal file
98
src/ScadaLink.ExternalSystemGateway/DatabaseGateway.cs
Normal file
@@ -0,0 +1,98 @@
|
||||
using System.Data.Common;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ScadaLink.Commons.Entities.ExternalSystems;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
using ScadaLink.StoreAndForward;
|
||||
|
||||
namespace ScadaLink.ExternalSystemGateway;
|
||||
|
||||
/// <summary>
|
||||
/// WP-9: Database access from scripts.
|
||||
/// Database.Connection("name") — returns ADO.NET SqlConnection (connection pooling).
|
||||
/// Database.CachedWrite("name", "sql", params) — submits to S&F engine.
|
||||
/// </summary>
|
||||
public class DatabaseGateway : IDatabaseGateway
|
||||
{
|
||||
private readonly IExternalSystemRepository _repository;
|
||||
private readonly StoreAndForwardService? _storeAndForward;
|
||||
private readonly ILogger<DatabaseGateway> _logger;
|
||||
|
||||
public DatabaseGateway(
|
||||
IExternalSystemRepository repository,
|
||||
ILogger<DatabaseGateway> logger,
|
||||
StoreAndForwardService? storeAndForward = null)
|
||||
{
|
||||
_repository = repository;
|
||||
_logger = logger;
|
||||
_storeAndForward = storeAndForward;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns an open SqlConnection from the named database connection definition.
|
||||
/// Connection pooling is managed by the underlying ADO.NET provider.
|
||||
/// </summary>
|
||||
public async Task<DbConnection> GetConnectionAsync(
|
||||
string connectionName,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var definition = await ResolveConnectionAsync(connectionName, cancellationToken);
|
||||
if (definition == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Database connection '{connectionName}' not found");
|
||||
}
|
||||
|
||||
var connection = new SqlConnection(definition.ConnectionString);
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
return connection;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Submits a SQL write to the store-and-forward engine for reliable delivery.
|
||||
/// </summary>
|
||||
public async Task CachedWriteAsync(
|
||||
string connectionName,
|
||||
string sql,
|
||||
IReadOnlyDictionary<string, object?>? parameters = null,
|
||||
string? originInstanceName = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var definition = await ResolveConnectionAsync(connectionName, cancellationToken);
|
||||
if (definition == null)
|
||||
{
|
||||
throw new InvalidOperationException($"Database connection '{connectionName}' not found");
|
||||
}
|
||||
|
||||
if (_storeAndForward == null)
|
||||
{
|
||||
throw new InvalidOperationException("Store-and-forward service not available for cached writes");
|
||||
}
|
||||
|
||||
var payload = JsonSerializer.Serialize(new
|
||||
{
|
||||
ConnectionName = connectionName,
|
||||
Sql = sql,
|
||||
Parameters = parameters
|
||||
});
|
||||
|
||||
await _storeAndForward.EnqueueAsync(
|
||||
StoreAndForwardCategory.CachedDbWrite,
|
||||
connectionName,
|
||||
payload,
|
||||
originInstanceName,
|
||||
definition.MaxRetries > 0 ? definition.MaxRetries : null,
|
||||
definition.RetryDelay > TimeSpan.Zero ? definition.RetryDelay : null);
|
||||
}
|
||||
|
||||
private async Task<DatabaseConnectionDefinition?> ResolveConnectionAsync(
|
||||
string connectionName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var connections = await _repository.GetAllDatabaseConnectionsAsync(cancellationToken);
|
||||
return connections.FirstOrDefault(c =>
|
||||
c.Name.Equals(connectionName, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
62
src/ScadaLink.ExternalSystemGateway/ErrorClassifier.cs
Normal file
62
src/ScadaLink.ExternalSystemGateway/ErrorClassifier.cs
Normal file
@@ -0,0 +1,62 @@
|
||||
using System.Net;
|
||||
|
||||
namespace ScadaLink.ExternalSystemGateway;
|
||||
|
||||
/// <summary>
|
||||
/// WP-8: Classifies HTTP errors as transient or permanent.
|
||||
/// Transient: connection refused, timeout, HTTP 408/429/5xx.
|
||||
/// Permanent: HTTP 4xx (except 408/429).
|
||||
/// </summary>
|
||||
public static class ErrorClassifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Determines whether an HTTP status code represents a transient failure.
|
||||
/// </summary>
|
||||
public static bool IsTransient(HttpStatusCode statusCode)
|
||||
{
|
||||
var code = (int)statusCode;
|
||||
return code >= 500 || code == 408 || code == 429;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether an exception represents a transient failure.
|
||||
/// </summary>
|
||||
public static bool IsTransient(Exception exception)
|
||||
{
|
||||
return exception is HttpRequestException
|
||||
or TaskCanceledException
|
||||
or TimeoutException
|
||||
or OperationCanceledException;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a TransientException for S&F buffering.
|
||||
/// </summary>
|
||||
public static TransientExternalSystemException AsTransient(string message, Exception? inner = null)
|
||||
{
|
||||
return new TransientExternalSystemException(message, inner);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception type that signals a transient failure suitable for store-and-forward retry.
|
||||
/// </summary>
|
||||
public class TransientExternalSystemException : Exception
|
||||
{
|
||||
public TransientExternalSystemException(string message, Exception? innerException = null)
|
||||
: base(message, innerException) { }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exception type that signals a permanent failure (should not be retried).
|
||||
/// </summary>
|
||||
public class PermanentExternalSystemException : Exception
|
||||
{
|
||||
public int? HttpStatusCode { get; }
|
||||
|
||||
public PermanentExternalSystemException(string message, int? httpStatusCode = null, Exception? innerException = null)
|
||||
: base(message, innerException)
|
||||
{
|
||||
HttpStatusCode = httpStatusCode;
|
||||
}
|
||||
}
|
||||
246
src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs
Normal file
246
src/ScadaLink.ExternalSystemGateway/ExternalSystemClient.cs
Normal file
@@ -0,0 +1,246 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ScadaLink.Commons.Entities.ExternalSystems;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
using ScadaLink.StoreAndForward;
|
||||
|
||||
namespace ScadaLink.ExternalSystemGateway;
|
||||
|
||||
/// <summary>
|
||||
/// WP-6: HTTP/REST client that invokes external APIs.
|
||||
/// WP-7: Dual call modes — Call (synchronous) and CachedCall (S&F on transient failure).
|
||||
/// WP-8: Error classification applied to HTTP responses and exceptions.
|
||||
/// </summary>
|
||||
public class ExternalSystemClient : IExternalSystemClient
|
||||
{
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IExternalSystemRepository _repository;
|
||||
private readonly StoreAndForwardService? _storeAndForward;
|
||||
private readonly ILogger<ExternalSystemClient> _logger;
|
||||
|
||||
public ExternalSystemClient(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IExternalSystemRepository repository,
|
||||
ILogger<ExternalSystemClient> logger,
|
||||
StoreAndForwardService? storeAndForward = null)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_repository = repository;
|
||||
_logger = logger;
|
||||
_storeAndForward = storeAndForward;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WP-7: Synchronous call — all failures returned to caller.
|
||||
/// </summary>
|
||||
public async Task<ExternalCallResult> CallAsync(
|
||||
string systemName,
|
||||
string methodName,
|
||||
IReadOnlyDictionary<string, object?>? parameters = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var (system, method) = await ResolveSystemAndMethodAsync(systemName, methodName, cancellationToken);
|
||||
if (system == null || method == null)
|
||||
{
|
||||
return new ExternalCallResult(false, null, $"External system '{systemName}' or method '{methodName}' not found");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var response = await InvokeHttpAsync(system, method, parameters, cancellationToken);
|
||||
return new ExternalCallResult(true, response, null);
|
||||
}
|
||||
catch (TransientExternalSystemException ex)
|
||||
{
|
||||
return new ExternalCallResult(false, null, $"Transient error: {ex.Message}");
|
||||
}
|
||||
catch (PermanentExternalSystemException ex)
|
||||
{
|
||||
return new ExternalCallResult(false, null, $"Permanent error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WP-7: CachedCall — attempt immediate, transient failure goes to S&F, permanent returned to script.
|
||||
/// </summary>
|
||||
public async Task<ExternalCallResult> CachedCallAsync(
|
||||
string systemName,
|
||||
string methodName,
|
||||
IReadOnlyDictionary<string, object?>? parameters = null,
|
||||
string? originInstanceName = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var (system, method) = await ResolveSystemAndMethodAsync(systemName, methodName, cancellationToken);
|
||||
if (system == null || method == null)
|
||||
{
|
||||
return new ExternalCallResult(false, null, $"External system '{systemName}' or method '{methodName}' not found");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var response = await InvokeHttpAsync(system, method, parameters, cancellationToken);
|
||||
return new ExternalCallResult(true, response, null);
|
||||
}
|
||||
catch (PermanentExternalSystemException ex)
|
||||
{
|
||||
// Permanent failures returned to script, never buffered
|
||||
return new ExternalCallResult(false, null, $"Permanent error: {ex.Message}");
|
||||
}
|
||||
catch (TransientExternalSystemException)
|
||||
{
|
||||
// Transient failure — hand to S&F
|
||||
if (_storeAndForward == null)
|
||||
{
|
||||
return new ExternalCallResult(false, null, "Transient error and store-and-forward not available");
|
||||
}
|
||||
|
||||
var payload = JsonSerializer.Serialize(new
|
||||
{
|
||||
SystemName = systemName,
|
||||
MethodName = methodName,
|
||||
Parameters = parameters
|
||||
});
|
||||
|
||||
var sfResult = await _storeAndForward.EnqueueAsync(
|
||||
StoreAndForwardCategory.ExternalSystem,
|
||||
systemName,
|
||||
payload,
|
||||
originInstanceName,
|
||||
system.MaxRetries > 0 ? system.MaxRetries : null,
|
||||
system.RetryDelay > TimeSpan.Zero ? system.RetryDelay : null);
|
||||
|
||||
return new ExternalCallResult(true, null, null, WasBuffered: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WP-6: Executes the HTTP request against the external system.
|
||||
/// </summary>
|
||||
internal async Task<string?> InvokeHttpAsync(
|
||||
ExternalSystemDefinition system,
|
||||
ExternalSystemMethod method,
|
||||
IReadOnlyDictionary<string, object?>? parameters,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient($"ExternalSystem_{system.Name}");
|
||||
|
||||
var url = BuildUrl(system.EndpointUrl, method.Path, parameters, method.HttpMethod);
|
||||
var request = new HttpRequestMessage(new HttpMethod(method.HttpMethod), url);
|
||||
|
||||
// Apply authentication
|
||||
ApplyAuth(request, system);
|
||||
|
||||
// For POST/PUT/PATCH, send parameters as JSON body
|
||||
if (method.HttpMethod.Equals("POST", StringComparison.OrdinalIgnoreCase) ||
|
||||
method.HttpMethod.Equals("PUT", StringComparison.OrdinalIgnoreCase) ||
|
||||
method.HttpMethod.Equals("PATCH", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (parameters != null && parameters.Count > 0)
|
||||
{
|
||||
request.Content = new StringContent(
|
||||
JsonSerializer.Serialize(parameters),
|
||||
Encoding.UTF8,
|
||||
"application/json");
|
||||
}
|
||||
}
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await client.SendAsync(request, cancellationToken);
|
||||
}
|
||||
catch (Exception ex) when (ErrorClassifier.IsTransient(ex))
|
||||
{
|
||||
throw ErrorClassifier.AsTransient($"Connection error to {system.Name}: {ex.Message}", ex);
|
||||
}
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
}
|
||||
|
||||
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
|
||||
if (ErrorClassifier.IsTransient(response.StatusCode))
|
||||
{
|
||||
throw ErrorClassifier.AsTransient(
|
||||
$"HTTP {(int)response.StatusCode} from {system.Name}: {errorBody}");
|
||||
}
|
||||
|
||||
throw new PermanentExternalSystemException(
|
||||
$"HTTP {(int)response.StatusCode} from {system.Name}: {errorBody}",
|
||||
(int)response.StatusCode);
|
||||
}
|
||||
|
||||
private static string BuildUrl(string baseUrl, string path, IReadOnlyDictionary<string, object?>? parameters, string httpMethod)
|
||||
{
|
||||
var url = baseUrl.TrimEnd('/') + "/" + path.TrimStart('/');
|
||||
|
||||
// For GET/DELETE, append parameters as query string
|
||||
if ((httpMethod.Equals("GET", StringComparison.OrdinalIgnoreCase) ||
|
||||
httpMethod.Equals("DELETE", StringComparison.OrdinalIgnoreCase)) &&
|
||||
parameters != null && parameters.Count > 0)
|
||||
{
|
||||
var queryString = string.Join("&",
|
||||
parameters.Where(p => p.Value != null)
|
||||
.Select(p => $"{Uri.EscapeDataString(p.Key)}={Uri.EscapeDataString(p.Value?.ToString() ?? "")}"));
|
||||
url += "?" + queryString;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
private static void ApplyAuth(HttpRequestMessage request, ExternalSystemDefinition system)
|
||||
{
|
||||
if (string.IsNullOrEmpty(system.AuthConfiguration))
|
||||
return;
|
||||
|
||||
switch (system.AuthType.ToLowerInvariant())
|
||||
{
|
||||
case "apikey":
|
||||
// Auth config format: "HeaderName:KeyValue" or just "KeyValue" (default header: X-API-Key)
|
||||
var parts = system.AuthConfiguration.Split(':', 2);
|
||||
if (parts.Length == 2)
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(parts[0], parts[1]);
|
||||
}
|
||||
else
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("X-API-Key", system.AuthConfiguration);
|
||||
}
|
||||
break;
|
||||
|
||||
case "basic":
|
||||
// Auth config format: "username:password"
|
||||
var basicParts = system.AuthConfiguration.Split(':', 2);
|
||||
if (basicParts.Length == 2)
|
||||
{
|
||||
var encoded = Convert.ToBase64String(
|
||||
Encoding.UTF8.GetBytes($"{basicParts[0]}:{basicParts[1]}"));
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", encoded);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(ExternalSystemDefinition? system, ExternalSystemMethod? method)> ResolveSystemAndMethodAsync(
|
||||
string systemName,
|
||||
string methodName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var systems = await _repository.GetAllExternalSystemsAsync(cancellationToken);
|
||||
var system = systems.FirstOrDefault(s => s.Name.Equals(systemName, StringComparison.OrdinalIgnoreCase));
|
||||
if (system == null)
|
||||
return (null, null);
|
||||
|
||||
var methods = await _repository.GetMethodsByExternalSystemIdAsync(system.Id, cancellationToken);
|
||||
var method = methods.FirstOrDefault(m => m.Name.Equals(methodName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
return (system, method);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace ScadaLink.ExternalSystemGateway;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the External System Gateway component.
|
||||
/// </summary>
|
||||
public class ExternalSystemGatewayOptions
|
||||
{
|
||||
/// <summary>Default HTTP request timeout per external system call.</summary>
|
||||
public TimeSpan DefaultHttpTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>Maximum number of concurrent HTTP connections per external system.</summary>
|
||||
public int MaxConcurrentConnectionsPerSystem { get; set; } = 10;
|
||||
}
|
||||
@@ -8,12 +8,20 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.5" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../ScadaLink.Commons/ScadaLink.Commons.csproj" />
|
||||
<ProjectReference Include="../ScadaLink.StoreAndForward/ScadaLink.StoreAndForward.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ScadaLink.ExternalSystemGateway.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
|
||||
namespace ScadaLink.ExternalSystemGateway;
|
||||
|
||||
@@ -6,13 +7,22 @@ public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddExternalSystemGateway(this IServiceCollection services)
|
||||
{
|
||||
// Phase 0: skeleton only
|
||||
services.AddOptions<ExternalSystemGatewayOptions>()
|
||||
.BindConfiguration("ScadaLink:ExternalSystemGateway");
|
||||
|
||||
services.AddHttpClient();
|
||||
services.AddSingleton<ExternalSystemClient>();
|
||||
services.AddSingleton<IExternalSystemClient>(sp => sp.GetRequiredService<ExternalSystemClient>());
|
||||
services.AddSingleton<DatabaseGateway>();
|
||||
services.AddSingleton<IDatabaseGateway>(sp => sp.GetRequiredService<DatabaseGateway>());
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddExternalSystemGatewayActors(this IServiceCollection services)
|
||||
{
|
||||
// Phase 0: placeholder for Akka actor registration
|
||||
// WP-10: Actor registration happens in AkkaHostedService.
|
||||
// Script Execution Actors run on dedicated blocking I/O dispatcher.
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ScadaLink.HealthMonitoring.Tests" />
|
||||
<InternalsVisibleTo Include="ScadaLink.IntegrationTests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
80
src/ScadaLink.InboundAPI/ApiKeyValidator.cs
Normal file
80
src/ScadaLink.InboundAPI/ApiKeyValidator.cs
Normal file
@@ -0,0 +1,80 @@
|
||||
using ScadaLink.Commons.Entities.InboundApi;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
|
||||
namespace ScadaLink.InboundAPI;
|
||||
|
||||
/// <summary>
|
||||
/// WP-1: Validates API keys from X-API-Key header.
|
||||
/// Checks that the key exists, is enabled, and is approved for the requested method.
|
||||
/// </summary>
|
||||
public class ApiKeyValidator
|
||||
{
|
||||
private readonly IInboundApiRepository _repository;
|
||||
|
||||
public ApiKeyValidator(IInboundApiRepository repository)
|
||||
{
|
||||
_repository = repository;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates an API key for a given method.
|
||||
/// Returns (isValid, apiKey, statusCode, errorMessage).
|
||||
/// </summary>
|
||||
public async Task<ApiKeyValidationResult> ValidateAsync(
|
||||
string? apiKeyValue,
|
||||
string methodName,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrEmpty(apiKeyValue))
|
||||
{
|
||||
return ApiKeyValidationResult.Unauthorized("Missing X-API-Key header");
|
||||
}
|
||||
|
||||
var apiKey = await _repository.GetApiKeyByValueAsync(apiKeyValue, cancellationToken);
|
||||
if (apiKey == null || !apiKey.IsEnabled)
|
||||
{
|
||||
return ApiKeyValidationResult.Unauthorized("Invalid or disabled API key");
|
||||
}
|
||||
|
||||
var method = await _repository.GetMethodByNameAsync(methodName, cancellationToken);
|
||||
if (method == null)
|
||||
{
|
||||
return ApiKeyValidationResult.NotFound($"Method '{methodName}' not found");
|
||||
}
|
||||
|
||||
// Check if this key is approved for the method
|
||||
var approvedKeys = await _repository.GetApprovedKeysForMethodAsync(method.Id, cancellationToken);
|
||||
var isApproved = approvedKeys.Any(k => k.Id == apiKey.Id);
|
||||
|
||||
if (!isApproved)
|
||||
{
|
||||
return ApiKeyValidationResult.Forbidden("API key not approved for this method");
|
||||
}
|
||||
|
||||
return ApiKeyValidationResult.Valid(apiKey, method);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of API key validation.
|
||||
/// </summary>
|
||||
public class ApiKeyValidationResult
|
||||
{
|
||||
public bool IsValid { get; private init; }
|
||||
public int StatusCode { get; private init; }
|
||||
public string? ErrorMessage { get; private init; }
|
||||
public ApiKey? ApiKey { get; private init; }
|
||||
public ApiMethod? Method { get; private init; }
|
||||
|
||||
public static ApiKeyValidationResult Valid(ApiKey apiKey, ApiMethod method) =>
|
||||
new() { IsValid = true, StatusCode = 200, ApiKey = apiKey, Method = method };
|
||||
|
||||
public static ApiKeyValidationResult Unauthorized(string message) =>
|
||||
new() { IsValid = false, StatusCode = 401, ErrorMessage = message };
|
||||
|
||||
public static ApiKeyValidationResult Forbidden(string message) =>
|
||||
new() { IsValid = false, StatusCode = 403, ErrorMessage = message };
|
||||
|
||||
public static ApiKeyValidationResult NotFound(string message) =>
|
||||
new() { IsValid = false, StatusCode = 400, ErrorMessage = message };
|
||||
}
|
||||
@@ -1,12 +1,107 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ScadaLink.InboundAPI;
|
||||
|
||||
/// <summary>
|
||||
/// WP-1: POST /api/{methodName} endpoint registration.
|
||||
/// WP-2: Method routing and parameter validation.
|
||||
/// WP-3: Script execution on central.
|
||||
/// WP-5: Error handling — 401, 403, 400, 500.
|
||||
/// </summary>
|
||||
public static class EndpointExtensions
|
||||
{
|
||||
public static IEndpointRouteBuilder MapInboundAPI(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
// Phase 0: skeleton only
|
||||
endpoints.MapPost("/api/{methodName}", HandleInboundApiRequest);
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleInboundApiRequest(
|
||||
HttpContext httpContext,
|
||||
string methodName)
|
||||
{
|
||||
var logger = httpContext.RequestServices.GetRequiredService<ILogger<ApiKeyValidator>>();
|
||||
var validator = httpContext.RequestServices.GetRequiredService<ApiKeyValidator>();
|
||||
var executor = httpContext.RequestServices.GetRequiredService<InboundScriptExecutor>();
|
||||
var routeHelper = httpContext.RequestServices.GetRequiredService<RouteHelper>();
|
||||
var options = httpContext.RequestServices.GetRequiredService<IOptions<InboundApiOptions>>().Value;
|
||||
|
||||
// WP-1: Extract and validate API key
|
||||
var apiKeyValue = httpContext.Request.Headers["X-API-Key"].FirstOrDefault();
|
||||
var validationResult = await validator.ValidateAsync(apiKeyValue, methodName, httpContext.RequestAborted);
|
||||
|
||||
if (!validationResult.IsValid)
|
||||
{
|
||||
// WP-5: Failures-only logging
|
||||
logger.LogWarning(
|
||||
"Inbound API auth failure for method {Method}: {Error} (status {StatusCode})",
|
||||
methodName, validationResult.ErrorMessage, validationResult.StatusCode);
|
||||
|
||||
return Results.Json(
|
||||
new { error = validationResult.ErrorMessage },
|
||||
statusCode: validationResult.StatusCode);
|
||||
}
|
||||
|
||||
var method = validationResult.Method!;
|
||||
|
||||
// WP-2: Deserialize and validate parameters
|
||||
JsonElement? body = null;
|
||||
try
|
||||
{
|
||||
if (httpContext.Request.ContentLength > 0 || httpContext.Request.ContentType?.Contains("json") == true)
|
||||
{
|
||||
using var doc = await JsonDocument.ParseAsync(
|
||||
httpContext.Request.Body, cancellationToken: httpContext.RequestAborted);
|
||||
body = doc.RootElement.Clone();
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return Results.Json(
|
||||
new { error = "Invalid JSON in request body" },
|
||||
statusCode: 400);
|
||||
}
|
||||
|
||||
var paramResult = ParameterValidator.Validate(body, method.ParameterDefinitions);
|
||||
if (!paramResult.IsValid)
|
||||
{
|
||||
return Results.Json(
|
||||
new { error = paramResult.ErrorMessage },
|
||||
statusCode: 400);
|
||||
}
|
||||
|
||||
// WP-3: Execute the method's script
|
||||
var timeout = method.TimeoutSeconds > 0
|
||||
? TimeSpan.FromSeconds(method.TimeoutSeconds)
|
||||
: options.DefaultMethodTimeout;
|
||||
|
||||
var scriptResult = await executor.ExecuteAsync(
|
||||
method, paramResult.Parameters, routeHelper, timeout, httpContext.RequestAborted);
|
||||
|
||||
if (!scriptResult.Success)
|
||||
{
|
||||
// WP-5: 500 for script failures, safe error message
|
||||
logger.LogWarning(
|
||||
"Inbound API script failure for method {Method}: {Error}",
|
||||
methodName, scriptResult.ErrorMessage);
|
||||
|
||||
return Results.Json(
|
||||
new { error = scriptResult.ErrorMessage ?? "Internal server error" },
|
||||
statusCode: 500);
|
||||
}
|
||||
|
||||
// Return the script result as JSON
|
||||
if (scriptResult.ResultJson != null)
|
||||
{
|
||||
return Results.Text(scriptResult.ResultJson, "application/json", statusCode: 200);
|
||||
}
|
||||
|
||||
return Results.Ok();
|
||||
}
|
||||
}
|
||||
|
||||
109
src/ScadaLink.InboundAPI/InboundScriptExecutor.cs
Normal file
109
src/ScadaLink.InboundAPI/InboundScriptExecutor.cs
Normal file
@@ -0,0 +1,109 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ScadaLink.Commons.Entities.InboundApi;
|
||||
|
||||
namespace ScadaLink.InboundAPI;
|
||||
|
||||
/// <summary>
|
||||
/// WP-3: Executes the C# script associated with an inbound API method.
|
||||
/// The script receives input parameters and a route helper, and returns a result
|
||||
/// that is serialized as the JSON response.
|
||||
///
|
||||
/// In a full implementation this would use Roslyn scripting. For now, scripts
|
||||
/// are a simple dispatch table so the rest of the pipeline can be tested end-to-end.
|
||||
/// </summary>
|
||||
public class InboundScriptExecutor
|
||||
{
|
||||
private readonly ILogger<InboundScriptExecutor> _logger;
|
||||
private readonly Dictionary<string, Func<InboundScriptContext, Task<object?>>> _scriptHandlers = new();
|
||||
|
||||
public InboundScriptExecutor(ILogger<InboundScriptExecutor> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a compiled script handler for a method name.
|
||||
/// In production, this would be called after Roslyn compilation of the method's Script property.
|
||||
/// </summary>
|
||||
public void RegisterHandler(string methodName, Func<InboundScriptContext, Task<object?>> handler)
|
||||
{
|
||||
_scriptHandlers[methodName] = handler;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes the script for the given method with the provided context.
|
||||
/// </summary>
|
||||
public async Task<InboundScriptResult> ExecuteAsync(
|
||||
ApiMethod method,
|
||||
IReadOnlyDictionary<string, object?> parameters,
|
||||
RouteHelper route,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
cts.CancelAfter(timeout);
|
||||
|
||||
var context = new InboundScriptContext(parameters, route, cts.Token);
|
||||
|
||||
object? result;
|
||||
if (_scriptHandlers.TryGetValue(method.Name, out var handler))
|
||||
{
|
||||
result = await handler(context).WaitAsync(cts.Token);
|
||||
}
|
||||
else
|
||||
{
|
||||
// No compiled handler — this means the script hasn't been registered.
|
||||
// In production, we'd compile the method.Script and cache it.
|
||||
return new InboundScriptResult(false, null, "Script not compiled or registered for this method");
|
||||
}
|
||||
|
||||
var resultJson = result != null
|
||||
? JsonSerializer.Serialize(result)
|
||||
: null;
|
||||
|
||||
return new InboundScriptResult(true, resultJson, null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning("Script execution timed out for method {Method}", method.Name);
|
||||
return new InboundScriptResult(false, null, "Script execution timed out");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Script execution failed for method {Method}", method.Name);
|
||||
// WP-5: Safe error message, no internal details
|
||||
return new InboundScriptResult(false, null, "Internal script error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context provided to inbound API scripts.
|
||||
/// </summary>
|
||||
public class InboundScriptContext
|
||||
{
|
||||
public IReadOnlyDictionary<string, object?> Parameters { get; }
|
||||
public RouteHelper Route { get; }
|
||||
public CancellationToken CancellationToken { get; }
|
||||
|
||||
public InboundScriptContext(
|
||||
IReadOnlyDictionary<string, object?> parameters,
|
||||
RouteHelper route,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Parameters = parameters;
|
||||
Route = route;
|
||||
CancellationToken = cancellationToken;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of executing an inbound API script.
|
||||
/// </summary>
|
||||
public record InboundScriptResult(
|
||||
bool Success,
|
||||
string? ResultJson,
|
||||
string? ErrorMessage);
|
||||
149
src/ScadaLink.InboundAPI/ParameterValidator.cs
Normal file
149
src/ScadaLink.InboundAPI/ParameterValidator.cs
Normal file
@@ -0,0 +1,149 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ScadaLink.InboundAPI;
|
||||
|
||||
/// <summary>
|
||||
/// WP-2: Validates and deserializes JSON request body against method parameter definitions.
|
||||
/// Extended type system: Boolean, Integer, Float, String, Object, List.
|
||||
/// </summary>
|
||||
public static class ParameterValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates the request body against the method's parameter definitions.
|
||||
/// Returns deserialized parameters or an error message.
|
||||
/// </summary>
|
||||
public static ParameterValidationResult Validate(
|
||||
JsonElement? body,
|
||||
string? parameterDefinitions)
|
||||
{
|
||||
if (string.IsNullOrEmpty(parameterDefinitions))
|
||||
{
|
||||
// No parameters defined — body should be empty or null
|
||||
return ParameterValidationResult.Valid(new Dictionary<string, object?>());
|
||||
}
|
||||
|
||||
List<ParameterDefinition> definitions;
|
||||
try
|
||||
{
|
||||
definitions = JsonSerializer.Deserialize<List<ParameterDefinition>>(
|
||||
parameterDefinitions,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
|
||||
?? [];
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return ParameterValidationResult.Invalid("Invalid parameter definitions in method configuration");
|
||||
}
|
||||
|
||||
if (definitions.Count == 0)
|
||||
{
|
||||
return ParameterValidationResult.Valid(new Dictionary<string, object?>());
|
||||
}
|
||||
|
||||
if (body == null || body.Value.ValueKind == JsonValueKind.Null || body.Value.ValueKind == JsonValueKind.Undefined)
|
||||
{
|
||||
// Check if all parameters are optional
|
||||
var required = definitions.Where(d => d.Required).ToList();
|
||||
if (required.Count > 0)
|
||||
{
|
||||
return ParameterValidationResult.Invalid(
|
||||
$"Missing required parameters: {string.Join(", ", required.Select(r => r.Name))}");
|
||||
}
|
||||
|
||||
return ParameterValidationResult.Valid(new Dictionary<string, object?>());
|
||||
}
|
||||
|
||||
if (body.Value.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return ParameterValidationResult.Invalid("Request body must be a JSON object");
|
||||
}
|
||||
|
||||
var result = new Dictionary<string, object?>();
|
||||
var errors = new List<string>();
|
||||
|
||||
foreach (var def in definitions)
|
||||
{
|
||||
if (body.Value.TryGetProperty(def.Name, out var prop))
|
||||
{
|
||||
var (value, error) = CoerceValue(prop, def.Type, def.Name);
|
||||
if (error != null)
|
||||
{
|
||||
errors.Add(error);
|
||||
}
|
||||
else
|
||||
{
|
||||
result[def.Name] = value;
|
||||
}
|
||||
}
|
||||
else if (def.Required)
|
||||
{
|
||||
errors.Add($"Missing required parameter: {def.Name}");
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
return ParameterValidationResult.Invalid(string.Join("; ", errors));
|
||||
}
|
||||
|
||||
return ParameterValidationResult.Valid(result);
|
||||
}
|
||||
|
||||
private static (object? value, string? error) CoerceValue(JsonElement element, string expectedType, string paramName)
|
||||
{
|
||||
return expectedType.ToLowerInvariant() switch
|
||||
{
|
||||
"boolean" => element.ValueKind == JsonValueKind.True || element.ValueKind == JsonValueKind.False
|
||||
? (element.GetBoolean(), null)
|
||||
: (null, $"Parameter '{paramName}' must be a Boolean"),
|
||||
|
||||
"integer" => element.ValueKind == JsonValueKind.Number && element.TryGetInt64(out var intVal)
|
||||
? (intVal, null)
|
||||
: (null, $"Parameter '{paramName}' must be an Integer"),
|
||||
|
||||
"float" => element.ValueKind == JsonValueKind.Number
|
||||
? (element.GetDouble(), null)
|
||||
: (null, $"Parameter '{paramName}' must be a Float"),
|
||||
|
||||
"string" => element.ValueKind == JsonValueKind.String
|
||||
? (element.GetString(), null)
|
||||
: (null, $"Parameter '{paramName}' must be a String"),
|
||||
|
||||
"object" => element.ValueKind == JsonValueKind.Object
|
||||
? (JsonSerializer.Deserialize<Dictionary<string, object?>>(element.GetRawText()), null)
|
||||
: (null, $"Parameter '{paramName}' must be an Object"),
|
||||
|
||||
"list" => element.ValueKind == JsonValueKind.Array
|
||||
? (JsonSerializer.Deserialize<List<object?>>(element.GetRawText()), null)
|
||||
: (null, $"Parameter '{paramName}' must be a List"),
|
||||
|
||||
_ => (null, $"Unknown parameter type '{expectedType}' for parameter '{paramName}'")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Defines a parameter in a method's parameter definitions.
|
||||
/// </summary>
|
||||
public class ParameterDefinition
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Type { get; set; } = "String";
|
||||
public bool Required { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of parameter validation.
|
||||
/// </summary>
|
||||
public class ParameterValidationResult
|
||||
{
|
||||
public bool IsValid { get; private init; }
|
||||
public string? ErrorMessage { get; private init; }
|
||||
public IReadOnlyDictionary<string, object?> Parameters { get; private init; } = new Dictionary<string, object?>();
|
||||
|
||||
public static ParameterValidationResult Valid(Dictionary<string, object?> parameters) =>
|
||||
new() { IsValid = true, Parameters = parameters };
|
||||
|
||||
public static ParameterValidationResult Invalid(string message) =>
|
||||
new() { IsValid = false, ErrorMessage = message };
|
||||
}
|
||||
162
src/ScadaLink.InboundAPI/RouteHelper.cs
Normal file
162
src/ScadaLink.InboundAPI/RouteHelper.cs
Normal file
@@ -0,0 +1,162 @@
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
using ScadaLink.Commons.Messages.InboundApi;
|
||||
using ScadaLink.Communication;
|
||||
|
||||
namespace ScadaLink.InboundAPI;
|
||||
|
||||
/// <summary>
|
||||
/// WP-4: Route.To() helper for cross-site calls from inbound API scripts.
|
||||
/// Resolves instance to site, routes via CommunicationService, blocks until response or timeout.
|
||||
/// Site unreachable returns error (no store-and-forward).
|
||||
/// </summary>
|
||||
public class RouteHelper
|
||||
{
|
||||
private readonly IInstanceLocator _instanceLocator;
|
||||
private readonly CommunicationService _communicationService;
|
||||
|
||||
public RouteHelper(
|
||||
IInstanceLocator instanceLocator,
|
||||
CommunicationService communicationService)
|
||||
{
|
||||
_instanceLocator = instanceLocator;
|
||||
_communicationService = communicationService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a route target for the specified instance.
|
||||
/// </summary>
|
||||
public RouteTarget To(string instanceCode)
|
||||
{
|
||||
return new RouteTarget(instanceCode, _instanceLocator, _communicationService);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WP-4: Represents a route target (an instance) for cross-site calls.
|
||||
/// </summary>
|
||||
public class RouteTarget
|
||||
{
|
||||
private readonly string _instanceCode;
|
||||
private readonly IInstanceLocator _instanceLocator;
|
||||
private readonly CommunicationService _communicationService;
|
||||
|
||||
internal RouteTarget(
|
||||
string instanceCode,
|
||||
IInstanceLocator instanceLocator,
|
||||
CommunicationService communicationService)
|
||||
{
|
||||
_instanceCode = instanceCode;
|
||||
_instanceLocator = instanceLocator;
|
||||
_communicationService = communicationService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calls a script on the remote instance. Synchronous from API caller's perspective.
|
||||
/// </summary>
|
||||
public async Task<object?> Call(
|
||||
string scriptName,
|
||||
IReadOnlyDictionary<string, object?>? parameters = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var siteId = await ResolveSiteAsync(cancellationToken);
|
||||
var correlationId = Guid.NewGuid().ToString();
|
||||
|
||||
var request = new RouteToCallRequest(
|
||||
correlationId, _instanceCode, scriptName, parameters, DateTimeOffset.UtcNow);
|
||||
|
||||
var response = await _communicationService.RouteToCallAsync(
|
||||
siteId, request, cancellationToken);
|
||||
|
||||
if (!response.Success)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
response.ErrorMessage ?? "Remote script call failed");
|
||||
}
|
||||
|
||||
return response.ReturnValue;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a single attribute value from the remote instance.
|
||||
/// </summary>
|
||||
public async Task<object?> GetAttribute(
|
||||
string attributeName,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = await GetAttributes(new[] { attributeName }, cancellationToken);
|
||||
return result.TryGetValue(attributeName, out var value) ? value : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets multiple attribute values from the remote instance (batch read).
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyDictionary<string, object?>> GetAttributes(
|
||||
IEnumerable<string> attributeNames,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var siteId = await ResolveSiteAsync(cancellationToken);
|
||||
var correlationId = Guid.NewGuid().ToString();
|
||||
|
||||
var request = new RouteToGetAttributesRequest(
|
||||
correlationId, _instanceCode, attributeNames.ToList(), DateTimeOffset.UtcNow);
|
||||
|
||||
var response = await _communicationService.RouteToGetAttributesAsync(
|
||||
siteId, request, cancellationToken);
|
||||
|
||||
if (!response.Success)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
response.ErrorMessage ?? "Remote attribute read failed");
|
||||
}
|
||||
|
||||
return response.Values;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets a single attribute value on the remote instance.
|
||||
/// </summary>
|
||||
public async Task SetAttribute(
|
||||
string attributeName,
|
||||
string value,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await SetAttributes(
|
||||
new Dictionary<string, string> { { attributeName, value } },
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets multiple attribute values on the remote instance (batch write).
|
||||
/// </summary>
|
||||
public async Task SetAttributes(
|
||||
IReadOnlyDictionary<string, string> attributeValues,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var siteId = await ResolveSiteAsync(cancellationToken);
|
||||
var correlationId = Guid.NewGuid().ToString();
|
||||
|
||||
var request = new RouteToSetAttributesRequest(
|
||||
correlationId, _instanceCode, attributeValues, DateTimeOffset.UtcNow);
|
||||
|
||||
var response = await _communicationService.RouteToSetAttributesAsync(
|
||||
siteId, request, cancellationToken);
|
||||
|
||||
if (!response.Success)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
response.ErrorMessage ?? "Remote attribute write failed");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> ResolveSiteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var siteId = await _instanceLocator.GetSiteIdForInstanceAsync(_instanceCode, cancellationToken);
|
||||
if (siteId == null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Instance '{_instanceCode}' not found or has no assigned site");
|
||||
}
|
||||
|
||||
return siteId;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,11 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../ScadaLink.Commons/ScadaLink.Commons.csproj" />
|
||||
<ProjectReference Include="../ScadaLink.Communication/ScadaLink.Communication.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ScadaLink.InboundAPI.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -6,7 +6,10 @@ public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddInboundAPI(this IServiceCollection services)
|
||||
{
|
||||
// Phase 0: skeleton only
|
||||
services.AddScoped<ApiKeyValidator>();
|
||||
services.AddSingleton<InboundScriptExecutor>();
|
||||
services.AddScoped<RouteHelper>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
12
src/ScadaLink.NotificationService/ISmtpClientWrapper.cs
Normal file
12
src/ScadaLink.NotificationService/ISmtpClientWrapper.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace ScadaLink.NotificationService;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction over SMTP client for testability.
|
||||
/// </summary>
|
||||
public interface ISmtpClientWrapper
|
||||
{
|
||||
Task ConnectAsync(string host, int port, bool useTls, CancellationToken cancellationToken = default);
|
||||
Task AuthenticateAsync(string authType, string? credentials, CancellationToken cancellationToken = default);
|
||||
Task SendAsync(string from, IEnumerable<string> bccRecipients, string subject, string body, CancellationToken cancellationToken = default);
|
||||
Task DisconnectAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using MailKit.Net.Smtp;
|
||||
using MailKit.Security;
|
||||
using MimeKit;
|
||||
|
||||
namespace ScadaLink.NotificationService;
|
||||
|
||||
/// <summary>
|
||||
/// WP-11: MailKit-based SMTP client wrapper.
|
||||
/// Supports OAuth2 Client Credentials (M365) and Basic Auth.
|
||||
/// BCC delivery, plain text.
|
||||
/// </summary>
|
||||
public class MailKitSmtpClientWrapper : ISmtpClientWrapper, IDisposable
|
||||
{
|
||||
private readonly SmtpClient _client = new();
|
||||
|
||||
public async Task ConnectAsync(string host, int port, bool useTls, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var secureSocket = useTls ? SecureSocketOptions.StartTls : SecureSocketOptions.Auto;
|
||||
await _client.ConnectAsync(host, port, secureSocket, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task AuthenticateAsync(string authType, string? credentials, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrEmpty(credentials))
|
||||
return;
|
||||
|
||||
switch (authType.ToLowerInvariant())
|
||||
{
|
||||
case "basic":
|
||||
var parts = credentials.Split(':', 2);
|
||||
if (parts.Length == 2)
|
||||
{
|
||||
await _client.AuthenticateAsync(parts[0], parts[1], cancellationToken);
|
||||
}
|
||||
break;
|
||||
|
||||
case "oauth2":
|
||||
// OAuth2 token is passed directly as credentials (pre-fetched by token service)
|
||||
var oauth2 = new SaslMechanismOAuth2("", credentials);
|
||||
await _client.AuthenticateAsync(oauth2, cancellationToken);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SendAsync(string from, IEnumerable<string> bccRecipients, string subject, string body, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var message = new MimeMessage();
|
||||
message.From.Add(MailboxAddress.Parse(from));
|
||||
|
||||
foreach (var recipient in bccRecipients)
|
||||
{
|
||||
message.Bcc.Add(MailboxAddress.Parse(recipient));
|
||||
}
|
||||
|
||||
message.Subject = subject;
|
||||
message.Body = new TextPart("plain") { Text = body };
|
||||
|
||||
await _client.SendAsync(message, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task DisconnectAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_client.IsConnected)
|
||||
{
|
||||
await _client.DisconnectAsync(true, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_client.Dispose();
|
||||
}
|
||||
}
|
||||
177
src/ScadaLink.NotificationService/NotificationDeliveryService.cs
Normal file
177
src/ScadaLink.NotificationService/NotificationDeliveryService.cs
Normal file
@@ -0,0 +1,177 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ScadaLink.Commons.Entities.Notifications;
|
||||
using ScadaLink.Commons.Interfaces.Repositories;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
using ScadaLink.Commons.Types.Enums;
|
||||
using ScadaLink.StoreAndForward;
|
||||
|
||||
namespace ScadaLink.NotificationService;
|
||||
|
||||
/// <summary>
|
||||
/// WP-11: Notification delivery via SMTP.
|
||||
/// WP-12: Error classification and S&F integration.
|
||||
/// Transient: connection refused, timeout, SMTP 4xx → hand to S&F.
|
||||
/// Permanent: SMTP 5xx → returned to script.
|
||||
/// </summary>
|
||||
public class NotificationDeliveryService : INotificationDeliveryService
|
||||
{
|
||||
private readonly INotificationRepository _repository;
|
||||
private readonly Func<ISmtpClientWrapper> _smtpClientFactory;
|
||||
private readonly OAuth2TokenService? _tokenService;
|
||||
private readonly StoreAndForwardService? _storeAndForward;
|
||||
private readonly ILogger<NotificationDeliveryService> _logger;
|
||||
|
||||
public NotificationDeliveryService(
|
||||
INotificationRepository repository,
|
||||
Func<ISmtpClientWrapper> smtpClientFactory,
|
||||
ILogger<NotificationDeliveryService> logger,
|
||||
OAuth2TokenService? tokenService = null,
|
||||
StoreAndForwardService? storeAndForward = null)
|
||||
{
|
||||
_repository = repository;
|
||||
_smtpClientFactory = smtpClientFactory;
|
||||
_logger = logger;
|
||||
_tokenService = tokenService;
|
||||
_storeAndForward = storeAndForward;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a notification to a named list. BCC delivery, plain text.
|
||||
/// </summary>
|
||||
public async Task<NotificationResult> SendAsync(
|
||||
string listName,
|
||||
string subject,
|
||||
string message,
|
||||
string? originInstanceName = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var list = await _repository.GetListByNameAsync(listName, cancellationToken);
|
||||
if (list == null)
|
||||
{
|
||||
return new NotificationResult(false, $"Notification list '{listName}' not found");
|
||||
}
|
||||
|
||||
var recipients = await _repository.GetRecipientsByListIdAsync(list.Id, cancellationToken);
|
||||
if (recipients.Count == 0)
|
||||
{
|
||||
return new NotificationResult(false, $"Notification list '{listName}' has no recipients");
|
||||
}
|
||||
|
||||
var smtpConfigs = await _repository.GetAllSmtpConfigurationsAsync(cancellationToken);
|
||||
var smtpConfig = smtpConfigs.FirstOrDefault();
|
||||
if (smtpConfig == null)
|
||||
{
|
||||
return new NotificationResult(false, "No SMTP configuration available");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await DeliverAsync(smtpConfig, recipients, subject, message, cancellationToken);
|
||||
return new NotificationResult(true, null);
|
||||
}
|
||||
catch (SmtpPermanentException ex)
|
||||
{
|
||||
// WP-12: Permanent SMTP failure — returned to script
|
||||
_logger.LogError(ex, "Permanent SMTP failure sending to list {List}", listName);
|
||||
return new NotificationResult(false, $"Permanent SMTP error: {ex.Message}");
|
||||
}
|
||||
catch (Exception ex) when (IsTransientSmtpError(ex))
|
||||
{
|
||||
// WP-12: Transient SMTP failure — hand to S&F
|
||||
_logger.LogWarning(ex, "Transient SMTP failure sending to list {List}, buffering for retry", listName);
|
||||
|
||||
if (_storeAndForward == null)
|
||||
{
|
||||
return new NotificationResult(false, "Transient SMTP error and store-and-forward not available");
|
||||
}
|
||||
|
||||
var payload = JsonSerializer.Serialize(new
|
||||
{
|
||||
ListName = listName,
|
||||
Subject = subject,
|
||||
Message = message
|
||||
});
|
||||
|
||||
await _storeAndForward.EnqueueAsync(
|
||||
StoreAndForwardCategory.Notification,
|
||||
listName,
|
||||
payload,
|
||||
originInstanceName,
|
||||
smtpConfig.MaxRetries > 0 ? smtpConfig.MaxRetries : null,
|
||||
smtpConfig.RetryDelay > TimeSpan.Zero ? smtpConfig.RetryDelay : null);
|
||||
|
||||
return new NotificationResult(true, null, WasBuffered: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delivers an email via SMTP. Throws on failure.
|
||||
/// </summary>
|
||||
internal async Task DeliverAsync(
|
||||
SmtpConfiguration config,
|
||||
IReadOnlyList<NotificationRecipient> recipients,
|
||||
string subject,
|
||||
string body,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var client = _smtpClientFactory() as IDisposable;
|
||||
var smtp = _smtpClientFactory();
|
||||
|
||||
try
|
||||
{
|
||||
var useTls = config.TlsMode?.Equals("starttls", StringComparison.OrdinalIgnoreCase) == true;
|
||||
await smtp.ConnectAsync(config.Host, config.Port, useTls, cancellationToken);
|
||||
|
||||
// Resolve credentials (OAuth2 token refresh if needed)
|
||||
var credentials = config.Credentials;
|
||||
if (config.AuthType.Equals("oauth2", StringComparison.OrdinalIgnoreCase) && _tokenService != null && credentials != null)
|
||||
{
|
||||
var token = await _tokenService.GetTokenAsync(credentials, cancellationToken);
|
||||
credentials = token;
|
||||
}
|
||||
|
||||
await smtp.AuthenticateAsync(config.AuthType, credentials, cancellationToken);
|
||||
|
||||
var bccAddresses = recipients.Select(r => r.EmailAddress).ToList();
|
||||
await smtp.SendAsync(config.FromAddress, bccAddresses, subject, body, cancellationToken);
|
||||
|
||||
await smtp.DisconnectAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception ex) when (ex is not SmtpPermanentException && !IsTransientSmtpError(ex))
|
||||
{
|
||||
// Classify unrecognized SMTP exceptions
|
||||
if (ex.Message.Contains("5.", StringComparison.Ordinal) ||
|
||||
ex.Message.Contains("550", StringComparison.Ordinal) ||
|
||||
ex.Message.Contains("553", StringComparison.Ordinal) ||
|
||||
ex.Message.Contains("554", StringComparison.Ordinal))
|
||||
{
|
||||
throw new SmtpPermanentException(ex.Message, ex);
|
||||
}
|
||||
|
||||
// Default: treat as transient
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsTransientSmtpError(Exception ex)
|
||||
{
|
||||
return ex is TimeoutException
|
||||
or OperationCanceledException
|
||||
or System.Net.Sockets.SocketException
|
||||
or IOException
|
||||
|| ex.Message.Contains("4.", StringComparison.Ordinal)
|
||||
|| ex.Message.Contains("421", StringComparison.Ordinal)
|
||||
|| ex.Message.Contains("450", StringComparison.Ordinal)
|
||||
|| ex.Message.Contains("451", StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signals a permanent SMTP failure (5xx) that should not be retried.
|
||||
/// </summary>
|
||||
public class SmtpPermanentException : Exception
|
||||
{
|
||||
public SmtpPermanentException(string message, Exception? innerException = null)
|
||||
: base(message, innerException) { }
|
||||
}
|
||||
@@ -1,6 +1,15 @@
|
||||
namespace ScadaLink.NotificationService;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the Notification Service.
|
||||
/// Most SMTP configuration is stored in the database (SmtpConfiguration entity).
|
||||
/// This provides fallback defaults and operational limits.
|
||||
/// </summary>
|
||||
public class NotificationOptions
|
||||
{
|
||||
// Phase 0: minimal POCO — most SMTP configuration is stored in the database
|
||||
/// <summary>Default connection timeout for SMTP connections.</summary>
|
||||
public int ConnectionTimeoutSeconds { get; set; } = 30;
|
||||
|
||||
/// <summary>Maximum concurrent SMTP connections.</summary>
|
||||
public int MaxConcurrentConnections { get; set; } = 5;
|
||||
}
|
||||
|
||||
87
src/ScadaLink.NotificationService/OAuth2TokenService.cs
Normal file
87
src/ScadaLink.NotificationService/OAuth2TokenService.cs
Normal file
@@ -0,0 +1,87 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ScadaLink.NotificationService;
|
||||
|
||||
/// <summary>
|
||||
/// WP-11: OAuth2 Client Credentials token lifecycle — fetch, cache, refresh on expiry.
|
||||
/// Used for Microsoft 365 SMTP authentication.
|
||||
/// </summary>
|
||||
public class OAuth2TokenService
|
||||
{
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ILogger<OAuth2TokenService> _logger;
|
||||
private string? _cachedToken;
|
||||
private DateTimeOffset _tokenExpiry = DateTimeOffset.MinValue;
|
||||
private readonly SemaphoreSlim _lock = new(1, 1);
|
||||
|
||||
public OAuth2TokenService(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ILogger<OAuth2TokenService> logger)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a valid access token, refreshing if expired.
|
||||
/// Credentials format: "tenantId:clientId:clientSecret"
|
||||
/// </summary>
|
||||
public async Task<string> GetTokenAsync(string credentials, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_cachedToken != null && DateTimeOffset.UtcNow < _tokenExpiry)
|
||||
{
|
||||
return _cachedToken;
|
||||
}
|
||||
|
||||
await _lock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
// Double-check after acquiring lock
|
||||
if (_cachedToken != null && DateTimeOffset.UtcNow < _tokenExpiry)
|
||||
{
|
||||
return _cachedToken;
|
||||
}
|
||||
|
||||
var parts = credentials.Split(':', 3);
|
||||
if (parts.Length < 3)
|
||||
{
|
||||
throw new InvalidOperationException("OAuth2 credentials must be 'tenantId:clientId:clientSecret'");
|
||||
}
|
||||
|
||||
var tenantId = parts[0];
|
||||
var clientId = parts[1];
|
||||
var clientSecret = parts[2];
|
||||
|
||||
var client = _httpClientFactory.CreateClient("OAuth2");
|
||||
var tokenUrl = $"https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token";
|
||||
|
||||
var form = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["grant_type"] = "client_credentials",
|
||||
["client_id"] = clientId,
|
||||
["client_secret"] = clientSecret,
|
||||
["scope"] = "https://outlook.office365.com/.default"
|
||||
});
|
||||
|
||||
var response = await client.PostAsync(tokenUrl, form, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
|
||||
_cachedToken = doc.RootElement.GetProperty("access_token").GetString()
|
||||
?? throw new InvalidOperationException("No access_token in OAuth2 response");
|
||||
|
||||
var expiresIn = doc.RootElement.GetProperty("expires_in").GetInt32();
|
||||
_tokenExpiry = DateTimeOffset.UtcNow.AddSeconds(expiresIn - 60); // Refresh 60s before expiry
|
||||
|
||||
_logger.LogInformation("OAuth2 token refreshed, expires in {ExpiresIn}s", expiresIn);
|
||||
return _cachedToken;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,12 +8,20 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MailKit" Version="4.15.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.5" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../ScadaLink.Commons/ScadaLink.Commons.csproj" />
|
||||
<ProjectReference Include="../ScadaLink.StoreAndForward/ScadaLink.StoreAndForward.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ScadaLink.NotificationService.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
|
||||
namespace ScadaLink.NotificationService;
|
||||
|
||||
@@ -6,13 +7,21 @@ public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddNotificationService(this IServiceCollection services)
|
||||
{
|
||||
// Phase 0: skeleton only
|
||||
services.AddOptions<NotificationOptions>()
|
||||
.BindConfiguration("ScadaLink:Notification");
|
||||
|
||||
services.AddHttpClient();
|
||||
services.AddSingleton<OAuth2TokenService>();
|
||||
services.AddSingleton<Func<ISmtpClientWrapper>>(_ => () => new MailKitSmtpClientWrapper());
|
||||
services.AddSingleton<NotificationDeliveryService>();
|
||||
services.AddSingleton<INotificationDeliveryService>(sp => sp.GetRequiredService<NotificationDeliveryService>());
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddNotificationServiceActors(this IServiceCollection services)
|
||||
{
|
||||
// Phase 0: placeholder for Akka actor registration
|
||||
// Actor registration happens in AkkaHostedService.
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Akka.Actor;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
using ScadaLink.Commons.Messages.Instance;
|
||||
using ScadaLink.Commons.Messages.ScriptExecution;
|
||||
|
||||
@@ -13,6 +14,13 @@ namespace ScadaLink.SiteRuntime.Scripts;
|
||||
/// Instance.CallScript("scriptName", params)
|
||||
/// Scripts.CallShared("scriptName", params)
|
||||
///
|
||||
/// WP-13 (Phase 7): Integration surface APIs:
|
||||
/// ExternalSystem.Call("systemName", "methodName", params)
|
||||
/// ExternalSystem.CachedCall("systemName", "methodName", params)
|
||||
/// Database.Connection("name")
|
||||
/// Database.CachedWrite("name", "sql", params)
|
||||
/// Notify.To("listName").Send("subject", "message")
|
||||
///
|
||||
/// WP-20: Recursion Limit — call depth tracked and enforced.
|
||||
/// </summary>
|
||||
public class ScriptRuntimeContext
|
||||
@@ -26,6 +34,21 @@ public class ScriptRuntimeContext
|
||||
private readonly ILogger _logger;
|
||||
private readonly string _instanceName;
|
||||
|
||||
/// <summary>
|
||||
/// WP-13: External system client for ExternalSystem.Call/CachedCall.
|
||||
/// </summary>
|
||||
private readonly IExternalSystemClient? _externalSystemClient;
|
||||
|
||||
/// <summary>
|
||||
/// WP-13: Database gateway for Database.Connection/CachedWrite.
|
||||
/// </summary>
|
||||
private readonly IDatabaseGateway? _databaseGateway;
|
||||
|
||||
/// <summary>
|
||||
/// WP-13: Notification delivery for Notify.To().Send().
|
||||
/// </summary>
|
||||
private readonly INotificationDeliveryService? _notificationService;
|
||||
|
||||
public ScriptRuntimeContext(
|
||||
IActorRef instanceActor,
|
||||
IActorRef self,
|
||||
@@ -34,7 +57,10 @@ public class ScriptRuntimeContext
|
||||
int maxCallDepth,
|
||||
TimeSpan askTimeout,
|
||||
string instanceName,
|
||||
ILogger logger)
|
||||
ILogger logger,
|
||||
IExternalSystemClient? externalSystemClient = null,
|
||||
IDatabaseGateway? databaseGateway = null,
|
||||
INotificationDeliveryService? notificationService = null)
|
||||
{
|
||||
_instanceActor = instanceActor;
|
||||
_self = self;
|
||||
@@ -44,6 +70,9 @@ public class ScriptRuntimeContext
|
||||
_askTimeout = askTimeout;
|
||||
_instanceName = instanceName;
|
||||
_logger = logger;
|
||||
_externalSystemClient = externalSystemClient;
|
||||
_databaseGateway = databaseGateway;
|
||||
_notificationService = notificationService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -123,6 +152,26 @@ public class ScriptRuntimeContext
|
||||
/// </summary>
|
||||
public ScriptCallHelper Scripts => new(_sharedScriptLibrary, this, _currentCallDepth, _maxCallDepth, _logger);
|
||||
|
||||
/// <summary>
|
||||
/// WP-13: Provides access to external system calls.
|
||||
/// ExternalSystem.Call("systemName", "methodName", params)
|
||||
/// ExternalSystem.CachedCall("systemName", "methodName", params)
|
||||
/// </summary>
|
||||
public ExternalSystemHelper ExternalSystem => new(_externalSystemClient, _instanceName, _logger);
|
||||
|
||||
/// <summary>
|
||||
/// WP-13: Provides access to database operations.
|
||||
/// Database.Connection("name")
|
||||
/// Database.CachedWrite("name", "sql", params)
|
||||
/// </summary>
|
||||
public DatabaseHelper Database => new(_databaseGateway, _instanceName, _logger);
|
||||
|
||||
/// <summary>
|
||||
/// WP-13: Provides access to notification delivery.
|
||||
/// Notify.To("listName").Send("subject", "message")
|
||||
/// </summary>
|
||||
public NotifyHelper Notify => new(_notificationService, _instanceName, _logger);
|
||||
|
||||
/// <summary>
|
||||
/// Helper class for Scripts.CallShared() syntax.
|
||||
/// </summary>
|
||||
@@ -169,4 +218,136 @@ public class ScriptRuntimeContext
|
||||
return await _library.ExecuteAsync(scriptName, _context, parameters, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WP-13: Helper for ExternalSystem.Call/CachedCall syntax.
|
||||
/// </summary>
|
||||
public class ExternalSystemHelper
|
||||
{
|
||||
private readonly IExternalSystemClient? _client;
|
||||
private readonly string _instanceName;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
internal ExternalSystemHelper(IExternalSystemClient? client, string instanceName, ILogger logger)
|
||||
{
|
||||
_client = client;
|
||||
_instanceName = instanceName;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<ExternalCallResult> Call(
|
||||
string systemName,
|
||||
string methodName,
|
||||
IReadOnlyDictionary<string, object?>? parameters = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_client == null)
|
||||
throw new InvalidOperationException("External system client not available");
|
||||
|
||||
return await _client.CallAsync(systemName, methodName, parameters, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<ExternalCallResult> CachedCall(
|
||||
string systemName,
|
||||
string methodName,
|
||||
IReadOnlyDictionary<string, object?>? parameters = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_client == null)
|
||||
throw new InvalidOperationException("External system client not available");
|
||||
|
||||
return await _client.CachedCallAsync(systemName, methodName, parameters, _instanceName, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WP-13: Helper for Database.Connection/CachedWrite syntax.
|
||||
/// </summary>
|
||||
public class DatabaseHelper
|
||||
{
|
||||
private readonly IDatabaseGateway? _gateway;
|
||||
private readonly string _instanceName;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
internal DatabaseHelper(IDatabaseGateway? gateway, string instanceName, ILogger logger)
|
||||
{
|
||||
_gateway = gateway;
|
||||
_instanceName = instanceName;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<System.Data.Common.DbConnection> Connection(
|
||||
string name,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_gateway == null)
|
||||
throw new InvalidOperationException("Database gateway not available");
|
||||
|
||||
return await _gateway.GetConnectionAsync(name, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task CachedWrite(
|
||||
string name,
|
||||
string sql,
|
||||
IReadOnlyDictionary<string, object?>? parameters = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_gateway == null)
|
||||
throw new InvalidOperationException("Database gateway not available");
|
||||
|
||||
await _gateway.CachedWriteAsync(name, sql, parameters, _instanceName, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WP-13: Helper for Notify.To("listName").Send("subject", "message") syntax.
|
||||
/// </summary>
|
||||
public class NotifyHelper
|
||||
{
|
||||
private readonly INotificationDeliveryService? _service;
|
||||
private readonly string _instanceName;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
internal NotifyHelper(INotificationDeliveryService? service, string instanceName, ILogger logger)
|
||||
{
|
||||
_service = service;
|
||||
_instanceName = instanceName;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public NotifyTarget To(string listName)
|
||||
{
|
||||
return new NotifyTarget(listName, _service, _instanceName, _logger);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WP-13: Target for Notify.To("listName").Send("subject", "message").
|
||||
/// </summary>
|
||||
public class NotifyTarget
|
||||
{
|
||||
private readonly string _listName;
|
||||
private readonly INotificationDeliveryService? _service;
|
||||
private readonly string _instanceName;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
internal NotifyTarget(string listName, INotificationDeliveryService? service, string instanceName, ILogger logger)
|
||||
{
|
||||
_listName = listName;
|
||||
_service = service;
|
||||
_instanceName = instanceName;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<NotificationResult> Send(
|
||||
string subject,
|
||||
string message,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_service == null)
|
||||
throw new InvalidOperationException("Notification service not available");
|
||||
|
||||
return await _service.SendAsync(_listName, subject, message, _instanceName, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user