Skip to content
Open
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
29 changes: 28 additions & 1 deletion modules/fundamental/src/sbom/endpoints/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ pub fn configure(
.service(v3::all)
.service(all_related)
.service(count_related)
.service(all_models)
.service(get)
.service(get_sbom_advisories)
.service(delete)
Expand Down Expand Up @@ -478,9 +479,35 @@ pub async fn models(
) -> actix_web::Result<impl Responder> {
let tx = db.begin_read().await?;
let result = fetch
.fetch_sbom_models(id.into_inner(), search, paginated, &tx)
.fetch_sbom_models(Some(id.into_inner()), search, paginated, &tx)
.await?;
Ok(HttpResponse::Ok().json(result))
}

/// Search for all AI models
#[utoipa::path(
tag = "sbom",
operation_id = "listAllModels",
params(
Query,
Paginated,
),
responses(
(status = 200, description = "AI Models", body = PaginatedResults<SbomModel>),
),
)]
#[get("/v2/sbom/models")]
pub async fn all_models(
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_models(None, search, paginated, &tx)
.await?;
Ok(HttpResponse::Ok().json(result))
}

Expand Down
61 changes: 61 additions & 0 deletions modules/fundamental/src/sbom/endpoints/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1858,6 +1858,67 @@ async fn query_aibom_models(
Ok(())
}

#[test_context(TrustifyContext)]
#[rstest]
#[case(None, &["canary-1b-v2", "granite-docling-258M"])]
#[case(Some(""), &["canary-1b-v2", "granite-docling-258M"])]
#[case(Some("thesearenotthedroidsyouarelookingfor"), &[])]
#[case(Some("hugging"), &["canary-1b-v2", "granite-docling-258M"])]
#[case(Some("granite"), &["granite-docling-258M"])]
#[case(Some("pkg:huggingface/ibm-granite"), &["granite-docling-258M"])]
#[case(Some("pkg:huggingface/ibm-granite/granite-docling-258M"), &["granite-docling-258M"])]
#[case(Some("pkg:huggingface/ibm-granite/granite-docling-258M@1.0"), &["granite-docling-258M"])]
#[case(Some("purl=pkg:huggingface/ibm-granite/granite-docling-258M@1.0"), &["granite-docling-258M"])]
#[case(Some("purl~granite"), &["granite-docling-258M"])]
#[case(Some("purl:namespace=ibm-granite&purl:version=1.0&purl:type=huggingface"), &["granite-docling-258M"])]
#[case(Some("name~granite"), &["granite-docling-258M"])]
#[case(Some("name=granite-docling-258M"), &["granite-docling-258M"])]
#[case(Some("properties:typeOfModel=idefics3"), &["granite-docling-258M"])]
#[case(Some("properties:typeOfModel=idefics3&properties:primaryPurpose=image-text-to-text"), &["granite-docling-258M"])]
#[case(Some("purl:type=boatymcboatface"), &["granite-docling-258M"])]
#[case(Some("name=non-existent-model-name"), &[])]
#[case(Some("purl:type=does-not-exist"), &[])]
#[case(Some("properties:typeOfModel=idefics3&properties:primaryPurpose=text-to-image"), &[])]
#[test_log::test(actix_web::test)]
async fn query_all_aibom_models(
ctx: &TrustifyContext,
#[case] q: Option<&str>,
#[case] names: &[&str],
) -> Result<(), anyhow::Error> {
ctx.ingest_documents([
"cyclonedx/ai/ibm-granite_granite-docling-258M_aibom.json",
"cyclonedx/ai/nvidia_canary-1b-v2_aibom.json",
])
.await?;

let uri = if let Some(q) = q {
format!("/api/v2/sbom/models?q={}", encode(q))
} else {
"/api/v2/sbom/models".into()
};
let req = TestRequest::get().uri(&uri).to_request();
let app = caller(ctx).await?;

#[derive(serde::Deserialize)]
struct Page<T> {
total: usize,
items: Vec<T>,
}
#[derive(serde::Deserialize)]
struct Summary {
name: String,
}

let response: Page<Summary> = app.call_and_read_body_json(req).await;
let mut v: Vec<_> = response.items.into_iter().map(|i| i.name).collect();
v.sort();

assert_eq!(response.total, names.len());
assert_eq!(v, names);

Ok(())
}

