Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 33 additions & 2 deletions modules/fundamental/src/sbom/endpoints/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ use crate::{
},
sbom::{
model::{
SbomExternalPackageReference, SbomNodeReference, SbomPackage, SbomPackageRelation,
SbomSummary, Which, details::SbomAdvisory,
SbomExternalPackageReference, SbomLookup, SbomNodeReference, SbomPackage,
SbomPackageRelation, SbomSummary, Which, details::SbomAdvisory,
},
service::{SbomService, sbom::FetchOptions},
},
Expand Down Expand Up @@ -61,6 +61,7 @@ pub fn configure(
.app_data(web::Data::new(Config { upload_limit }))
.service(v2::all)
.service(v3::all)
.service(lookup)
.service(all_related)
.service(count_related)
.service(get)
Expand Down Expand Up @@ -247,6 +248,36 @@ mod v3 {
}
}

/// Lightweight SBOM lookup returning only SBOM ID and document ID.
///
/// This endpoint joins only the `sbom` and `source_document` tables,
/// avoiding the expensive joins (licenses, PURLs, CPEs, packages) used
/// by the full `listSboms` endpoint. Designed for CLI tools that need
/// efficient bulk lookups for operations like SBOM pruning/deletion.
#[utoipa::path(
tag = "sbom",
operation_id = "lookupSboms",
params(
Query,
Paginated,
),
responses(
(status = 200, description = "Matching SBOMs (lightweight)", body = PaginatedResults<SbomLookup>),
),
)]
#[get("/v2/sbom/lookup")]
pub async fn lookup(
fetch: web::Data<SbomService>,
db: web::Data<Database>,
web::Query(search): web::Query<Query>,
web::Query(paginated): web::Query<Paginated>,
_: Require<ReadSbom>,
) -> actix_web::Result<impl Responder> {
let tx = db.begin_read().await?;
let result = fetch.fetch_sbom_lookups(search, paginated, &tx).await?;
Ok(HttpResponse::Ok().json(result))
}

/// Find all SBOMs containing the provided package.
///
/// The package can be provided either via a PURL or using the ID of a package as returned by
Expand Down
75 changes: 74 additions & 1 deletion modules/fundamental/src/sbom/endpoints/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use crate::{
Group, GroupRef, UpdateAssignments, create_groups, locate_id, read_assignments,
resolve_group_refs,
},
sbom::model::{SbomPackage, SbomSummary},
sbom::model::{SbomLookup, SbomPackage, SbomSummary},
test::{caller, label::Api},
};
use actix_http::StatusCode;
Expand Down Expand Up @@ -1925,3 +1925,76 @@ async fn related_by_hash(ctx: &TrustifyContext) -> Result<(), anyhow::Error> {

Ok(())
}

#[test_context(TrustifyContext)]
#[test(actix_web::test)]
async fn lookup_sboms(ctx: &TrustifyContext) -> Result<(), anyhow::Error> {
let app = caller(ctx).await?;
let result = ctx
.ingest_document("spdx/quarkus-bom-3.2.11.Final-redhat-00001.json")
.await?;
let id = result.id.to_string();

let uri = "/api/v2/sbom/lookup";
let req = TestRequest::get().uri(uri).to_request();
let response: PaginatedResults<SbomLookup> = app.call_and_read_body_json(req).await;

assert_eq!(response.total, 1);
assert_eq!(response.items.len(), 1);
assert_eq!(response.items[0].sbom_id.to_string(), id);
assert!(response.items[0].document_id.is_some());

Ok(())
}

#[test_context(TrustifyContext)]
#[test(actix_web::test)]
async fn lookup_sboms_search(ctx: &TrustifyContext) -> Result<(), anyhow::Error> {
let app = caller(ctx).await?;
ctx.ingest_document("spdx/quarkus-bom-3.2.11.Final-redhat-00001.json")
.await?;
ctx.ingest_document("zookeeper-3.9.2-cyclonedx.json")
.await?;

// Without filter: both SBOMs
let req = TestRequest::get().uri("/api/v2/sbom/lookup").to_request();
let response: PaginatedResults<SbomLookup> = app.call_and_read_body_json(req).await;
assert_eq!(response.total, 2);

// With search filter
let uri = format!("/api/v2/sbom/lookup?q={}", encode("quarkus"));
let req = TestRequest::get().uri(&uri).to_request();
let response: PaginatedResults<SbomLookup> = app.call_and_read_body_json(req).await;
assert_eq!(response.total, 1);
assert!(response.items[0].document_id.is_some());

Ok(())
}

