using Microsoft.Extensions.Options; using StellaOps.Concelier.Core.Linksets; using StellaOps.Scanner.WebService.Options; using System.Collections.Immutable; using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Serialization; namespace StellaOps.Scanner.WebService.Services; internal sealed class ConcelierHttpLinksetQueryService : IAdvisoryLinksetQueryService { private readonly HttpClient _client; private readonly ConcelierLinksetOptions _options; private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, PropertyNameCaseInsensitive = true }; public ConcelierHttpLinksetQueryService(HttpClient client, IOptions options) { _client = client ?? throw new ArgumentNullException(nameof(client)); _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); } public async Task QueryAsync(AdvisoryLinksetQueryOptions options, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(options); cancellationToken.ThrowIfCancellationRequested(); if (options.AdvisoryIds is null) { return new AdvisoryLinksetQueryResult(ImmutableArray.Empty, null, false); } var results = ImmutableArray.CreateBuilder(); foreach (var advisoryId in options.AdvisoryIds) { if (string.IsNullOrWhiteSpace(advisoryId)) { continue; } var path = $"/v1/lnm/linksets/{Uri.EscapeDataString(advisoryId)}?tenant={Uri.EscapeDataString(options.Tenant)}&includeConflicts=true&includeObservations=false&includeTimeline=false"; try { using var response = await _client.GetAsync(path, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { continue; } var payload = await response.Content.ReadFromJsonAsync(SerializerOptions, cancellationToken).ConfigureAwait(false); if (payload?.Linksets is null || payload.Linksets.Length == 0) { continue; } foreach (var linkset in payload.Linksets) { results.Add(Map(linkset)); } } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch { // swallow and continue; caller will see partial results } } var linksets = results.ToImmutable(); return new AdvisoryLinksetQueryResult(linksets, null, false); } private static AdvisoryLinkset Map(LinksetDto dto) { var normalized = dto.Normalized is null ? null : new AdvisoryLinksetNormalized( dto.Normalized.Purls, dto.Normalized.Cpes, dto.Normalized.Versions, dto.Normalized.Ranges, dto.Normalized.Severities); var conflicts = dto.Conflicts is null ? null : dto.Conflicts.Select(c => new AdvisoryLinksetConflict(c.Field, c.Reason ?? string.Empty, c.Values, c.SourceIds)).ToList(); return new AdvisoryLinkset( TenantId: dto.Tenant ?? string.Empty, Source: dto.Source ?? string.Empty, AdvisoryId: dto.AdvisoryId ?? string.Empty, ObservationIds: dto.ObservationIds?.ToImmutableArray() ?? ImmutableArray.Empty, Normalized: normalized, Provenance: null, Confidence: dto.Confidence, Conflicts: conflicts, CreatedAt: dto.CreatedAt ?? DateTimeOffset.MinValue, BuiltByJobId: dto.BuiltByJobId); } private sealed record LinksetDetailResponse([property: JsonPropertyName("linksets")] LinksetDto[] Linksets); private sealed record LinksetDto { [JsonPropertyName("advisoryId")] public string? AdvisoryId { get; init; } [JsonPropertyName("source")] public string? Source { get; init; } [JsonPropertyName("tenant")] public string? Tenant { get; init; } [JsonPropertyName("confidence")] public double? Confidence { get; init; } [JsonPropertyName("createdAt")] public DateTimeOffset? CreatedAt { get; init; } [JsonPropertyName("builtByJobId")] public string? BuiltByJobId { get; init; } [JsonPropertyName("observationIds")] public string[]? ObservationIds { get; init; } [JsonPropertyName("normalized")] public LinksetNormalizedDto? Normalized { get; init; } [JsonPropertyName("conflicts")] public LinksetConflictDto[]? Conflicts { get; init; } } private sealed record LinksetNormalizedDto { [JsonPropertyName("purls")] public IReadOnlyList? Purls { get; init; } [JsonPropertyName("cpes")] public IReadOnlyList? Cpes { get; init; } [JsonPropertyName("versions")] public IReadOnlyList? Versions { get; init; } [JsonPropertyName("ranges")] public IReadOnlyList>? Ranges { get; init; } [JsonPropertyName("severities")] public IReadOnlyList>? Severities { get; init; } } private sealed record LinksetConflictDto { [JsonPropertyName("field")] public string Field { get; init; } = string.Empty; [JsonPropertyName("reason")] public string? Reason { get; init; } [JsonPropertyName("values")] public IReadOnlyList? Values { get; init; } [JsonPropertyName("sourceIds")] public IReadOnlyList? SourceIds { get; init; } } }