diff --git a/src/App/Api/ApiEndpoints.cs b/src/App/Api/ApiEndpoints.cs new file mode 100644 index 0000000..f26706b --- /dev/null +++ b/src/App/Api/ApiEndpoints.cs @@ -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; + +/// Request/response contracts for the REST API (SDD §9). +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); + +/// Maps the versioned REST API. All endpoints require a valid API key (SDD §9). +public static class ApiEndpoints +{ + public static IEndpointRouteBuilder MapMeterVaultApi(this IEndpointRouteBuilder app) + { + var api = app.MapGroup("/api/v1").AddEndpointFilter().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; + } +} diff --git a/src/App/Api/ApiKeyFilter.cs b/src/App/Api/ApiKeyFilter.cs new file mode 100644 index 0000000..e91688f --- /dev/null +++ b/src/App/Api/ApiKeyFilter.cs @@ -0,0 +1,31 @@ +using MeterVault.Infrastructure.Options; +using Microsoft.Extensions.Options; + +namespace MeterVault.App.Api; + +/// +/// Endpoint filter enforcing the X-Api-Key header against the configured keys (SDD §9). +/// When no keys are configured the API is open — intended only for local development. +/// +public sealed class ApiKeyFilter(IOptions options) : IEndpointFilter +{ + public const string HeaderName = "X-Api-Key"; + + private readonly MeterVaultOptions _options = options.Value; + + public async ValueTask 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); + } +} diff --git a/src/App/Api/ReverseProxyTrust.cs b/src/App/Api/ReverseProxyTrust.cs new file mode 100644 index 0000000..f5fb232 --- /dev/null +++ b/src/App/Api/ReverseProxyTrust.cs @@ -0,0 +1,43 @@ +using System.Security.Claims; +using MeterVault.Infrastructure.Options; +using Microsoft.Extensions.Options; + +namespace MeterVault.App.Api; + +/// +/// When enabled (SDD §10), trusts an authenticating reverse proxy (Authelia/Traefik) by adopting +/// the user it asserts via X-Forwarded-User / Remote-User. 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. +/// +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>().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; + } +} diff --git a/src/App/MeterVault.App.csproj b/src/App/MeterVault.App.csproj index c0df808..e1b6108 100644 --- a/src/App/MeterVault.App.csproj +++ b/src/App/MeterVault.App.csproj @@ -14,6 +14,7 @@ + diff --git a/src/App/Program.cs b/src/App/Program.cs index 4ff29b6..c2c3ec1 100644 --- a/src/App/Program.cs +++ b/src/App/Program.cs @@ -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() .AddInteractiveServerRenderMode(); + app.MapMeterVaultApi(); + // Liveness/readiness probe for Gatus/Compose healthchecks (SDD §9). app.MapGet("/healthz", () => Results.Ok(new { status = "ok" })); diff --git a/src/Infrastructure/Ingestion/IngestionService.cs b/src/Infrastructure/Ingestion/IngestionService.cs index 4f7f550..b371fa2 100644 --- a/src/Infrastructure/Ingestion/IngestionService.cs +++ b/src/Infrastructure/Ingestion/IngestionService.cs @@ -26,6 +26,7 @@ public sealed class IngestionService(MeterVaultDbContext db) private readonly MeterVaultDbContext _db = db; + /// Ingests through a configured source (MQTT/HA workers): applies scale/offset and updates source status. public async Task IngestAsync( int sourceId, DateTimeOffset time, double rawValue, CancellationToken cancellationToken = default) { @@ -53,10 +54,40 @@ public sealed class IngestionService(MeterVaultDbContext db) return IngestionOutcome.RejectedDecrease; } - var existing = await _db.Readings - .FirstOrDefaultAsync(r => r.MeterId == meter.Id && r.Time == utc, cancellationToken).ConfigureAwait(false); + var outcome = await UpsertAsync(meter, utc, value, source.Id, cancellationToken).ConfigureAwait(false); + await UpdateSourceStatusAsync(source, utc, value, "ok", cancellationToken).ConfigureAwait(false); + return outcome; + } + + /// Ingests directly against a meter (REST push, e.g. Home Assistant POST /api/v1/readings). + public async Task IngestByMeterAsync( + int meterId, DateTimeOffset time, double value, CancellationToken cancellationToken = default) + { + var meter = await _db.Meters + .FirstOrDefaultAsync(m => m.Id == meterId, cancellationToken).ConfigureAwait(false); + if (meter is null) + { + return IngestionOutcome.UnknownSource; + } + + var utc = time.ToUniversalTime(); + if (MonotonicModes.Contains(meter.Mode) + && await IsSpuriousDecreaseAsync(meter.Id, utc, value, cancellationToken).ConfigureAwait(false)) + { + return IngestionOutcome.RejectedDecrease; + } + + var outcome = await UpsertAsync(meter, utc, value, sourceId: null, cancellationToken).ConfigureAwait(false); + await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + return outcome; + } + + private async Task UpsertAsync( + Meter meter, DateTimeOffset utc, double value, int? sourceId, CancellationToken cancellationToken) + { + var existing = _db.Readings.Local.FirstOrDefault(r => r.MeterId == meter.Id && r.Time == utc) + ?? await _db.Readings.FirstOrDefaultAsync(r => r.MeterId == meter.Id && r.Time == utc, cancellationToken).ConfigureAwait(false); - IngestionOutcome outcome; if (existing is null) { _db.Readings.Add(new Reading @@ -64,20 +95,15 @@ public sealed class IngestionService(MeterVaultDbContext db) MeterId = meter.Id, Time = utc, Value = value, - SourceId = source.Id, + SourceId = sourceId, Quality = ReadingQuality.Measured, }); - outcome = IngestionOutcome.Written; - } - else - { - existing.Value = value; - existing.SourceId = source.Id; - outcome = IngestionOutcome.Updated; + return IngestionOutcome.Written; } - await UpdateSourceStatusAsync(source, utc, value, "ok", cancellationToken).ConfigureAwait(false); - return outcome; + existing.Value = value; + existing.SourceId = sourceId ?? existing.SourceId; + return IngestionOutcome.Updated; } private async Task IsSpuriousDecreaseAsync( diff --git a/src/Infrastructure/Options/MeterVaultOptions.cs b/src/Infrastructure/Options/MeterVaultOptions.cs index f6be39f..bc69c1d 100644 --- a/src/Infrastructure/Options/MeterVaultOptions.cs +++ b/src/Infrastructure/Options/MeterVaultOptions.cs @@ -24,4 +24,13 @@ public sealed class MeterVaultOptions /// How long full-resolution raw readings are retained (SDD §5.5, default 3 years). public int RawRetentionDays { get; set; } = 1095; + + /// + /// API keys accepted on the X-Api-Key header for the REST API (SDD §9). Provide via env + /// (e.g. MeterVault__ApiKeys__0=...). Empty means the API is open (dev only). + /// + public IList ApiKeys { get; set; } = []; + + /// Honour X-Forwarded-User/Remote-User from a trusted reverse proxy (SDD §10). + public bool ReverseProxyTrust { get; set; } } diff --git a/tests/Integration.Tests/ApiTests.cs b/tests/Integration.Tests/ApiTests.cs new file mode 100644 index 0000000..43c4f8b --- /dev/null +++ b/tests/Integration.Tests/ApiTests.cs @@ -0,0 +1,71 @@ +using System.Net; +using System.Net.Http.Json; +using MeterVault.Core.Domain; +using MeterVault.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace MeterVault.Integration.Tests; + +[Collection("Timescale")] +public sealed class ApiTests(TimescaleFixture fx) +{ + private sealed record ReadingPush(int MeterId, DateTimeOffset Time, double Value); + + [Fact] + public async Task Readings_push_requires_key_and_writes_when_authorized() + { + int meterId; + await using (var db = fx.CreateContext()) + { + await DatabaseSeeder.SeedAsync(db); + var type = await db.EnergyTypes.FirstAsync(t => t.Key == "electricity"); + var meter = new Meter { Name = $"api-{Guid.NewGuid():N}", EnergyTypeId = type.Id, Mode = MeterMode.CumulativeCounter, Unit = "kWh" }; + db.Meters.Add(meter); + await db.SaveChangesAsync(); + meterId = meter.Id; + } + + using var factory = new MeterVaultAppFactory(fx.ConnectionString); + using var client = factory.CreateClient(); + + var push = new[] { new ReadingPush(meterId, new DateTimeOffset(2024, 5, 1, 0, 0, 0, TimeSpan.Zero), 1500) }; + + // Without the key → 401. + var unauthorized = await client.PostAsJsonAsync("/api/v1/readings", push); + Assert.Equal(HttpStatusCode.Unauthorized, unauthorized.StatusCode); + + // With the key → 200 and the reading is persisted. + using var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/readings") + { + Content = JsonContent.Create(push), + }; + request.Headers.Add("X-Api-Key", MeterVaultAppFactory.ApiKey); + var authorized = await client.SendAsync(request); + authorized.EnsureSuccessStatusCode(); + + await using (var db = fx.CreateContext()) + { + var reading = await db.Readings.SingleAsync(r => r.MeterId == meterId); + Assert.Equal(1500, reading.Value, 3); + await db.Readings.Where(r => r.MeterId == meterId).ExecuteDeleteAsync(); + await db.Meters.Where(m => m.Id == meterId).ExecuteDeleteAsync(); + } + } + + [Fact] + public async Task Meters_endpoint_and_swagger_are_available() + { + using var factory = new MeterVaultAppFactory(fx.ConnectionString); + using var client = factory.CreateClient(); + + using var metersRequest = new HttpRequestMessage(HttpMethod.Get, "/api/v1/meters"); + metersRequest.Headers.Add("X-Api-Key", MeterVaultAppFactory.ApiKey); + var meters = await client.SendAsync(metersRequest); + meters.EnsureSuccessStatusCode(); + + // Swagger document is served (no API key required). + var swagger = await client.GetAsync(new Uri("/swagger/v1/swagger.json", UriKind.Relative)); + swagger.EnsureSuccessStatusCode(); + Assert.Contains("MeterVault API", await swagger.Content.ReadAsStringAsync(), StringComparison.Ordinal); + } +} diff --git a/tests/Integration.Tests/MeterVaultAppFactory.cs b/tests/Integration.Tests/MeterVaultAppFactory.cs index 44b7902..1fc695c 100644 --- a/tests/Integration.Tests/MeterVaultAppFactory.cs +++ b/tests/Integration.Tests/MeterVaultAppFactory.cs @@ -9,11 +9,14 @@ namespace MeterVault.Integration.Tests; /// public sealed class MeterVaultAppFactory(string connectionString) : WebApplicationFactory { + public const string ApiKey = "test-api-key"; + protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.UseEnvironment("Testing"); builder.UseSetting("ConnectionStrings:Default", connectionString); builder.UseSetting("MeterVault:RunMigrationsAtStartup", "false"); builder.UseSetting("MeterVault:EnableLiveIngestion", "false"); + builder.UseSetting("MeterVault:ApiKeys:0", ApiKey); } }