Files
git.stella-ops.org/src/Scanner/StellaOps.Scanner.WebService/Services/ConcelierHttpLinksetQueryService.cs
2026-02-01 21:37:40 +02:00

174 lines
6.0 KiB
C#

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<ConcelierLinksetOptions> options)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
}
public async Task<AdvisoryLinksetQueryResult> QueryAsync(AdvisoryLinksetQueryOptions options, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(options);
cancellationToken.ThrowIfCancellationRequested();
if (options.AdvisoryIds is null)
{
return new AdvisoryLinksetQueryResult(ImmutableArray<AdvisoryLinkset>.Empty, null, false);
}
var results = ImmutableArray.CreateBuilder<AdvisoryLinkset>();
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<LinksetDetailResponse>(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<string>.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<string>? Purls { get; init; }
[JsonPropertyName("cpes")]
public IReadOnlyList<string>? Cpes { get; init; }
[JsonPropertyName("versions")]
public IReadOnlyList<string>? Versions { get; init; }
[JsonPropertyName("ranges")]
public IReadOnlyList<Dictionary<string, object?>>? Ranges { get; init; }
[JsonPropertyName("severities")]
public IReadOnlyList<Dictionary<string, object?>>? 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<string>? Values { get; init; }
[JsonPropertyName("sourceIds")]
public IReadOnlyList<string>? SourceIds { get; init; }
}
}