M6: REST API + API-key auth + OpenAPI

- Minimal API under /api/v1 (SDD §9): POST /readings (idempotent HA push), GET /meters,
  /energy-types, /consumption, /cost, /dashboard/summary, POST /events (records + recomputes),
  GET+POST /tariffs, GET /sources/status.
- IngestionService.IngestByMeterAsync for direct REST push (batch-safe upsert via Local cache).
- ApiKeyFilter: X-Api-Key enforced against configured keys (open only when none set).
- ReverseProxyTrust middleware: adopt X-Forwarded-User/Remote-User behind Authelia/Traefik.
- Swagger/OpenAPI (Swashbuckle) at /swagger.
- Tests: push rejected without key (401), accepted + persisted with key; meters + swagger live.

94 tests green (56 Core + 38 integration).

Claude-Session: https://claude.ai/code/session_01WujdMtMJPbxDpDnMeK22rr
This commit is contained in:
2026-07-13 12:16:49 +02:00
parent d5419729e5
commit 9abc2937c2
9 changed files with 325 additions and 13 deletions
+117
View File
@@ -0,0 +1,117 @@
using MeterVault.Core.Domain;
using MeterVault.Infrastructure.Costing;
using MeterVault.Infrastructure.Dashboard;
using MeterVault.Infrastructure.Ingestion;
using MeterVault.Infrastructure.Normalization;
using MeterVault.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace MeterVault.App.Api;
/// <summary>Request/response contracts for the REST API (SDD §9).</summary>
public sealed record ReadingPush(int MeterId, DateTimeOffset Time, double Value);
public sealed record EventPush(int MeterId, DateTimeOffset Time, MeterEventType Type,
double? Amount, double? PrevValue, double? NewValue, string? Unit, string? Notes);
public sealed record TariffPush(TariffScope ScopeType, int? ScopeId, TariffComponent Component,
double Value, string Unit, DateOnly ValidFrom, DateOnly? ValidTo);
public sealed record IngestResult(int Written, int Updated, int Rejected);
/// <summary>Maps the versioned REST API. All endpoints require a valid API key (SDD §9).</summary>
public static class ApiEndpoints
{
public static IEndpointRouteBuilder MapMeterVaultApi(this IEndpointRouteBuilder app)
{
var api = app.MapGroup("/api/v1").AddEndpointFilter<ApiKeyFilter>().WithTags("MeterVault");
api.MapPost("/readings", async (ReadingPush[] readings, IngestionService ingestion, CancellationToken ct) =>
{
int written = 0, updated = 0, rejected = 0;
foreach (var r in readings)
{
switch (await ingestion.IngestByMeterAsync(r.MeterId, r.Time, r.Value, ct))
{
case IngestionOutcome.Written: written++; break;
case IngestionOutcome.Updated: updated++; break;
case IngestionOutcome.RejectedDecrease: rejected++; break;
default: break;
}
}
return Results.Ok(new IngestResult(written, updated, rejected));
}).WithSummary("Ingest one or more readings (idempotent). Lets Home Assistant push.");
api.MapGet("/meters", async (MeterVaultDbContext db, CancellationToken ct) =>
Results.Ok(await db.Meters.AsNoTracking()
.Select(m => new { m.Id, m.Name, m.EnergyTypeId, Mode = m.Mode.ToString(), m.Unit, m.IsActive })
.ToListAsync(ct)));
api.MapGet("/energy-types", async (MeterVaultDbContext db, CancellationToken ct) =>
Results.Ok(await db.EnergyTypes.AsNoTracking()
.Select(t => new { t.Id, t.Key, t.DisplayName, t.BaseUnit, Mode = t.DefaultMode.ToString() })
.ToListAsync(ct)));
api.MapGet("/consumption", async (int meter, DateTimeOffset from, DateTimeOffset to,
CostService cost, CancellationToken ct) =>
{
var buckets = await cost.GetMeterCostsAsync(meter, from, to, CostBucket.Month, ct);
return Results.Ok(buckets.Select(b => new { b.Period, b.Consumption, b.Generation }));
}).WithSummary("Normalized monthly consumption/generation for a meter.");
api.MapGet("/cost", async (int meter, DateTimeOffset from, DateTimeOffset to,
CostService cost, CancellationToken ct) =>
Results.Ok(await cost.GetMeterCostsAsync(meter, from, to, CostBucket.Month, ct)));
api.MapGet("/dashboard/summary", async (DashboardService dashboard, CancellationToken ct) =>
Results.Ok(await dashboard.GetSummaryAsync(DateOnly.FromDateTime(DateTime.UtcNow), ct)));
api.MapPost("/events", async (EventPush push, MeterVaultDbContext db,
NormalizationService normalization, CancellationToken ct) =>
{
db.MeterEvents.Add(new MeterEvent
{
MeterId = push.MeterId,
Time = push.Time,
EventType = push.Type,
Amount = push.Amount,
PrevValue = push.PrevValue,
NewValue = push.NewValue,
Unit = push.Unit,
Notes = push.Notes,
});
await db.SaveChangesAsync(ct);
await normalization.RecomputeMeterAsync(push.MeterId, null, ct);
await db.SaveChangesAsync(ct);
return Results.Ok();
}).WithSummary("Record a delivery / swap / tank level / correction and recompute the meter.");
api.MapGet("/tariffs", async (MeterVaultDbContext db, CancellationToken ct) =>
Results.Ok(await db.Tariffs.AsNoTracking().OrderBy(t => t.ValidFrom).ToListAsync(ct)));
api.MapPost("/tariffs", async (TariffPush push, MeterVaultDbContext db, CancellationToken ct) =>
{
var tariff = new Tariff
{
ScopeType = push.ScopeType,
ScopeId = push.ScopeId,
Component = push.Component,
Value = push.Value,
Unit = push.Unit,
ValidFrom = push.ValidFrom,
ValidTo = push.ValidTo,
};
db.Tariffs.Add(tariff);
await db.SaveChangesAsync(ct);
return Results.Created($"/api/v1/tariffs/{tariff.Id}", new { tariff.Id });
});
api.MapGet("/sources/status", async (MeterVaultDbContext db, CancellationToken ct) =>
Results.Ok(await db.MeterSources.AsNoTracking()
.Select(s => new { s.Id, s.MeterId, Type = s.SourceType.ToString(), s.IsEnabled, s.LastSeenAt, s.LastValue, s.LastStatus })
.ToListAsync(ct)));
return app;
}
}
+31
View File
@@ -0,0 +1,31 @@
using MeterVault.Infrastructure.Options;
using Microsoft.Extensions.Options;
namespace MeterVault.App.Api;
/// <summary>
/// Endpoint filter enforcing the <c>X-Api-Key</c> header against the configured keys (SDD §9).
/// When no keys are configured the API is open — intended only for local development.
/// </summary>
public sealed class ApiKeyFilter(IOptions<MeterVaultOptions> options) : IEndpointFilter
{
public const string HeaderName = "X-Api-Key";
private readonly MeterVaultOptions _options = options.Value;
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
if (_options.ApiKeys.Count == 0)
{
return await next(context).ConfigureAwait(false);
}
var provided = context.HttpContext.Request.Headers[HeaderName].ToString();
if (string.IsNullOrEmpty(provided) || !_options.ApiKeys.Contains(provided, StringComparer.Ordinal))
{
return Results.Problem(statusCode: StatusCodes.Status401Unauthorized, title: "Invalid or missing API key.");
}
return await next(context).ConfigureAwait(false);
}
}
+43
View File
@@ -0,0 +1,43 @@
using System.Security.Claims;
using MeterVault.Infrastructure.Options;
using Microsoft.Extensions.Options;
namespace MeterVault.App.Api;
/// <summary>
/// When enabled (SDD §10), trusts an authenticating reverse proxy (Authelia/Traefik) by adopting
/// the user it asserts via <c>X-Forwarded-User</c> / <c>Remote-User</c>. This lets the homelab run
/// MeterVault behind existing SSO without built-in accounts. Only enable when the app is not
/// directly reachable — any client could otherwise spoof the header.
/// </summary>
public static class ReverseProxyTrust
{
private static readonly string[] UserHeaders = ["X-Forwarded-User", "Remote-User", "X-Forwarded-Preferred-Username"];
public static IApplicationBuilder UseReverseProxyTrust(this WebApplication app)
{
var options = app.Services.GetRequiredService<IOptions<MeterVaultOptions>>().Value;
if (!options.ReverseProxyTrust)
{
return app;
}
app.Use(async (context, next) =>
{
foreach (var header in UserHeaders)
{
var user = context.Request.Headers[header].ToString();
if (!string.IsNullOrWhiteSpace(user))
{
var identity = new ClaimsIdentity([new Claim(ClaimTypes.Name, user)], "ReverseProxy");
context.User = new ClaimsPrincipal(identity);
break;
}
}
await next(context).ConfigureAwait(false);
});
return app;
}
}
+1
View File
@@ -14,6 +14,7 @@
<PackageReference Include="Serilog.Sinks.Console" />
<PackageReference Include="MudBlazor" />
<PackageReference Include="Blazor-ApexCharts" />
<PackageReference Include="Swashbuckle.AspNetCore" />
</ItemGroup>
<ItemGroup>
+11
View File
@@ -1,3 +1,4 @@
using MeterVault.App.Api;
using MeterVault.App.Components;
using MeterVault.Infrastructure;
using MeterVault.Infrastructure.Options;
@@ -38,6 +39,10 @@ try
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(c =>
c.SwaggerDoc("v1", new() { Title = "MeterVault API", Version = "v1" }));
var app = builder.Build();
await MigrateDatabaseAsync(app).ConfigureAwait(false);
@@ -50,14 +55,20 @@ try
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
app.UseSerilogRequestLogging();
app.UseReverseProxyTrust();
// No HTTPS redirection: the app serves plain HTTP (port 8080) behind a reverse proxy
// that terminates TLS (SDD §10). HTTPS redirection here would break the container and proxy.
app.UseAntiforgery();
app.UseSwagger();
app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "MeterVault API v1"));
app.MapStaticAssets();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
app.MapMeterVaultApi();
// Liveness/readiness probe for Gatus/Compose healthchecks (SDD §9).
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }));