using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.Options;
using StellaOps.Policy.Registry.Contracts;
namespace StellaOps.Policy.Registry;
///
/// HTTP client implementation for Policy Registry API.
///
public sealed class PolicyRegistryClient : IPolicyRegistryClient
{
private readonly HttpClient _httpClient;
private readonly JsonSerializerOptions _jsonOptions;
public PolicyRegistryClient(HttpClient httpClient, IOptions? options = null)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
PropertyNameCaseInsensitive = true
};
if (options?.Value?.BaseUrl is not null && _httpClient.BaseAddress is null)
{
_httpClient.BaseAddress = new Uri(options.Value.BaseUrl);
}
}
private static void AddTenantHeader(HttpRequestMessage request, Guid tenantId)
{
request.Headers.Add("X-Tenant-Id", tenantId.ToString());
}
private static string BuildQueryString(PaginationParams? pagination, params (string name, string? value)[] additional)
{
var parts = new List();
if (pagination is not null)
{
if (pagination.PageSize != 20)
{
parts.Add($"page_size={pagination.PageSize}");
}
if (!string.IsNullOrWhiteSpace(pagination.PageToken))
{
parts.Add($"page_token={Uri.EscapeDataString(pagination.PageToken)}");
}
}
foreach (var (name, value) in additional)
{
if (!string.IsNullOrWhiteSpace(value))
{
parts.Add($"{name}={Uri.EscapeDataString(value)}");
}
}
return parts.Count > 0 ? "?" + string.Join("&", parts) : string.Empty;
}
// ============================================================
// VERIFICATION POLICY OPERATIONS
// ============================================================
public async Task ListVerificationPoliciesAsync(
Guid tenantId,
PaginationParams? pagination = null,
CancellationToken cancellationToken = default)
{
var query = BuildQueryString(pagination);
using var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/policy/verification-policies{query}");
AddTenantHeader(request, tenantId);
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
public async Task CreateVerificationPolicyAsync(
Guid tenantId,
CreateVerificationPolicyRequest request,
CancellationToken cancellationToken = default)
{
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/policy/verification-policies");
AddTenantHeader(httpRequest, tenantId);
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
public async Task GetVerificationPolicyAsync(
Guid tenantId,
string policyId,
CancellationToken cancellationToken = default)
{
using var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/policy/verification-policies/{Uri.EscapeDataString(policyId)}");
AddTenantHeader(request, tenantId);
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
public async Task UpdateVerificationPolicyAsync(
Guid tenantId,
string policyId,
UpdateVerificationPolicyRequest request,
CancellationToken cancellationToken = default)
{
using var httpRequest = new HttpRequestMessage(HttpMethod.Put, $"/api/v1/policy/verification-policies/{Uri.EscapeDataString(policyId)}");
AddTenantHeader(httpRequest, tenantId);
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
public async Task DeleteVerificationPolicyAsync(
Guid tenantId,
string policyId,
CancellationToken cancellationToken = default)
{
using var request = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/policy/verification-policies/{Uri.EscapeDataString(policyId)}");
AddTenantHeader(request, tenantId);
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
}
// ============================================================
// POLICY PACK OPERATIONS
// ============================================================
public async Task ListPolicyPacksAsync(
Guid tenantId,
PolicyPackStatus? status = null,
PaginationParams? pagination = null,
CancellationToken cancellationToken = default)
{
var query = BuildQueryString(pagination, ("status", status?.ToString().ToLowerInvariant()));
using var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/policy/packs{query}");
AddTenantHeader(request, tenantId);
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
public async Task CreatePolicyPackAsync(
Guid tenantId,
CreatePolicyPackRequest request,
CancellationToken cancellationToken = default)
{
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/policy/packs");
AddTenantHeader(httpRequest, tenantId);
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
public async Task GetPolicyPackAsync(
Guid tenantId,
Guid packId,
CancellationToken cancellationToken = default)
{
using var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/policy/packs/{packId}");
AddTenantHeader(request, tenantId);
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
public async Task UpdatePolicyPackAsync(
Guid tenantId,
Guid packId,
UpdatePolicyPackRequest request,
CancellationToken cancellationToken = default)
{
using var httpRequest = new HttpRequestMessage(HttpMethod.Put, $"/api/v1/policy/packs/{packId}");
AddTenantHeader(httpRequest, tenantId);
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
public async Task DeletePolicyPackAsync(
Guid tenantId,
Guid packId,
CancellationToken cancellationToken = default)
{
using var request = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/policy/packs/{packId}");
AddTenantHeader(request, tenantId);
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
}
public async Task CompilePolicyPackAsync(
Guid tenantId,
Guid packId,
CancellationToken cancellationToken = default)
{
using var request = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/policy/packs/{packId}/compile");
AddTenantHeader(request, tenantId);
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
// Note: 422 also returns CompilationResult, so we read regardless of status
return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
public async Task SimulatePolicyPackAsync(
Guid tenantId,
Guid packId,
SimulationRequest request,
CancellationToken cancellationToken = default)
{
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/policy/packs/{packId}/simulate");
AddTenantHeader(httpRequest, tenantId);
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
public async Task PublishPolicyPackAsync(
Guid tenantId,
Guid packId,
PublishRequest? request = null,
CancellationToken cancellationToken = default)
{
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/policy/packs/{packId}/publish");
AddTenantHeader(httpRequest, tenantId);
if (request is not null)
{
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
}
var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
public async Task PromotePolicyPackAsync(
Guid tenantId,
Guid packId,
PromoteRequest? request = null,
CancellationToken cancellationToken = default)
{
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/policy/packs/{packId}/promote");
AddTenantHeader(httpRequest, tenantId);
if (request is not null)
{
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
}
var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
// ============================================================
// SNAPSHOT OPERATIONS
// ============================================================
public async Task ListSnapshotsAsync(
Guid tenantId,
PaginationParams? pagination = null,
CancellationToken cancellationToken = default)
{
var query = BuildQueryString(pagination);
using var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/policy/snapshots{query}");
AddTenantHeader(request, tenantId);
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
public async Task CreateSnapshotAsync(
Guid tenantId,
CreateSnapshotRequest request,
CancellationToken cancellationToken = default)
{
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/policy/snapshots");
AddTenantHeader(httpRequest, tenantId);
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
public async Task GetSnapshotAsync(
Guid tenantId,
Guid snapshotId,
CancellationToken cancellationToken = default)
{
using var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/policy/snapshots/{snapshotId}");
AddTenantHeader(request, tenantId);
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
public async Task DeleteSnapshotAsync(
Guid tenantId,
Guid snapshotId,
CancellationToken cancellationToken = default)
{
using var request = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/policy/snapshots/{snapshotId}");
AddTenantHeader(request, tenantId);
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
}
public async Task GetSnapshotByDigestAsync(
Guid tenantId,
string digest,
CancellationToken cancellationToken = default)
{
using var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/policy/snapshots/by-digest/{Uri.EscapeDataString(digest)}");
AddTenantHeader(request, tenantId);
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
// ============================================================
// VIOLATION OPERATIONS
// ============================================================
public async Task ListViolationsAsync(
Guid tenantId,
Severity? severity = null,
PaginationParams? pagination = null,
CancellationToken cancellationToken = default)
{
var query = BuildQueryString(pagination, ("severity", severity?.ToString().ToLowerInvariant()));
using var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/policy/violations{query}");
AddTenantHeader(request, tenantId);
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
public async Task AppendViolationAsync(
Guid tenantId,
CreateViolationRequest request,
CancellationToken cancellationToken = default)
{
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/policy/violations");
AddTenantHeader(httpRequest, tenantId);
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
public async Task AppendViolationBatchAsync(
Guid tenantId,
ViolationBatchRequest request,
CancellationToken cancellationToken = default)
{
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/policy/violations/batch");
AddTenantHeader(httpRequest, tenantId);
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
public async Task GetViolationAsync(
Guid tenantId,
Guid violationId,
CancellationToken cancellationToken = default)
{
using var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/policy/violations/{violationId}");
AddTenantHeader(request, tenantId);
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
// ============================================================
// OVERRIDE OPERATIONS
// ============================================================
public async Task CreateOverrideAsync(
Guid tenantId,
CreateOverrideRequest request,
CancellationToken cancellationToken = default)
{
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/policy/overrides");
AddTenantHeader(httpRequest, tenantId);
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
public async Task GetOverrideAsync(
Guid tenantId,
Guid overrideId,
CancellationToken cancellationToken = default)
{
using var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/policy/overrides/{overrideId}");
AddTenantHeader(request, tenantId);
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
public async Task DeleteOverrideAsync(
Guid tenantId,
Guid overrideId,
CancellationToken cancellationToken = default)
{
using var request = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/policy/overrides/{overrideId}");
AddTenantHeader(request, tenantId);
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
}
public async Task ApproveOverrideAsync(
Guid tenantId,
Guid overrideId,
ApproveOverrideRequest? request = null,
CancellationToken cancellationToken = default)
{
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/policy/overrides/{overrideId}:approve");
AddTenantHeader(httpRequest, tenantId);
if (request is not null)
{
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
}
var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
public async Task DisableOverrideAsync(
Guid tenantId,
Guid overrideId,
CancellationToken cancellationToken = default)
{
using var request = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/policy/overrides/{overrideId}:disable");
AddTenantHeader(request, tenantId);
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
// ============================================================
// SEALED MODE OPERATIONS
// ============================================================
public async Task GetSealedModeStatusAsync(
Guid tenantId,
CancellationToken cancellationToken = default)
{
using var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/policy/sealed-mode/status");
AddTenantHeader(request, tenantId);
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
public async Task SealAsync(
Guid tenantId,
SealRequest? request = null,
CancellationToken cancellationToken = default)
{
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/policy/sealed-mode/seal");
AddTenantHeader(httpRequest, tenantId);
if (request is not null)
{
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
}
var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
public async Task UnsealAsync(
Guid tenantId,
UnsealRequest request,
CancellationToken cancellationToken = default)
{
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/policy/sealed-mode/unseal");
AddTenantHeader(httpRequest, tenantId);
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
public async Task VerifyBundleAsync(
Guid tenantId,
VerifyBundleRequest request,
CancellationToken cancellationToken = default)
{
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/policy/sealed-mode/verify");
AddTenantHeader(httpRequest, tenantId);
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
// ============================================================
// STALENESS OPERATIONS
// ============================================================
public async Task GetStalenessStatusAsync(
Guid tenantId,
CancellationToken cancellationToken = default)
{
using var request = new HttpRequestMessage(HttpMethod.Get, "/api/v1/policy/staleness/status");
AddTenantHeader(request, tenantId);
var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
public async Task EvaluateStalenessAsync(
Guid tenantId,
EvaluateStalenessRequest request,
CancellationToken cancellationToken = default)
{
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/policy/staleness/evaluate");
AddTenantHeader(httpRequest, tenantId);
httpRequest.Content = JsonContent.Create(request, options: _jsonOptions);
var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync(_jsonOptions, cancellationToken).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize response");
}
}
///
/// Configuration options for Policy Registry client.
///
public sealed class PolicyRegistryClientOptions
{
public string? BaseUrl { get; set; }
}