Add OpenAPI specification for Link-Not-Merge Policy APIs
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user