#[test_context(TrustifyContext)]
#[rstest]
#[case::no_filter([], 3)]
Expand Down
8 changes: 5 additions & 3 deletions modules/fundamental/src/sbom/service/sbom.rs
Original file line number Diff line number Diff line change
Expand Up @@ -437,14 +437,13 @@ impl SbomService {
#[instrument(skip(self, connection), err(level=tracing::Level::INFO))]
pub async fn fetch_sbom_models<C: ConnectionTrait>(
&self,
sbom_id: Uuid,
sbom_id: Option<Uuid>,
search: Query,
paginated: Paginated,
connection: &C,
) -> Result<PaginatedResults<SbomModel>, Error> {
let query = join_purls_and_cpes(
let mut query = join_purls_and_cpes(
sbom_ai::Entity::find()
.filter(sbom_ai::Column::SbomId.eq(sbom_id))
.select_only()
.column_as(sbom_ai::Column::NodeId, "id")
.group_by(sbom_ai::Column::NodeId)
Expand All @@ -467,6 +466,9 @@ impl SbomService {
}),
)?,
);
if let Some(id) = sbom_id {
query = query.filter(sbom_ai::Column::SbomId.eq(id));
}

let limiter = limit_selector::<'_, _, _, _, ModelCatcher>(
connection,
Expand Down
110 changes: 110 additions & 0 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2572,6 +2572,116 @@ paths:
items:
type: integer
format: int64
/api/v2/sbom/models:
get:
tags:
- sbom
summary: Search for all AI models
operationId: listAllModels
parameters:
- name: q
in: query
description: |
EBNF grammar for the _q_ parameter:
```text
q = ( values | filter ) { '&' q }
values = value { '|', values }
filter = field, operator, values
operator = "=" | "!=" | "~" | "!~" | ">=" | ">" | "<=" | "<"
value = (* any text but escape special characters with '\' *)
field = (* must match an entity attribute name *)
```
Any values in a _q_ will result in a case-insensitive "full
text search", effectively producing an OR clause of LIKE
clauses for every string-ish field in the resource being
queried.

Examples:
- `foo` - any field containing 'foo'
- `foo|bar` - any field containing either 'foo' OR 'bar'
- `foo&bar` - some field contains 'foo' AND some field contains 'bar'

A _filter_ may also be used to constrain the results. The
filter's field name must correspond to one of the resource's
attributes. If it doesn't, an error will be returned
containing a list of the valid fields for that resource.

An ASCII value of `NUL`, percent-encoded as `%00`, may be used
to find resources on which a particular field isn't set. For
example, `name=%00` and `name!=%00` yield the WHERE clauses,
'NAME IS NULL' and 'NAME IS NOT NULL', respectively.

Examples:
- `name=foo` - entity's _name_ matches 'foo' exactly
- `name~foo` - entity's _name_ contains 'foo', case-insensitive
- `name~foo|bar` - entity's _name_ contains either 'foo' OR 'bar', case-insensitive
- `name=` - entity's _name_ is the empty string, ''
- `name=%00` - entity's _name_ isn't set
- `published>3 days ago` - date values can be "human time"

Multiple full text searches and/or filters should be
'&'-delimited -- they are logically AND'd together.

- `red hat|fedora&labels:type=cve|osv&published>last wednesday 17:00`

Fields corresponding to JSON objects in the database may use a
':' to delimit the column name and the object key,
e.g. `purl:qualifiers:type=pom`

Any operator or special character, e.g. '|', '&', within a
value should be escaped by prefixing it with a backslash.
required: false
schema:
type: string
- name: sort
in: query
description: |
EBNF grammar for the _sort_ parameter:
```text
sort = field [ ':', order ] { ',' sort }
order = ( "asc" | "desc" )
field = (* must match the name of entity's attributes *)
```
The optional _order_ should be one of "asc" or "desc". If
omitted, the order defaults to "asc".

Each _field_ name must correspond to one of the columns of the
table holding the entities being queried. Those corresponding
to JSON objects in the database may use a ':' to delimit the
column name and the object key,
e.g. `purl:qualifiers:type:desc`
required: false
schema:
type: string
- name: offset
in: query
description: |-
The first item to return, skipping all that come before it.

NOTE: The order of items is defined by the API being called.
required: false
schema:
type: integer
format: int64
minimum: 0
- name: limit
in: query
description: |-
The maximum number of entries to return.

Zero means: no limit
required: false
schema:
type: integer
format: int64
minimum: 0
responses:
'200':
description: AI Models
content:
application/json:
schema:
$ref: '#/components/schemas/PaginatedResults_SbomModel'
/api/v2/sbom/{id}:
get:
tags:
Expand Down
Loading