#[test_context(TrustifyContext)]
#[test(actix_web::test)]
async fn lookup_sboms_pagination(ctx: &TrustifyContext) -> Result<(), anyhow::Error> {
let app = caller(ctx).await?;
ctx.ingest_document("spdx/quarkus-bom-3.2.11.Final-redhat-00001.json")
.await?;
ctx.ingest_document("zookeeper-3.9.2-cyclonedx.json")
.await?;

// Page with limit=1
let req = TestRequest::get()
.uri("/api/v2/sbom/lookup?limit=1")
.to_request();
let response: PaginatedResults<SbomLookup> = app.call_and_read_body_json(req).await;
assert_eq!(response.total, 2);
assert_eq!(response.items.len(), 1);

// Second page
let req = TestRequest::get()
.uri("/api/v2/sbom/lookup?limit=1&offset=1")
.to_request();
let response: PaginatedResults<SbomLookup> = app.call_and_read_body_json(req).await;
assert_eq!(response.total, 2);
assert_eq!(response.items.len(), 1);

Ok(())
}
13 changes: 12 additions & 1 deletion modules/fundamental/src/sbom/model/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use crate::{
sbom::service::sbom::IntoPackage,
source_document::model::SourceDocument,
};
use sea_orm::{ConnectionTrait, ModelTrait, PaginatorTrait, prelude::Uuid};
use sea_orm::{ConnectionTrait, FromQueryResult, ModelTrait, PaginatorTrait, prelude::Uuid};
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use tracing::{info_span, instrument};
Expand All @@ -20,6 +20,17 @@ use trustify_entity::{
};
use utoipa::ToSchema;

/// Lightweight SBOM lookup result containing only the SBOM ID and document ID.
/// Designed for efficient bulk lookups (e.g., CLI delete/prune operations)
/// by joining only the `sbom` and `source_document` tables.
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema, FromQueryResult)]
pub struct SbomLookup {
#[serde(with = "uuid::serde::urn")]
#[schema(value_type=String)]
pub sbom_id: Uuid,
pub document_id: Option<String>,
}

#[derive(Serialize, Deserialize, Debug, Clone, ToSchema, Default)]
pub struct SbomHead {
#[serde(with = "uuid::serde::urn")]
Expand Down
37 changes: 34 additions & 3 deletions modules/fundamental/src/sbom/service/sbom.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ use crate::{
Error,
common::license_filtering::{LICENSE, license_text_coalesce},
sbom::model::{
SbomExternalPackageReference, SbomNodeReference, SbomPackage, SbomPackageRelation,
SbomPackageSummary, SbomSummary, Which, details::SbomDetails,
SbomExternalPackageReference, SbomLookup, SbomNodeReference, SbomPackage,
SbomPackageRelation, SbomPackageSummary, SbomSummary, Which, details::SbomDetails,
},
};
use futures_util::{StreamExt, TryStreamExt, stream};
Expand All @@ -21,7 +21,7 @@ use tracing::{Instrument, info_span, instrument};
use trustify_common::{
cpe::Cpe,
db::{
limiter::{LimiterTrait, limit_selector},
limiter::{LimiterAsModelTrait, LimiterTrait, limit_selector},
multi_model::{FromQueryResultMultiModel, SelectIntoMultiModel},
query::{Columns, Filtering, IntoColumns, Query, q},
},
Expand Down Expand Up @@ -114,6 +114,37 @@ impl SbomService {
})
}

/// Fetch lightweight SBOM lookups (only sbom_id and document_id).
/// Joins only `sbom` and `source_document` tables for maximum efficiency.
#[instrument(skip(self, connection), err(level=tracing::Level::INFO))]
pub async fn fetch_sbom_lookups<C: ConnectionTrait>(
&self,
search: Query,
paginated: Paginated,
connection: &C,
) -> Result<PaginatedResults<SbomLookup>, Error> {
let limiter = sbom::Entity::find()
.join(JoinType::Join, sbom::Relation::SourceDocument.def())
.select_only()
.column(sbom::Column::SbomId)
.column(sbom::Column::DocumentId)
.filtering_with(
search,
Columns::from_entity::<sbom::Entity>()
.add_columns(source_document::Entity)
.translator(|f, op, v| match f.split_once(':') {
Some(("label", key)) => Some(format!("labels:{key}{op}{v}")),
_ => None,
}),
)?
.limiting_as::<SbomLookup>(connection, paginated.offset, paginated.limit);

let total = limiter.total().await?;
let items = limiter.fetch().await?;

Ok(PaginatedResults { total, items })
}

/// delete one sbom
#[instrument(skip(self, connection), err(level=tracing::Level::INFO))]
pub async fn delete_sbom<C: ConnectionTrait>(
Expand Down
Loading
Loading