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,6 @@
namespace TarninaHub.Application.Abstractions;
public interface IMigrationRunner
{
Task ApplyAsync(CancellationToken cancellationToken);
}
@@ -1,6 +1,6 @@
using TarninaHub.Models; using TarninaHub.Application.Dtos;
namespace TarninaHub.Services; namespace TarninaHub.Application.Abstractions;
public interface IStorageService public interface IStorageService
{ {
@@ -1,4 +1,4 @@
namespace TarninaHub.Models; namespace TarninaHub.Application.Dtos;
public sealed class StorageProbe public sealed class StorageProbe
{ {
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Domain\TarninaHub.Domain.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,29 @@
using Asp.Versioning.ApiExplorer;
using Microsoft.Extensions.Options;
using Microsoft.OpenApi;
using Swashbuckle.AspNetCore.SwaggerGen;
namespace TarninaHub.Configuration;
public sealed class ConfigureSwaggerOptions : IConfigureOptions<SwaggerGenOptions>
{
private readonly IApiVersionDescriptionProvider _provider;
public ConfigureSwaggerOptions(IApiVersionDescriptionProvider provider)
{
_provider = provider;
}
public void Configure(SwaggerGenOptions options)
{
foreach (var description in _provider.ApiVersionDescriptions)
{
options.SwaggerDoc(description.GroupName, new OpenApiInfo
{
Title = "TarninaHub API",
Version = description.ApiVersion.ToString(),
Description = "Backend API (BFF) for TarninaHub — Małopolski Hub Innowacji Społecznych.",
});
}
}
}
+4 -2
View File
@@ -1,10 +1,12 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using TarninaHub.Models; using TarninaHub.Application.Abstractions;
using TarninaHub.Services; using TarninaHub.Application.Dtos;
namespace TarninaHub.Controllers; namespace TarninaHub.Controllers;
[ApiController] [ApiController]
[ApiVersion("1.0")]
[Route("api/system")] [Route("api/system")]
public class SystemController : ControllerBase public class SystemController : ControllerBase
{ {
@@ -0,0 +1,9 @@
namespace TarninaHub.Domain.Enums;
public enum ApplicationStatus
{
Draft,
Submitted,
Accepted,
Rejected,
}
+9
View File
@@ -0,0 +1,9 @@
namespace TarninaHub.Domain.Enums;
public enum DocumentScope
{
Local,
Partial,
Full,
Anonymized,
}
+8
View File
@@ -0,0 +1,8 @@
namespace TarninaHub.Domain.Enums;
public enum GrantCallStatus
{
Planned,
Open,
Closed,
}
+10
View File
@@ -0,0 +1,10 @@
namespace TarninaHub.Domain.Enums;
public enum IdeaStatus
{
Draft,
Submitted,
InReview,
Accepted,
Rejected,
}
+9
View File
@@ -0,0 +1,9 @@
namespace TarninaHub.Domain.Enums;
public enum InnovationStage
{
Idea,
Pilot,
Scaling,
Service,
}
+8
View File
@@ -0,0 +1,8 @@
namespace TarninaHub.Domain.Enums;
public enum LocationKind
{
Gmina,
Powiat,
Wojewodztwo,
}
@@ -0,0 +1,8 @@
namespace TarninaHub.Domain.Enums;
public enum PartnershipStatus
{
Proposed,
Active,
Closed,
}
+11
View File
@@ -0,0 +1,11 @@
namespace TarninaHub.Domain.Enums;
public enum SubmissionStatus
{
New,
Triaged,
Matched,
InProgress,
Closed,
Rejected,
}
+10
View File
@@ -0,0 +1,10 @@
namespace TarninaHub.Domain.Enums;
public enum TagGroup
{
Audience,
Topic,
Stage,
Sector,
Cross,
}
+10
View File
@@ -0,0 +1,10 @@
namespace TarninaHub.Domain.Enums;
public enum UserRole
{
User,
Jst,
Operator,
Manager,
Admin,
}
+9
View File
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
+38
View File
@@ -0,0 +1,38 @@
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
namespace TarninaHub.Errors;
public sealed class GlobalExceptionHandler : IExceptionHandler
{
private readonly ILogger<GlobalExceptionHandler> _logger;
public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
{
_logger = logger;
}
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
_logger.LogError(exception, "Unhandled exception for {Method} {Path}",
httpContext.Request.Method,
httpContext.Request.Path);
var problem = new ProblemDetails
{
Type = "https://httpstatuses.io/500",
Title = "An unexpected error occurred.",
Status = StatusCodes.Status500InternalServerError,
Instance = httpContext.Request.Path,
};
httpContext.Response.StatusCode = problem.Status.Value;
await httpContext.Response.WriteAsJsonAsync(problem, cancellationToken);
return true;
}
}
@@ -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);
}
}
@@ -1,9 +1,10 @@
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using SurrealDb.Net; using SurrealDb.Net;
using TarninaHub.Models; using TarninaHub.Application.Abstractions;
using TarninaHub.Settings; using TarninaHub.Application.Dtos;
using TarninaHub.Infrastructure.Settings;
namespace TarninaHub.Services; namespace TarninaHub.Infrastructure.Persistence;
public sealed class StorageService : IStorageService public sealed class StorageService : IStorageService
{ {
@@ -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('\\', '/')}";
}
}
@@ -1,4 +1,4 @@
namespace TarninaHub.Settings; namespace TarninaHub.Infrastructure.Settings;
public sealed class SurrealDbSettings public sealed class SurrealDbSettings
{ {
@@ -8,6 +8,12 @@ public sealed class SurrealDbSettings
public string DataDirectory { get; set; } = "data"; 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> /// <summary>
/// When set (e.g. ws://127.0.0.1:8010/rpc), the backend connects to a /// 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. /// dedicated SurrealDB server instead of the embedded RocksDB engine.
@@ -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>
+49 -48
View File
@@ -1,61 +1,74 @@
using TarninaHub.Services; using Asp.Versioning;
using TarninaHub.Settings; using Asp.Versioning.ApiExplorer;
using Microsoft.Extensions.Options;
using Swashbuckle.AspNetCore.SwaggerGen;
using TarninaHub.Application.Abstractions;
using TarninaHub.Configuration;
using TarninaHub.Errors;
using TarninaHub.Infrastructure;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
var surrealSettings = builder.Configuration.GetSection("SurrealDb").Get<SurrealDbSettings>() // Layered infrastructure: SurrealDB client, settings and persistence services.
?? new SurrealDbSettings(); builder.Services.AddInfrastructure(builder.Configuration);
builder.Services.Configure<SurrealDbSettings>(builder.Configuration.GetSection("SurrealDb"));
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi();
builder.Services.AddControllers(); builder.Services.AddControllers();
builder.Services.AddSingleton<IStorageService, StorageService>(); // Uniform error responses (RFC 7807) and a global exception handler.
builder.Services.AddProblemDetails();
var surrealOptions = new SurrealDbOptions builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
{
Endpoint = BuildEndpoint(surrealSettings),
Namespace = surrealSettings.Namespace,
Database = surrealSettings.Database,
};
if (string.IsNullOrWhiteSpace(surrealSettings.RemoteUrl))
{
// Embedded RocksDB, persisted next to the running app (./data), never in memory.
var dataDirectory = Path.Combine(AppContext.BaseDirectory, surrealSettings.DataDirectory);
Directory.CreateDirectory(dataDirectory);
// API versioning: `?api-version=1.0` or header `X-Api-Version`; default 1.0.
builder.Services builder.Services
.AddSurreal(surrealOptions, ServiceLifetime.Singleton) .AddApiVersioning(options =>
.AddRocksDbProvider();
}
else
{ {
// Remote SurrealDB server on its own dedicated port (used for sync with the frontend). options.DefaultApiVersion = new ApiVersion(1, 0);
surrealOptions.Username = surrealSettings.Username; options.AssumeDefaultVersionWhenUnspecified = true;
surrealOptions.Password = surrealSettings.Password; options.ApiVersionReader = ApiVersionReader.Combine(
new QueryStringApiVersionReader("api-version"),
new HeaderApiVersionReader("X-Api-Version"));
options.ReportApiVersions = true;
})
.AddMvc()
.AddApiExplorer(options =>
{
options.GroupNameFormat = "'v'VVV";
options.SubstituteApiVersionInUrl = true;
});
builder.Services.AddSurreal(surrealOptions, ServiceLifetime.Singleton); // OpenAPI document + Swagger UI (Swashbuckle), one document per API version.
} builder.Services.AddEndpointsApiExplorer();
builder.Services.AddTransient<IConfigureOptions<SwaggerGenOptions>, ConfigureSwaggerOptions>();
builder.Services.AddSwaggerGen();
var app = builder.Build(); var app = builder.Build();
using (var scope = app.Services.CreateScope()) using (var scope = app.Services.CreateScope())
{ {
var migrationRunner = scope.ServiceProvider.GetRequiredService<IMigrationRunner>();
var storageService = scope.ServiceProvider.GetRequiredService<IStorageService>(); var storageService = scope.ServiceProvider.GetRequiredService<IStorageService>();
await migrationRunner.ApplyAsync(CancellationToken.None);
await storageService.InitializeAsync(CancellationToken.None); await storageService.InitializeAsync(CancellationToken.None);
} }
// Configure the HTTP request pipeline. app.UseExceptionHandler();
if (app.Environment.IsDevelopment()) app.UseStatusCodePages();
app.UseSwagger();
app.UseSwaggerUI(options =>
{ {
app.MapOpenApi(); var provider = app.Services.GetRequiredService<IApiVersionDescriptionProvider>();
foreach (var description in provider.ApiVersionDescriptions)
{
options.SwaggerEndpoint(
$"/swagger/{description.GroupName}/swagger.json",
$"TarninaHub API {description.GroupName.ToUpperInvariant()}");
} }
else });
if (!app.Environment.IsDevelopment())
{ {
app.UseHttpsRedirection(); app.UseHttpsRedirection();
} }
@@ -68,15 +81,3 @@ app.MapControllers();
app.MapFallbackToFile("index.html"); app.MapFallbackToFile("index.html");
app.Run(); app.Run();
static string BuildEndpoint(SurrealDbSettings settings)
{
if (!string.IsNullOrWhiteSpace(settings.RemoteUrl))
{
return settings.RemoteUrl;
}
var dataDirectory = Path.Combine(AppContext.BaseDirectory, settings.DataDirectory);
return $"rocksdb://{dataDirectory.Replace('\\', '/')}";
}
+24 -3
View File
@@ -9,9 +9,30 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.12" /> <!-- Layered projects are nested under backend/ for deployment; exclude their
<PackageReference Include="SurrealDb.Embedded.RocksDb" Version="1.0.0" /> sources from the API host compile (they are referenced as projects). -->
<PackageReference Include="SurrealDb.Net" Version="1.0.0" /> <Compile Remove="Application/**/*.cs" />
<Compile Remove="Domain/**/*.cs" />
<Compile Remove="Infrastructure/**/*.cs" />
<Content Remove="Application/**" />
<Content Remove="Domain/**" />
<Content Remove="Infrastructure/**" />
</ItemGroup>
<ItemGroup>
<Content Include="migrations/**/*.surql" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Asp.Versioning.Mvc" Version="10.2.1" />
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="10.2.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="Application/TarninaHub.Application.csproj" />
<ProjectReference Include="Domain/TarninaHub.Domain.csproj" />
<ProjectReference Include="Infrastructure/TarninaHub.Infrastructure.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>