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:
@@ -26,6 +26,7 @@ public sealed class IngestionService(MeterVaultDbContext db)
|
||||
|
||||
private readonly MeterVaultDbContext _db = db;
|
||||
|
||||
/// <summary>Ingests through a configured source (MQTT/HA workers): applies scale/offset and updates source status.</summary>
|
||||
public async Task<IngestionOutcome> 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;
|
||||
}
|
||||
|
||||
/// <summary>Ingests directly against a meter (REST push, e.g. Home Assistant POST /api/v1/readings).</summary>
|
||||
public async Task<IngestionOutcome> 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<IngestionOutcome> 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<bool> IsSpuriousDecreaseAsync(
|
||||
|
||||
@@ -24,4 +24,13 @@ public sealed class MeterVaultOptions
|
||||
|
||||
/// <summary>How long full-resolution raw readings are retained (SDD §5.5, default 3 years).</summary>
|
||||
public int RawRetentionDays { get; set; } = 1095;
|
||||
|
||||
/// <summary>
|
||||
/// API keys accepted on the <c>X-Api-Key</c> header for the REST API (SDD §9). Provide via env
|
||||
/// (e.g. <c>MeterVault__ApiKeys__0=...</c>). Empty means the API is open (dev only).
|
||||
/// </summary>
|
||||
public IList<string> ApiKeys { get; set; } = [];
|
||||
|
||||
/// <summary>Honour <c>X-Forwarded-User</c>/<c>Remote-User</c> from a trusted reverse proxy (SDD §10).</summary>
|
||||
public bool ReverseProxyTrust { get; set; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user