feat(backend): restructure API into layers with OpenAPI, versioning and error handling
Split the backend into API host, Domain, Application and Infrastructure projects. Add Swagger/OpenAPI per API version, Asp.Versioning (query/header), ProblemDetails with a global exception handler and SurrealQL migrations applied at startup (namespace/database ensured). Infrastructure owns the SurrealDB client, settings and DI.
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SurrealDb.Net;
|
||||
using TarninaHub.Application.Abstractions;
|
||||
using TarninaHub.Infrastructure.Persistence;
|
||||
using TarninaHub.Infrastructure.Settings;
|
||||
|
||||
namespace TarninaHub.Infrastructure;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static IServiceCollection AddInfrastructure(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
services.Configure<SurrealDbSettings>(configuration.GetSection("SurrealDb"));
|
||||
|
||||
var settings = configuration.GetSection("SurrealDb").Get<SurrealDbSettings>()
|
||||
?? new SurrealDbSettings();
|
||||
|
||||
var options = new SurrealDbOptions
|
||||
{
|
||||
Endpoint = SurrealDbEndpoint.Build(settings),
|
||||
Namespace = settings.Namespace,
|
||||
Database = settings.Database,
|
||||
};
|
||||
|
||||
if (string.IsNullOrWhiteSpace(settings.RemoteUrl))
|
||||
{
|
||||
// Embedded RocksDB, persisted next to the running app (./data), never in memory.
|
||||
var dataDirectory = Path.Combine(AppContext.BaseDirectory, settings.DataDirectory);
|
||||
Directory.CreateDirectory(dataDirectory);
|
||||
|
||||
services
|
||||
.AddSurreal(options, ServiceLifetime.Singleton)
|
||||
.AddRocksDbProvider();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Remote SurrealDB server on its own dedicated port (used for sync with the frontend).
|
||||
options.Username = settings.Username;
|
||||
options.Password = settings.Password;
|
||||
|
||||
services.AddSurreal(options, ServiceLifetime.Singleton);
|
||||
}
|
||||
|
||||
services.AddSingleton<IStorageService, StorageService>();
|
||||
services.AddSingleton<IMigrationRunner, MigrationRunner>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SurrealDb.Net;
|
||||
using TarninaHub.Application.Abstractions;
|
||||
using TarninaHub.Infrastructure.Settings;
|
||||
|
||||
namespace TarninaHub.Infrastructure.Persistence;
|
||||
|
||||
public sealed class MigrationRunner : IMigrationRunner
|
||||
{
|
||||
private readonly ISurrealDbClient _client;
|
||||
private readonly SurrealDbSettings _settings;
|
||||
private readonly ILogger<MigrationRunner> _logger;
|
||||
|
||||
public MigrationRunner(
|
||||
ISurrealDbClient client,
|
||||
IOptions<SurrealDbSettings> settings,
|
||||
ILogger<MigrationRunner> logger)
|
||||
{
|
||||
_client = client;
|
||||
_settings = settings.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task ApplyAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await EnsureNamespaceAsync(cancellationToken);
|
||||
|
||||
var migrationsPath = Path.Combine(AppContext.BaseDirectory, _settings.MigrationsDirectory);
|
||||
|
||||
if (!Directory.Exists(migrationsPath))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Migrations directory '{MigrationsPath}' was not found; skipping migrations.",
|
||||
migrationsPath);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var files = Directory.GetFiles(migrationsPath, "*.surql")
|
||||
.OrderBy(file => file, StringComparer.Ordinal);
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
var sql = await File.ReadAllTextAsync(file, cancellationToken);
|
||||
|
||||
await _client.RawQuery(sql, cancellationToken: cancellationToken);
|
||||
|
||||
_logger.LogInformation("Applied migration {Migration}.", Path.GetFileName(file));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnsureNamespaceAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var sql =
|
||||
$"DEFINE NAMESPACE IF NOT EXISTS {_settings.Namespace}; " +
|
||||
$"USE NS {_settings.Namespace}; " +
|
||||
$"DEFINE DATABASE IF NOT EXISTS {_settings.Database}; " +
|
||||
$"USE DB {_settings.Database};";
|
||||
|
||||
await _client.RawQuery(sql, cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using SurrealDb.Net;
|
||||
using TarninaHub.Application.Abstractions;
|
||||
using TarninaHub.Application.Dtos;
|
||||
using TarninaHub.Infrastructure.Settings;
|
||||
|
||||
namespace TarninaHub.Infrastructure.Persistence;
|
||||
|
||||
public sealed class StorageService : IStorageService
|
||||
{
|
||||
private const string ProbeTable = "storage_probe";
|
||||
|
||||
private readonly ISurrealDbClient _client;
|
||||
private readonly SurrealDbSettings _settings;
|
||||
private readonly string _dataDirectory;
|
||||
|
||||
public StorageService(ISurrealDbClient client, IOptions<SurrealDbSettings> settings)
|
||||
{
|
||||
_client = client;
|
||||
_settings = settings.Value;
|
||||
_dataDirectory = Path.Combine(AppContext.BaseDirectory, _settings.DataDirectory);
|
||||
}
|
||||
|
||||
public async Task InitializeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _client.RawQuery(
|
||||
$"DEFINE TABLE IF NOT EXISTS {ProbeTable} SCHEMALESS;",
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
public StorageInfo GetInfo()
|
||||
{
|
||||
var isRemote = !string.IsNullOrWhiteSpace(_settings.RemoteUrl);
|
||||
|
||||
return new StorageInfo(
|
||||
isRemote ? "remote" : "rocksdb",
|
||||
isRemote ? _settings.RemoteUrl! : _dataDirectory,
|
||||
_settings.Namespace,
|
||||
_settings.Database);
|
||||
}
|
||||
|
||||
public async Task<int> GetProbeCountAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var probes = await _client.Select<StorageProbe>(ProbeTable, cancellationToken);
|
||||
|
||||
return probes.Count();
|
||||
}
|
||||
|
||||
public Task<StorageProbe> AddProbeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var probe = new StorageProbe
|
||||
{
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
return _client.Create(ProbeTable, probe, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace TarninaHub.Infrastructure.Settings;
|
||||
|
||||
public static class SurrealDbEndpoint
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the SurrealDB endpoint. Remote mode uses the configured URL;
|
||||
/// embedded mode uses the RocksDB provider persisted to the data directory
|
||||
/// (forward slashes, so the same endpoint works on Windows and Linux).
|
||||
/// </summary>
|
||||
public static string Build(SurrealDbSettings settings)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(settings.RemoteUrl))
|
||||
{
|
||||
return settings.RemoteUrl;
|
||||
}
|
||||
|
||||
var dataDirectory = Path.Combine(AppContext.BaseDirectory, settings.DataDirectory);
|
||||
|
||||
return $"rocksdb://{dataDirectory.Replace('\\', '/')}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace TarninaHub.Infrastructure.Settings;
|
||||
|
||||
public sealed class SurrealDbSettings
|
||||
{
|
||||
public string Namespace { get; set; } = "hubmi";
|
||||
|
||||
public string Database { get; set; } = "hubmi";
|
||||
|
||||
public string DataDirectory { get; set; } = "data";
|
||||
|
||||
/// <summary>
|
||||
/// Directory (relative to the app base directory) with ordered *.surql
|
||||
/// migration files applied at startup. Copied from backend/migrations.
|
||||
/// </summary>
|
||||
public string MigrationsDirectory { get; set; } = "migrations";
|
||||
|
||||
/// <summary>
|
||||
/// When set (e.g. ws://127.0.0.1:8010/rpc), the backend connects to a
|
||||
/// dedicated SurrealDB server instead of the embedded RocksDB engine.
|
||||
/// The server must persist to ./data, not to memory.
|
||||
/// </summary>
|
||||
public string? RemoteUrl { get; set; }
|
||||
|
||||
public string? Username { get; set; }
|
||||
|
||||
public string? Password { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.7" />
|
||||
<PackageReference Include="SurrealDb.Embedded.RocksDb" Version="1.0.0" />
|
||||
<PackageReference Include="SurrealDb.Net" Version="1.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Application\TarninaHub.Application.csproj" />
|
||||
<ProjectReference Include="..\Domain\TarninaHub.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user