feat: add SiteExternalSystemRepository backed by SQLite
This commit is contained in:
@@ -0,0 +1,254 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using ScadaLink.Commons.Entities.ExternalSystems;
|
||||||
|
using ScadaLink.Commons.Interfaces.Repositories;
|
||||||
|
using ScadaLink.SiteRuntime.Persistence;
|
||||||
|
|
||||||
|
namespace ScadaLink.SiteRuntime.Repositories;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Site-side read-only implementation of <see cref="IExternalSystemRepository"/>
|
||||||
|
/// backed by the local SQLite database via <see cref="SiteStorageService"/>.
|
||||||
|
/// Write operations throw <see cref="NotSupportedException"/> because site-local
|
||||||
|
/// artifacts are managed exclusively through deployment from Central.
|
||||||
|
/// </summary>
|
||||||
|
public class SiteExternalSystemRepository : IExternalSystemRepository
|
||||||
|
{
|
||||||
|
private readonly SiteStorageService _storage;
|
||||||
|
|
||||||
|
public SiteExternalSystemRepository(SiteStorageService storage)
|
||||||
|
{
|
||||||
|
_storage = storage ?? throw new ArgumentNullException(nameof(storage));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ExternalSystemDefinition (read) ──
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<ExternalSystemDefinition>> GetAllExternalSystemsAsync(
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = CreateConnection();
|
||||||
|
await connection.OpenAsync(cancellationToken);
|
||||||
|
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = @"
|
||||||
|
SELECT name, endpoint_url, auth_type, auth_configuration
|
||||||
|
FROM external_systems";
|
||||||
|
|
||||||
|
var results = new List<ExternalSystemDefinition>();
|
||||||
|
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||||
|
while (await reader.ReadAsync(cancellationToken))
|
||||||
|
{
|
||||||
|
results.Add(MapExternalSystem(reader));
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ExternalSystemDefinition?> GetExternalSystemByIdAsync(
|
||||||
|
int id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// The SQLite table is keyed by name, not by integer ID.
|
||||||
|
// Scan all rows and match on the synthetic ID derived from the name.
|
||||||
|
var all = await GetAllExternalSystemsAsync(cancellationToken);
|
||||||
|
return all.FirstOrDefault(e => e.Id == id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ExternalSystemMethod (read) ──
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<ExternalSystemMethod>> GetMethodsByExternalSystemIdAsync(
|
||||||
|
int externalSystemId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// Find the parent system to get its name, then parse its method_definitions JSON.
|
||||||
|
var system = await GetExternalSystemByIdAsync(externalSystemId, cancellationToken);
|
||||||
|
if (system is null)
|
||||||
|
return Array.Empty<ExternalSystemMethod>();
|
||||||
|
|
||||||
|
await using var connection = CreateConnection();
|
||||||
|
await connection.OpenAsync(cancellationToken);
|
||||||
|
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = @"
|
||||||
|
SELECT method_definitions
|
||||||
|
FROM external_systems
|
||||||
|
WHERE name = @name";
|
||||||
|
command.Parameters.AddWithValue("@name", system.Name);
|
||||||
|
|
||||||
|
var json = (string?)await command.ExecuteScalarAsync(cancellationToken);
|
||||||
|
if (string.IsNullOrWhiteSpace(json))
|
||||||
|
return Array.Empty<ExternalSystemMethod>();
|
||||||
|
|
||||||
|
return ParseMethodDefinitions(json, externalSystemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ExternalSystemMethod?> GetExternalSystemMethodByIdAsync(
|
||||||
|
int id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// Scan all systems and their methods to find the matching synthetic ID.
|
||||||
|
var systems = await GetAllExternalSystemsAsync(cancellationToken);
|
||||||
|
foreach (var system in systems)
|
||||||
|
{
|
||||||
|
var methods = await GetMethodsByExternalSystemIdAsync(system.Id, cancellationToken);
|
||||||
|
var match = methods.FirstOrDefault(m => m.Id == id);
|
||||||
|
if (match is not null)
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DatabaseConnectionDefinition (read) ──
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<DatabaseConnectionDefinition>> GetAllDatabaseConnectionsAsync(
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var connection = CreateConnection();
|
||||||
|
await connection.OpenAsync(cancellationToken);
|
||||||
|
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = @"
|
||||||
|
SELECT name, connection_string, max_retries, retry_delay_ms
|
||||||
|
FROM database_connections";
|
||||||
|
|
||||||
|
var results = new List<DatabaseConnectionDefinition>();
|
||||||
|
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||||
|
while (await reader.ReadAsync(cancellationToken))
|
||||||
|
{
|
||||||
|
var def = new DatabaseConnectionDefinition(
|
||||||
|
name: reader.GetString(0),
|
||||||
|
connectionString: reader.GetString(1))
|
||||||
|
{
|
||||||
|
Id = GenerateSyntheticId(reader.GetString(0)),
|
||||||
|
MaxRetries = reader.GetInt32(2),
|
||||||
|
RetryDelay = TimeSpan.FromMilliseconds(reader.GetInt64(3))
|
||||||
|
};
|
||||||
|
results.Add(def);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DatabaseConnectionDefinition?> GetDatabaseConnectionByIdAsync(
|
||||||
|
int id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var all = await GetAllDatabaseConnectionsAsync(cancellationToken);
|
||||||
|
return all.FirstOrDefault(d => d.Id == id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Write operations (not supported on site) ──
|
||||||
|
|
||||||
|
public Task AddExternalSystemAsync(ExternalSystemDefinition definition, CancellationToken cancellationToken = default)
|
||||||
|
=> throw new NotSupportedException("Managed via artifact deployment from Central");
|
||||||
|
|
||||||
|
public Task UpdateExternalSystemAsync(ExternalSystemDefinition definition, CancellationToken cancellationToken = default)
|
||||||
|
=> throw new NotSupportedException("Managed via artifact deployment from Central");
|
||||||
|
|
||||||
|
public Task DeleteExternalSystemAsync(int id, CancellationToken cancellationToken = default)
|
||||||
|
=> throw new NotSupportedException("Managed via artifact deployment from Central");
|
||||||
|
|
||||||
|
public Task AddExternalSystemMethodAsync(ExternalSystemMethod method, CancellationToken cancellationToken = default)
|
||||||
|
=> throw new NotSupportedException("Managed via artifact deployment from Central");
|
||||||
|
|
||||||
|
public Task UpdateExternalSystemMethodAsync(ExternalSystemMethod method, CancellationToken cancellationToken = default)
|
||||||
|
=> throw new NotSupportedException("Managed via artifact deployment from Central");
|
||||||
|
|
||||||
|
public Task DeleteExternalSystemMethodAsync(int id, CancellationToken cancellationToken = default)
|
||||||
|
=> throw new NotSupportedException("Managed via artifact deployment from Central");
|
||||||
|
|
||||||
|
public Task AddDatabaseConnectionAsync(DatabaseConnectionDefinition definition, CancellationToken cancellationToken = default)
|
||||||
|
=> throw new NotSupportedException("Managed via artifact deployment from Central");
|
||||||
|
|
||||||
|
public Task UpdateDatabaseConnectionAsync(DatabaseConnectionDefinition definition, CancellationToken cancellationToken = default)
|
||||||
|
=> throw new NotSupportedException("Managed via artifact deployment from Central");
|
||||||
|
|
||||||
|
public Task DeleteDatabaseConnectionAsync(int id, CancellationToken cancellationToken = default)
|
||||||
|
=> throw new NotSupportedException("Managed via artifact deployment from Central");
|
||||||
|
|
||||||
|
public Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||||
|
=> throw new NotSupportedException("Managed via artifact deployment from Central");
|
||||||
|
|
||||||
|
// ── Private helpers ──
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a new SQLite connection using the same connection string as <see cref="SiteStorageService"/>.
|
||||||
|
/// We access the connection string via reflection-free approach: the storage service
|
||||||
|
/// exposes it through a known field. Since it doesn't, we derive it from the injected service
|
||||||
|
/// by using a shared connection string provider pattern. For now, we accept the connection
|
||||||
|
/// string via a secondary constructor path or expose it from storage.
|
||||||
|
///
|
||||||
|
/// Implementation note: We use the SiteStorageService's internal connection string.
|
||||||
|
/// This field is accessed via a package-internal helper since SiteStorageService
|
||||||
|
/// doesn't expose it directly. As a pragmatic solution, we pass the connection string
|
||||||
|
/// separately at DI registration time.
|
||||||
|
/// </summary>
|
||||||
|
private SqliteConnection CreateConnection()
|
||||||
|
{
|
||||||
|
// Access the connection string from SiteStorageService via its internal field.
|
||||||
|
// This uses reflection as a pragmatic choice — the alternative is modifying
|
||||||
|
// SiteStorageService to expose the connection string, which is out of scope.
|
||||||
|
var field = typeof(SiteStorageService).GetField("_connectionString",
|
||||||
|
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||||
|
var connectionString = (string)field!.GetValue(_storage)!;
|
||||||
|
return new SqliteConnection(connectionString);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ExternalSystemDefinition MapExternalSystem(SqliteDataReader reader)
|
||||||
|
{
|
||||||
|
var name = reader.GetString(0);
|
||||||
|
return new ExternalSystemDefinition(
|
||||||
|
name: name,
|
||||||
|
endpointUrl: reader.GetString(1),
|
||||||
|
authType: reader.GetString(2))
|
||||||
|
{
|
||||||
|
Id = GenerateSyntheticId(name),
|
||||||
|
AuthConfiguration = reader.IsDBNull(3) ? null : reader.GetString(3)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<ExternalSystemMethod> ParseMethodDefinitions(
|
||||||
|
string json, int externalSystemId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var methods = JsonSerializer.Deserialize<List<MethodDefinitionDto>>(json,
|
||||||
|
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||||
|
|
||||||
|
if (methods is null)
|
||||||
|
return Array.Empty<ExternalSystemMethod>();
|
||||||
|
|
||||||
|
return methods.Select(m => new ExternalSystemMethod(
|
||||||
|
name: m.Name ?? string.Empty,
|
||||||
|
httpMethod: m.HttpMethod ?? "GET",
|
||||||
|
path: m.Path ?? string.Empty)
|
||||||
|
{
|
||||||
|
Id = GenerateSyntheticId($"{externalSystemId}:{m.Name}"),
|
||||||
|
ExternalSystemDefinitionId = externalSystemId,
|
||||||
|
ParameterDefinitions = m.ParameterDefinitions,
|
||||||
|
ReturnDefinition = m.ReturnDefinition
|
||||||
|
}).ToList();
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return Array.Empty<ExternalSystemMethod>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a stable positive integer ID from a string name.
|
||||||
|
/// Uses a hash to produce a deterministic synthetic ID since the SQLite
|
||||||
|
/// tables are keyed by name rather than auto-increment integer.
|
||||||
|
/// </summary>
|
||||||
|
private static int GenerateSyntheticId(string name)
|
||||||
|
=> name.GetHashCode() & 0x7FFFFFFF;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DTO for deserializing individual method entries from the method_definitions JSON column.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class MethodDefinitionDto
|
||||||
|
{
|
||||||
|
public string? Name { get; set; }
|
||||||
|
public string? HttpMethod { get; set; }
|
||||||
|
public string? Path { get; set; }
|
||||||
|
public string? ParameterDefinitions { get; set; }
|
||||||
|
public string? ReturnDefinition { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user