Add OpenAPI specification for Link-Not-Merge Policy APIs
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Introduced a new OpenAPI YAML file for the StellaOps Concelier service.
- Defined endpoints for listing linksets, retrieving linksets by advisory ID, and searching linksets.
- Included detailed parameter specifications and response schemas for each endpoint.
- Established components for reusable parameters and schemas, enhancing API documentation clarity.
This commit is contained in:
StellaOps Bot
2025-11-22 23:39:01 +02:00
parent 48702191be
commit 2e89a92d92
13 changed files with 938 additions and 49 deletions

View File

@@ -118,6 +118,7 @@ builder.Services.AddOptions<AdvisoryObservationEventPublisherOptions>()
.ValidateOnStart();
builder.Services.AddConcelierAocGuards();
builder.Services.AddConcelierLinksetMappers();
builder.Services.TryAddSingleton<IAdvisoryLinksetQueryService, AdvisoryLinksetQueryService>();
builder.Services.AddSingleton<IMeterFactory>(MeterProvider.Default.GetMeterProvider());
builder.Services.AddSingleton<LinksetCacheTelemetry>();
builder.Services.AddAdvisoryRawServices();
@@ -619,14 +620,17 @@ var observationsEndpoint = app.MapGet("/concelier/observations", async (
return Results.Ok(response);
}).WithName("GetConcelierObservations");
app.MapGet("/v1/lnm/linksets/{advisoryId}", async (
const int DefaultLnmPageSize = 50;
const int MaxLnmPageSize = 200;
app.MapGet("/v1/lnm/linksets", async (
HttpContext context,
string advisoryId,
[FromQuery(Name = "source")] string source,
[FromQuery(Name = "includeConflicts")] bool includeConflicts,
[FromServices] IAdvisoryLinksetLookup linksetLookup,
[FromServices] LinksetCacheTelemetry telemetry,
TimeProvider timeProvider,
[FromQuery(Name = "advisoryId")] string? advisoryId,
[FromQuery(Name = "source")] string? source,
[FromQuery(Name = "page")] int? page,
[FromQuery(Name = "pageSize")] int? pageSize,
[FromQuery(Name = "includeConflicts")] bool? includeConflicts,
[FromServices] IAdvisoryLinksetQueryService queryService,
CancellationToken cancellationToken) =>
{
ApplyNoCache(context.Response);
@@ -642,36 +646,116 @@ app.MapGet("/v1/lnm/linksets/{advisoryId}", async (
return authorizationError;
}
if (string.IsNullOrWhiteSpace(advisoryId) || string.IsNullOrWhiteSpace(source))
var resolvedPage = NormalizePage(page);
var resolvedPageSize = NormalizePageSize(pageSize);
var advisoryIds = string.IsNullOrWhiteSpace(advisoryId) ? null : new[] { advisoryId.Trim() };
var sources = string.IsNullOrWhiteSpace(source) ? null : new[] { source.Trim() };
var result = await QueryPageAsync(
queryService,
tenant!,
advisoryIds,
sources,
resolvedPage,
resolvedPageSize,
cancellationToken).ConfigureAwait(false);
var items = result.Items
.Select(linkset => ToLnmResponse(linkset, includeConflicts.GetValueOrDefault(true), includeTimeline: false, includeObservations: false))
.ToArray();
return Results.Ok(new LnmLinksetPage(items, resolvedPage, resolvedPageSize, result.Total));
}).WithName("ListLnmLinksets");
app.MapPost("/v1/lnm/linksets/search", async (
HttpContext context,
[FromBody] LnmLinksetSearchRequest request,
[FromServices] IAdvisoryLinksetQueryService queryService,
CancellationToken cancellationToken) =>
{
ApplyNoCache(context.Response);
if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError))
{
return Results.BadRequest("advisoryId and source are required.");
return tenantError;
}
var authorizationError = EnsureTenantAuthorized(context, tenant);
if (authorizationError is not null)
{
return authorizationError;
}
var resolvedPage = NormalizePage(request.Page);
var resolvedPageSize = NormalizePageSize(request.PageSize);
var advisoryIds = string.IsNullOrWhiteSpace(request.AdvisoryId) ? null : new[] { request.AdvisoryId.Trim() };
var sources = string.IsNullOrWhiteSpace(request.Source) ? null : new[] { request.Source.Trim() };
var result = await QueryPageAsync(
queryService,
tenant!,
advisoryIds,
sources,
resolvedPage,
resolvedPageSize,
cancellationToken).ConfigureAwait(false);
var items = result.Items
.Select(linkset => ToLnmResponse(
linkset,
includeConflicts: true,
includeTimeline: request.IncludeTimeline,
includeObservations: request.IncludeObservations))
.ToArray();
return Results.Ok(new LnmLinksetPage(items, resolvedPage, resolvedPageSize, result.Total));
}).WithName("SearchLnmLinksets");
app.MapGet("/v1/lnm/linksets/{advisoryId}", async (
HttpContext context,
string advisoryId,
[FromQuery(Name = "source")] string? source,
[FromQuery(Name = "includeConflicts")] bool includeConflicts = true,
[FromQuery(Name = "includeObservations")] bool includeObservations = false,
[FromServices] IAdvisoryLinksetQueryService queryService,
[FromServices] LinksetCacheTelemetry telemetry,
CancellationToken cancellationToken) =>
{
ApplyNoCache(context.Response);
if (!TryResolveTenant(context, requireHeader: true, out var tenant, out var tenantError))
{
return tenantError;
}
var authorizationError = EnsureTenantAuthorized(context, tenant);
if (authorizationError is not null)
{
return authorizationError;
}
if (string.IsNullOrWhiteSpace(advisoryId))
{
return Results.BadRequest("advisoryId is required.");
}
var stopwatch = Stopwatch.StartNew();
var advisoryIds = new[] { advisoryId.Trim() };
var sources = string.IsNullOrWhiteSpace(source) ? null : new[] { source.Trim() };
var options = new AdvisoryLinksetQueryOptions(tenant!, Source: source.Trim(), AdvisoryId: advisoryId.Trim());
var linksets = await linksetLookup.FindByTenantAsync(options.TenantId, options.Source, options.AdvisoryId, cancellationToken).ConfigureAwait(false);
var result = await queryService
.QueryAsync(new AdvisoryLinksetQueryOptions(tenant!, advisoryIds, sources, limit: 1), cancellationToken)
.ConfigureAwait(false);
if (linksets.Count == 0)
if (result.Linksets.IsDefaultOrEmpty)
{
return Results.NotFound();
}
var linkset = linksets[0];
var response = new LnmLinksetResponse(
linkset.AdvisoryId,
linkset.Source,
linkset.Observations,
linkset.Normalized is null
? null
: new LnmLinksetNormalized(linkset.Normalized.Purls, linkset.Normalized.Versions, linkset.Normalized.Ranges, linkset.Normalized.Severities),
includeConflicts ? linkset.Conflicts : Array.Empty<LnmLinksetConflict>(),
linkset.Provenance is null
? null
: new LnmLinksetProvenance(linkset.Provenance.ObservationHashes, linkset.Provenance.ToolVersion, linkset.Provenance.PolicyHash),
linkset.CreatedAt,
linkset.BuiltByJobId,
Cached: true);
var linkset = result.Linksets[0];
var response = ToLnmResponse(linkset, includeConflicts, includeTimeline: false, includeObservations: includeObservations);
telemetry.RecordHit(tenant, linkset.Source);
telemetry.RecordRebuild(tenant, linkset.Source, stopwatch.Elapsed.TotalMilliseconds);
@@ -679,6 +763,45 @@ app.MapGet("/v1/lnm/linksets/{advisoryId}", async (
return Results.Ok(response);
}).WithName("GetLnmLinkset");
app.MapGet("/linksets", async (
HttpContext context,
[FromQuery(Name = "limit")] int? limit,
[FromQuery(Name = "cursor")] string? cursor,
[FromServices] IAdvisoryLinksetQueryService queryService,
CancellationToken cancellationToken) =>
{
ApplyNoCache(context.Response);
if (!TryResolveTenant(context, requireHeader: false, out var tenant, out var tenantError))
{
return tenantError;
}
var authorizationError = EnsureTenantAuthorized(context, tenant);
if (authorizationError is not null)
{
return authorizationError;
}
var result = await queryService.QueryAsync(
new AdvisoryLinksetQueryOptions(tenant, Limit: limit, Cursor: cursor),
cancellationToken).ConfigureAwait(false);
var payload = new
{
linksets = result.Linksets.Select(ls => new
{
AdvisoryId = ls.AdvisoryId,
Purls = ls.Normalized?.Purls ?? Array.Empty<string>(),
Versions = ls.Normalized?.Versions ?? Array.Empty<string>()
}),
hasMore = result.HasMore,
nextCursor = result.NextCursor
};
return Results.Ok(payload);
}).WithName("ListLinksetsLegacy");
if (authorityConfigured)
{
observationsEndpoint.RequireAuthorization(ObservationsPolicyName);
@@ -1453,6 +1576,120 @@ if (authorityConfigured)
});
}
int NormalizePage(int? pageValue)
{
if (!pageValue.HasValue || pageValue.Value <= 0)
{
return 1;
}
return pageValue.Value;
}
int NormalizePageSize(int? size)
{
if (!size.HasValue || size.Value <= 0)
{
return DefaultLnmPageSize;
}
return size.Value > MaxLnmPageSize ? MaxLnmPageSize : size.Value;
}
async Task<(IReadOnlyList<AdvisoryLinkset> Items, int? Total)> QueryPageAsync(
IAdvisoryLinksetQueryService queryService,
string tenant,
IEnumerable<string>? advisoryIds,
IEnumerable<string>? sources,
int page,
int pageSize,
CancellationToken cancellationToken)
{
var cursor = (string?)null;
AdvisoryLinksetQueryResult? result = null;
for (var current = 1; current <= page; current++)
{
result = await queryService
.QueryAsync(new AdvisoryLinksetQueryOptions(tenant, advisoryIds, sources, pageSize, cursor), cancellationToken)
.ConfigureAwait(false);
if (!result.HasMore && current < page)
{
var exhaustedTotal = ((current - 1) * pageSize) + result.Linksets.Length;
return (Array.Empty<AdvisoryLinkset>(), exhaustedTotal);
}
cursor = result.NextCursor;
}
if (result is null)
{
return (Array.Empty<AdvisoryLinkset>(), 0);
}
var total = result.HasMore ? null : (int?)(((page - 1) * pageSize) + result.Linksets.Length);
return (result.Linksets, total);
}
LnmLinksetResponse ToLnmResponse(
AdvisoryLinkset linkset,
bool includeConflicts,
bool includeTimeline,
bool includeObservations)
{
var normalized = linkset.Normalized;
var conflicts = includeConflicts
? (linkset.Conflicts ?? Array.Empty<AdvisoryLinksetConflict>()).Select(c =>
new LnmLinksetConflict(
c.Field,
c.Reason,
c.Values is null ? null : string.Join(", ", c.Values),
ObservedAt: null,
EvidenceHash: c.SourceIds?.FirstOrDefault()))
.ToArray()
: Array.Empty<LnmLinksetConflict>();
var timeline = includeTimeline
? Array.Empty<LnmLinksetTimeline>() // timeline not yet captured in linkset store
: Array.Empty<LnmLinksetTimeline>();
var provenance = linkset.Provenance is null
? new LnmLinksetProvenance(linkset.CreatedAt, null, null, null)
: new LnmLinksetProvenance(
linkset.CreatedAt,
connectorId: null,
evidenceHash: linkset.Provenance.ObservationHashes?.FirstOrDefault(),
dsseEnvelopeHash: null);
var normalizedDto = normalized is null
? null
: new LnmLinksetNormalized(
Aliases: null,
Purl: normalized.Purls,
Versions: normalized.Versions,
Ranges: normalized.Ranges?.Select(r => (object)r).ToArray(),
Severities: normalized.Severities?.Select(s => (object)s).ToArray());
return new LnmLinksetResponse(
linkset.AdvisoryId,
linkset.Source,
normalized?.Purls ?? Array.Empty<string>(),
Array.Empty<string>(),
Summary: null,
PublishedAt: linkset.CreatedAt,
ModifiedAt: linkset.CreatedAt,
Severity: null,
Status: "fact-only",
provenance,
conflicts,
timeline,
normalizedDto,
Cached: false,
Remarks: Array.Empty<string>(),
Observations: includeObservations ? linkset.ObservationIds : Array.Empty<string>());
}
IResult JsonResult<T>(T value, int? statusCode = null)
{
var payload = JsonSerializer.Serialize(value, jsonOptions);