sprints work
This commit is contained in:
191
src/__Libraries/StellaOps.Provcache.Api/ApiModels.cs
Normal file
191
src/__Libraries/StellaOps.Provcache.Api/ApiModels.cs
Normal file
@@ -0,0 +1,191 @@
|
||||
namespace StellaOps.Provcache.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Response model for GET /v1/provcache/{veriKey}.
|
||||
/// </summary>
|
||||
public sealed class ProvcacheGetResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// The VeriKey that was looked up.
|
||||
/// </summary>
|
||||
public required string VeriKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The cache entry if found.
|
||||
/// </summary>
|
||||
public ProvcacheEntry? Entry { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The source of the cache hit (valkey, postgres, etc.).
|
||||
/// </summary>
|
||||
public string? Source { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Time taken for the lookup in milliseconds.
|
||||
/// </summary>
|
||||
public double ElapsedMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Status: "hit", "miss", "bypassed", "expired".
|
||||
/// </summary>
|
||||
public required string Status { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request model for POST /v1/provcache.
|
||||
/// </summary>
|
||||
public sealed class ProvcacheCreateRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// The cache entry to store.
|
||||
/// </summary>
|
||||
public ProvcacheEntry? Entry { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response model for POST /v1/provcache.
|
||||
/// </summary>
|
||||
public sealed class ProvcacheCreateResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// The VeriKey that was stored.
|
||||
/// </summary>
|
||||
public required string VeriKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the store operation succeeded.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When the entry expires.
|
||||
/// </summary>
|
||||
public DateTimeOffset ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request model for POST /v1/provcache/invalidate.
|
||||
/// </summary>
|
||||
public sealed class ProvcacheInvalidateRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// The invalidation type. If null, defaults to exact VeriKey match.
|
||||
/// </summary>
|
||||
public InvalidationType? Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The value to match for invalidation.
|
||||
/// For VeriKey type: exact VeriKey.
|
||||
/// For PolicyHash type: policy hash to match.
|
||||
/// For Pattern type: glob pattern.
|
||||
/// </summary>
|
||||
public required string Value { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason for invalidation (for audit log).
|
||||
/// </summary>
|
||||
public string? Reason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Actor performing the invalidation (for audit log).
|
||||
/// </summary>
|
||||
public string? Actor { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response model for POST /v1/provcache/invalidate.
|
||||
/// </summary>
|
||||
public sealed class ProvcacheInvalidateResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Number of entries affected by the invalidation.
|
||||
/// </summary>
|
||||
public long EntriesAffected { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The invalidation type that was used.
|
||||
/// </summary>
|
||||
public required string Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The value that was matched.
|
||||
/// </summary>
|
||||
public required string Value { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Reason for invalidation if provided.
|
||||
/// </summary>
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response model for GET /v1/provcache/metrics.
|
||||
/// </summary>
|
||||
public sealed class ProvcacheMetricsResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Total number of cache requests.
|
||||
/// </summary>
|
||||
public long TotalRequests { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total number of cache hits.
|
||||
/// </summary>
|
||||
public long TotalHits { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total number of cache misses.
|
||||
/// </summary>
|
||||
public long TotalMisses { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Total number of invalidations performed.
|
||||
/// </summary>
|
||||
public long TotalInvalidations { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Cache hit rate (0.0 - 1.0).
|
||||
/// </summary>
|
||||
public double HitRate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current number of entries in the cache.
|
||||
/// </summary>
|
||||
public long CurrentEntryCount { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Average lookup latency in milliseconds.
|
||||
/// </summary>
|
||||
public double AvgLatencyMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 99th percentile lookup latency in milliseconds.
|
||||
/// </summary>
|
||||
public double P99LatencyMs { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the Valkey cache layer is healthy.
|
||||
/// </summary>
|
||||
public bool ValkeyCacheHealthy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the Postgres repository layer is healthy.
|
||||
/// </summary>
|
||||
public bool PostgresRepositoryHealthy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When these metrics were collected.
|
||||
/// </summary>
|
||||
public DateTimeOffset CollectedAt { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Additional invalidation type for direct VeriKey invalidation via API.
|
||||
/// </summary>
|
||||
internal static class InvalidationTypeExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Direct VeriKey invalidation type constant.
|
||||
/// </summary>
|
||||
public const string VeriKey = "VeriKey";
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace StellaOps.Provcache.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Marker class for logging in Provcache API endpoints.
|
||||
/// </summary>
|
||||
public sealed class ProvcacheApiEndpoints;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for mapping Provcache API endpoints.
|
||||
/// </summary>
|
||||
public static class ProvcacheEndpointExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps Provcache API endpoints to the specified route builder.
|
||||
/// </summary>
|
||||
/// <param name="endpoints">The endpoint route builder.</param>
|
||||
/// <param name="prefix">The route prefix (default: "/v1/provcache").</param>
|
||||
/// <returns>A route group builder for further customization.</returns>
|
||||
public static RouteGroupBuilder MapProvcacheEndpoints(
|
||||
this IEndpointRouteBuilder endpoints,
|
||||
string prefix = "/v1/provcache")
|
||||
{
|
||||
var group = endpoints.MapGroup(prefix)
|
||||
.WithTags("Provcache")
|
||||
.WithOpenApi();
|
||||
|
||||
// GET /v1/provcache/{veriKey}
|
||||
group.MapGet("/{veriKey}", GetByVeriKey)
|
||||
.WithName("GetProvcacheEntry")
|
||||
.WithSummary("Get a cached decision by VeriKey")
|
||||
.WithDescription("Retrieves a cached evaluation decision by its VeriKey. Returns 200 if found, 204 if not cached, 410 if expired.")
|
||||
.Produces<ProvcacheGetResponse>(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status410Gone)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status500InternalServerError);
|
||||
|
||||
// POST /v1/provcache
|
||||
group.MapPost("/", CreateOrUpdate)
|
||||
.WithName("CreateOrUpdateProvcacheEntry")
|
||||
.WithSummary("Store a decision in the cache (idempotent)")
|
||||
.WithDescription("Stores or updates a cached evaluation decision. This operation is idempotent - storing the same VeriKey multiple times is safe.")
|
||||
.Accepts<ProvcacheCreateRequest>("application/json")
|
||||
.Produces<ProvcacheCreateResponse>(StatusCodes.Status201Created)
|
||||
.Produces<ProvcacheCreateResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status500InternalServerError);
|
||||
|
||||
// POST /v1/provcache/invalidate
|
||||
group.MapPost("/invalidate", Invalidate)
|
||||
.WithName("InvalidateProvcacheEntries")
|
||||
.WithSummary("Invalidate cache entries by key or pattern")
|
||||
.WithDescription("Invalidates one or more cache entries. Can invalidate by exact VeriKey, policy hash, signer set hash, feed epoch, or pattern.")
|
||||
.Accepts<ProvcacheInvalidateRequest>("application/json")
|
||||
.Produces<ProvcacheInvalidateResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status500InternalServerError);
|
||||
|
||||
// GET /v1/provcache/metrics
|
||||
group.MapGet("/metrics", GetMetrics)
|
||||
.WithName("GetProvcacheMetrics")
|
||||
.WithSummary("Get cache performance metrics")
|
||||
.WithDescription("Returns current cache metrics including hit rate, miss rate, latency percentiles, and entry counts.")
|
||||
.Produces<ProvcacheMetricsResponse>(StatusCodes.Status200OK)
|
||||
.Produces<ProblemDetails>(StatusCodes.Status500InternalServerError);
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /v1/provcache/{veriKey}
|
||||
/// </summary>
|
||||
private static async Task<IResult> GetByVeriKey(
|
||||
string veriKey,
|
||||
bool? bypassCache,
|
||||
IProvcacheService provcacheService,
|
||||
ILogger<ProvcacheApiEndpoints> logger,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
logger.LogDebug("GET /v1/provcache/{VeriKey}", veriKey);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await provcacheService.GetAsync(veriKey, bypassCache ?? false, cancellationToken);
|
||||
|
||||
return result.Status switch
|
||||
{
|
||||
ProvcacheResultStatus.CacheHit => Results.Ok(new ProvcacheGetResponse
|
||||
{
|
||||
VeriKey = result.Entry!.VeriKey,
|
||||
Entry = result.Entry,
|
||||
Source = result.Source,
|
||||
ElapsedMs = result.ElapsedMs,
|
||||
Status = "hit"
|
||||
}),
|
||||
ProvcacheResultStatus.Bypassed => Results.Ok(new ProvcacheGetResponse
|
||||
{
|
||||
VeriKey = veriKey,
|
||||
Entry = null,
|
||||
Source = null,
|
||||
ElapsedMs = result.ElapsedMs,
|
||||
Status = "bypassed"
|
||||
}),
|
||||
ProvcacheResultStatus.Expired => Results.StatusCode(StatusCodes.Status410Gone),
|
||||
_ => Results.NoContent()
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error getting cache entry for VeriKey {VeriKey}", veriKey);
|
||||
return Results.Problem(
|
||||
detail: ex.Message,
|
||||
statusCode: StatusCodes.Status500InternalServerError,
|
||||
title: "Cache lookup failed");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// POST /v1/provcache
|
||||
/// </summary>
|
||||
private static async Task<IResult> CreateOrUpdate(
|
||||
ProvcacheCreateRequest request,
|
||||
IProvcacheService provcacheService,
|
||||
ILogger<ProvcacheApiEndpoints> logger,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
logger.LogDebug("POST /v1/provcache for VeriKey {VeriKey}", request.Entry?.VeriKey);
|
||||
|
||||
if (request.Entry is null)
|
||||
{
|
||||
return Results.Problem(
|
||||
detail: "Request body must contain a valid entry",
|
||||
statusCode: StatusCodes.Status400BadRequest,
|
||||
title: "Invalid request");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var success = await provcacheService.SetAsync(request.Entry, cancellationToken);
|
||||
|
||||
if (!success)
|
||||
{
|
||||
return Results.Problem(
|
||||
detail: "Failed to store cache entry",
|
||||
statusCode: StatusCodes.Status500InternalServerError,
|
||||
title: "Cache write failed");
|
||||
}
|
||||
|
||||
return Results.Created($"/v1/provcache/{request.Entry.VeriKey}", new ProvcacheCreateResponse
|
||||
{
|
||||
VeriKey = request.Entry.VeriKey,
|
||||
Success = true,
|
||||
ExpiresAt = request.Entry.ExpiresAt
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error storing cache entry for VeriKey {VeriKey}", request.Entry?.VeriKey);
|
||||
return Results.Problem(
|
||||
detail: ex.Message,
|
||||
statusCode: StatusCodes.Status500InternalServerError,
|
||||
title: "Cache write failed");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// POST /v1/provcache/invalidate
|
||||
/// </summary>
|
||||
private static async Task<IResult> Invalidate(
|
||||
ProvcacheInvalidateRequest request,
|
||||
IProvcacheService provcacheService,
|
||||
ILogger<ProvcacheApiEndpoints> logger,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
logger.LogDebug("POST /v1/provcache/invalidate type={Type} value={Value}", request.Type, request.Value);
|
||||
|
||||
try
|
||||
{
|
||||
// If single VeriKey invalidation (Type is null = single VeriKey mode)
|
||||
if (request.Type is null)
|
||||
{
|
||||
var success = await provcacheService.InvalidateAsync(request.Value, request.Reason, cancellationToken);
|
||||
return Results.Ok(new ProvcacheInvalidateResponse
|
||||
{
|
||||
EntriesAffected = success ? 1 : 0,
|
||||
Type = "verikey",
|
||||
Value = request.Value,
|
||||
Reason = request.Reason
|
||||
});
|
||||
}
|
||||
|
||||
// Bulk invalidation
|
||||
var invalidationRequest = new InvalidationRequest
|
||||
{
|
||||
Type = request.Type ?? InvalidationType.Pattern,
|
||||
Value = request.Value,
|
||||
Reason = request.Reason,
|
||||
Actor = request.Actor
|
||||
};
|
||||
|
||||
var result = await provcacheService.InvalidateByAsync(invalidationRequest, cancellationToken);
|
||||
|
||||
return Results.Ok(new ProvcacheInvalidateResponse
|
||||
{
|
||||
EntriesAffected = result.EntriesAffected,
|
||||
Type = request.Type?.ToString() ?? "pattern",
|
||||
Value = request.Value,
|
||||
Reason = request.Reason
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error invalidating cache entries");
|
||||
return Results.Problem(
|
||||
detail: ex.Message,
|
||||
statusCode: StatusCodes.Status500InternalServerError,
|
||||
title: "Cache invalidation failed");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /v1/provcache/metrics
|
||||
/// </summary>
|
||||
private static async Task<IResult> GetMetrics(
|
||||
IProvcacheService provcacheService,
|
||||
ILogger<ProvcacheApiEndpoints> logger,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
logger.LogDebug("GET /v1/provcache/metrics");
|
||||
|
||||
try
|
||||
{
|
||||
var metrics = await provcacheService.GetMetricsAsync(cancellationToken);
|
||||
|
||||
var hitRate = metrics.TotalRequests > 0
|
||||
? (double)metrics.TotalHits / metrics.TotalRequests
|
||||
: 0;
|
||||
|
||||
return Results.Ok(new ProvcacheMetricsResponse
|
||||
{
|
||||
TotalRequests = metrics.TotalRequests,
|
||||
TotalHits = metrics.TotalHits,
|
||||
TotalMisses = metrics.TotalMisses,
|
||||
TotalInvalidations = metrics.TotalInvalidations,
|
||||
HitRate = hitRate,
|
||||
CurrentEntryCount = metrics.CurrentEntryCount,
|
||||
AvgLatencyMs = metrics.AvgLatencyMs,
|
||||
P99LatencyMs = metrics.P99LatencyMs,
|
||||
ValkeyCacheHealthy = metrics.ValkeyCacheHealthy,
|
||||
PostgresRepositoryHealthy = metrics.PostgresRepositoryHealthy,
|
||||
CollectedAt = metrics.CollectedAt
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error getting cache metrics");
|
||||
return Results.Problem(
|
||||
detail: ex.Message,
|
||||
statusCode: StatusCodes.Status500InternalServerError,
|
||||
title: "Metrics retrieval failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Placeholder for problem details when ASP.NET Core's ProblemDetails isn't available.
|
||||
/// </summary>
|
||||
internal sealed class ProblemDetails
|
||||
{
|
||||
public string? Type { get; set; }
|
||||
public string? Title { get; set; }
|
||||
public int? Status { get; set; }
|
||||
public string? Detail { get; set; }
|
||||
public string? Instance { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<RootNamespace>StellaOps.Provcache.Api</RootNamespace>
|
||||
<AssemblyName>StellaOps.Provcache.Api</AssemblyName>
|
||||
<Description>API endpoints for Provcache - Provenance Cache for StellaOps</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../StellaOps.Provcache/StellaOps.Provcache.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user