9abc2937c2
- 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
118 lines
5.4 KiB
C#
118 lines
5.4 KiB
C#
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;
|
|
}
|
|
}
|