Frontend gaps fill work. Testing fixes work. Auditing in progress.
This commit is contained in:
@@ -0,0 +1,495 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Platform.WebService.Constants;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.Platform.WebService.Services;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Endpoints;
|
||||
|
||||
public static class PlatformEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapPlatformEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var platform = app.MapGroup("/api/v1/platform")
|
||||
.WithTags("Platform");
|
||||
|
||||
MapHealthEndpoints(platform);
|
||||
MapQuotaEndpoints(platform);
|
||||
MapOnboardingEndpoints(platform);
|
||||
MapPreferencesEndpoints(platform);
|
||||
MapSearchEndpoints(app, platform);
|
||||
MapMetadataEndpoints(platform);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static void MapHealthEndpoints(IEndpointRouteBuilder platform)
|
||||
{
|
||||
var health = platform.MapGroup("/health").WithTags("Platform Health");
|
||||
|
||||
health.MapGet("/summary", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
PlatformHealthService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var result = await service.GetSummaryAsync(requestContext!, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(new PlatformItemResponse<PlatformHealthSummary>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
result.DataAsOf,
|
||||
result.Cached,
|
||||
result.CacheTtlSeconds,
|
||||
result.Value));
|
||||
}).RequireAuthorization(PlatformPolicies.HealthRead);
|
||||
|
||||
health.MapGet("/dependencies", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
PlatformHealthService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var result = await service.GetDependenciesAsync(requestContext!, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(new PlatformListResponse<PlatformDependencyStatus>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
result.DataAsOf,
|
||||
result.Cached,
|
||||
result.CacheTtlSeconds,
|
||||
result.Value,
|
||||
result.Value.Count));
|
||||
}).RequireAuthorization(PlatformPolicies.HealthRead);
|
||||
|
||||
health.MapGet("/incidents", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
PlatformHealthService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var result = await service.GetIncidentsAsync(requestContext!, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(new PlatformListResponse<PlatformIncident>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
result.DataAsOf,
|
||||
result.Cached,
|
||||
result.CacheTtlSeconds,
|
||||
result.Value,
|
||||
result.Value.Count));
|
||||
}).RequireAuthorization(PlatformPolicies.HealthRead);
|
||||
|
||||
health.MapGet("/metrics", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
PlatformHealthService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var result = await service.GetMetricsAsync(requestContext!, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(new PlatformListResponse<PlatformHealthMetric>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
result.DataAsOf,
|
||||
result.Cached,
|
||||
result.CacheTtlSeconds,
|
||||
result.Value,
|
||||
result.Value.Count));
|
||||
}).RequireAuthorization(PlatformPolicies.HealthAdmin);
|
||||
}
|
||||
|
||||
private static void MapQuotaEndpoints(IEndpointRouteBuilder platform)
|
||||
{
|
||||
var quotas = platform.MapGroup("/quotas").WithTags("Platform Quotas");
|
||||
|
||||
quotas.MapGet("/summary", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
PlatformQuotaService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var result = await service.GetSummaryAsync(requestContext!, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(new PlatformListResponse<PlatformQuotaUsage>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
result.DataAsOf,
|
||||
result.Cached,
|
||||
result.CacheTtlSeconds,
|
||||
result.Value,
|
||||
result.Value.Count));
|
||||
}).RequireAuthorization(PlatformPolicies.QuotaRead);
|
||||
|
||||
quotas.MapGet("/tenants/{tenantId}", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
PlatformQuotaService service,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "tenant_missing" });
|
||||
}
|
||||
|
||||
var result = await service.GetTenantAsync(tenantId.Trim().ToLowerInvariant(), cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(new PlatformListResponse<PlatformQuotaUsage>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
result.DataAsOf,
|
||||
result.Cached,
|
||||
result.CacheTtlSeconds,
|
||||
result.Value,
|
||||
result.Value.Count));
|
||||
}).RequireAuthorization(PlatformPolicies.QuotaRead);
|
||||
|
||||
quotas.MapGet("/alerts", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
PlatformQuotaService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var result = await service.GetAlertsAsync(requestContext!, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(new PlatformListResponse<PlatformQuotaAlert>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
result.DataAsOf,
|
||||
result.Cached,
|
||||
result.CacheTtlSeconds,
|
||||
result.Value,
|
||||
result.Value.Count));
|
||||
}).RequireAuthorization(PlatformPolicies.QuotaRead);
|
||||
|
||||
quotas.MapPost("/alerts", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
PlatformQuotaService service,
|
||||
PlatformQuotaAlertRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var alert = await service.CreateAlertAsync(requestContext!, request, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Created($"/api/v1/platform/quotas/alerts/{alert.AlertId}", alert);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}).RequireAuthorization(PlatformPolicies.QuotaAdmin);
|
||||
}
|
||||
|
||||
private static void MapOnboardingEndpoints(IEndpointRouteBuilder platform)
|
||||
{
|
||||
var onboarding = platform.MapGroup("/onboarding").WithTags("Platform Onboarding");
|
||||
|
||||
onboarding.MapGet("/status", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
PlatformOnboardingService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var state = await service.GetStatusAsync(requestContext!, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(state);
|
||||
}).RequireAuthorization(PlatformPolicies.OnboardingRead);
|
||||
|
||||
onboarding.MapPost("/complete/{step}", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
PlatformOnboardingService service,
|
||||
string step,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var state = await service.CompleteStepAsync(requestContext!, step, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(state);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}).RequireAuthorization(PlatformPolicies.OnboardingWrite);
|
||||
|
||||
onboarding.MapPost("/skip", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
PlatformOnboardingService service,
|
||||
PlatformOnboardingSkipRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var state = await service.SkipAsync(requestContext!, request?.Reason, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(state);
|
||||
}).RequireAuthorization(PlatformPolicies.OnboardingWrite);
|
||||
|
||||
platform.MapGet("/tenants/{tenantId}/setup-status", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
PlatformOnboardingService service,
|
||||
string tenantId,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
{
|
||||
return Results.BadRequest(new { error = "tenant_missing" });
|
||||
}
|
||||
|
||||
var status = await service.GetTenantSetupStatusAsync(tenantId.Trim().ToLowerInvariant(), cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(status);
|
||||
}).RequireAuthorization(PlatformPolicies.OnboardingRead);
|
||||
}
|
||||
|
||||
private static void MapPreferencesEndpoints(IEndpointRouteBuilder platform)
|
||||
{
|
||||
var preferences = platform.MapGroup("/preferences").WithTags("Platform Preferences");
|
||||
|
||||
preferences.MapGet("/dashboard", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
PlatformPreferencesService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var prefs = await service.GetPreferencesAsync(requestContext!, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(prefs);
|
||||
}).RequireAuthorization(PlatformPolicies.PreferencesRead);
|
||||
|
||||
preferences.MapPut("/dashboard", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
PlatformPreferencesService service,
|
||||
PlatformDashboardPreferencesRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var prefs = await service.UpsertPreferencesAsync(requestContext!, request, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(prefs);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}).RequireAuthorization(PlatformPolicies.PreferencesWrite);
|
||||
|
||||
var profiles = platform.MapGroup("/dashboard/profiles").WithTags("Platform Preferences");
|
||||
|
||||
profiles.MapGet("/", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
PlatformPreferencesService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var items = await service.GetProfilesAsync(requestContext!, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(items);
|
||||
}).RequireAuthorization(PlatformPolicies.PreferencesRead);
|
||||
|
||||
profiles.MapGet("/{profileId}", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
PlatformPreferencesService service,
|
||||
string profileId,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var profile = await service.GetProfileAsync(requestContext!, profileId, cancellationToken).ConfigureAwait(false);
|
||||
return profile is null ? Results.NotFound() : Results.Ok(profile);
|
||||
}).RequireAuthorization(PlatformPolicies.PreferencesRead);
|
||||
|
||||
profiles.MapPost("/", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
PlatformPreferencesService service,
|
||||
PlatformDashboardProfileRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var profile = await service.CreateProfileAsync(requestContext!, request, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Created($"/api/v1/platform/dashboard/profiles/{profile.ProfileId}", profile);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}).RequireAuthorization(PlatformPolicies.PreferencesWrite);
|
||||
}
|
||||
|
||||
private static void MapSearchEndpoints(IEndpointRouteBuilder app, IEndpointRouteBuilder platform)
|
||||
{
|
||||
var searchGroup = platform.MapGroup("/search").WithTags("Platform Search");
|
||||
async Task<IResult> HandleSearch(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
PlatformSearchService service,
|
||||
[AsParameters] SearchQuery query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var sources = query.Sources
|
||||
?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.ToArray();
|
||||
|
||||
var result = await service.SearchAsync(
|
||||
requestContext!,
|
||||
query.Query,
|
||||
sources,
|
||||
query.Limit,
|
||||
query.Offset,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Ok(new PlatformListResponse<PlatformSearchItem>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
result.DataAsOf,
|
||||
result.Cached,
|
||||
result.CacheTtlSeconds,
|
||||
result.Value.Items,
|
||||
result.Value.Total,
|
||||
result.Value.Limit,
|
||||
result.Value.Offset,
|
||||
result.Value.Query));
|
||||
}
|
||||
|
||||
searchGroup.MapGet("/", HandleSearch).RequireAuthorization(PlatformPolicies.SearchRead);
|
||||
|
||||
app.MapGet("/api/v1/search", HandleSearch)
|
||||
.WithTags("Platform Search")
|
||||
.RequireAuthorization(PlatformPolicies.SearchRead);
|
||||
}
|
||||
|
||||
private static void MapMetadataEndpoints(IEndpointRouteBuilder platform)
|
||||
{
|
||||
platform.MapGet("/metadata", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
PlatformMetadataService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var result = await service.GetMetadataAsync(requestContext!, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(new PlatformItemResponse<PlatformMetadata>(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
result.DataAsOf,
|
||||
result.Cached,
|
||||
result.CacheTtlSeconds,
|
||||
result.Value));
|
||||
}).RequireAuthorization(PlatformPolicies.MetadataRead);
|
||||
}
|
||||
|
||||
private static bool TryResolveContext(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
out PlatformRequestContext? requestContext,
|
||||
out IResult? failure)
|
||||
{
|
||||
if (resolver.TryResolve(context, out requestContext, out var error))
|
||||
{
|
||||
failure = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
failure = Results.BadRequest(new { error = error ?? "tenant_missing" });
|
||||
return false;
|
||||
}
|
||||
|
||||
private sealed record SearchQuery(
|
||||
[FromQuery(Name = "q")] string? Query,
|
||||
string? Sources,
|
||||
int? Limit,
|
||||
int? Offset);
|
||||
}
|
||||
Reference in New Issue
Block a user