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:
Rafał Tyński
2026-10-04 01:49:51 +02:00
parent 0a5ef715eb
commit b3756238a3
26 changed files with 437 additions and 63 deletions
@@ -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);
}
}