Skip to content

Commit 3dba923

Browse files
authored
Add get_project_artifacts() (#98)
1 parent bbf7f62 commit 3dba923

File tree

4 files changed

+146
-22
lines changed

4 files changed

+146
-22
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ While the project is still on major version 0, breaking changes may be introduce
1414

1515
## Unreleased
1616

17+
### Added
18+
19+
- Project artifacts method: `HarborAsyncClient.get_project_artifacts()`
20+
- `with_sbom_overview` parameter for `HarborAsyncClient.get_artifact()` and `HarborAsyncClient.get_artifacts()` to include SBOM overview in the response.
21+
1722
### Changed
1823

1924
- Models updated to API schema from [c97253f](https://github.com/goharbor/harbor/blob/4a12623459a754ff4d07fbd1cddb4df436e8524c/api/v2.0/swagger.yaml)

harborapi/client.py

Lines changed: 112 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@
127127
from .retry import retry
128128
from .utils import get_artifact_path
129129
from .utils import get_basicauth
130+
from .utils import get_mime_type_header
130131
from .utils import get_params
131132
from .utils import get_project_headers
132133
from .utils import get_repo_path
@@ -2336,6 +2337,100 @@ async def get_project_summary(
23362337
)
23372338
return self.construct_model(ProjectSummary, summary)
23382339

2340+
# GET /projects/{project_name_or_id}/artifacts
2341+
async def get_project_artifacts(
2342+
self,
2343+
project_name_or_id: Union[str, int],
2344+
query: Optional[str] = None,
2345+
sort: Optional[str] = None,
2346+
page: int = 1,
2347+
page_size: int = 10,
2348+
limit: Optional[int] = None,
2349+
latest: bool = False,
2350+
with_tag: bool = False,
2351+
with_label: bool = False,
2352+
with_scan_overview: bool = False,
2353+
with_sbom_overview: bool = False,
2354+
with_immutable_status: bool = False,
2355+
with_accessory: bool = False,
2356+
mime_type: Union[str, Sequence[str]] = DEFAULT_MIME_TYPES,
2357+
) -> List[Artifact]:
2358+
"""Get artifatcs for a project.
2359+
2360+
Parameters
2361+
----------
2362+
project_name_or_id : Union[str, int]
2363+
The name or ID of the project
2364+
String arguments are treated as project names.
2365+
Integer arguments are treated as project IDs.
2366+
query : Optional[str]
2367+
Query string to filter the artifacts.
2368+
2369+
Supported query patterns are:
2370+
2371+
* exact match(`"k=v"`)
2372+
* fuzzy match(`"k=~v"`)
2373+
* range(`"k=[min~max]"`)
2374+
* list with union releationship(`"k={v1 v2 v3}"`)
2375+
* list with intersection relationship(`"k=(v1 v2 v3)`).
2376+
2377+
The value of range and list can be:
2378+
2379+
* string(enclosed by `"` or `'`)
2380+
* integer
2381+
* time(in format `"2020-04-09 02:36:00"`)
2382+
2383+
All of these query patterns should be put in the query string
2384+
and separated by `","`. e.g. `"k1=v1,k2=~v2,k3=[min~max]"`
2385+
sort : Optional[str]
2386+
The sort order of the artifacts.
2387+
page : int
2388+
The page of results to return
2389+
page_size : int
2390+
The number of results to return per page
2391+
limit : Optional[int]
2392+
The maximum number of results to return
2393+
latest : bool
2394+
Whether to return only the latest version of each artifact
2395+
with_tag : bool
2396+
Whether to include the tags of the artifacts
2397+
with_label : bool
2398+
Whether to include the labels of the artifacts
2399+
with_scan_overview : bool
2400+
Whether to include the scan overview of the artifacts
2401+
with_sbom_overview : bool
2402+
Whether to include the SBOM overview of the artifacts
2403+
with_immutable_status : bool
2404+
Whether to include the immutable status of the artifacts
2405+
with_accessory : bool
2406+
Whether to include the accessory of the artifacts
2407+
mime_type : Union[str, Sequence[str]]
2408+
MIME types for the scan report or scan summary. The first mime type will be used when a report is found for it.
2409+
Can be a list of MIME types or a single MIME type.
2410+
"""
2411+
params = get_params(
2412+
q=query,
2413+
sort=sort,
2414+
page=page,
2415+
page_size=page_size,
2416+
latest=latest,
2417+
with_tag=with_tag,
2418+
with_label=with_label,
2419+
with_scan_overview=with_scan_overview,
2420+
with_sbom_overview=with_sbom_overview,
2421+
with_immutable_status=with_immutable_status,
2422+
with_accessory=with_accessory,
2423+
)
2424+
headers = get_project_headers(project_name_or_id)
2425+
headers.update(get_mime_type_header(mime_type))
2426+
resp = await self.get(
2427+
f"/projects/{project_name_or_id}/artifacts",
2428+
params=params,
2429+
headers=headers,
2430+
limit=limit,
2431+
)
2432+
return self.construct_model(Artifact, resp, is_list=True)
2433+
23392434
# GET /projects/{project_name_or_id}/_deletable
23402435
async def get_project_deletable(
23412436
self, project_name_or_id: Union[str, int]
@@ -3512,7 +3607,7 @@ async def copy_artifact(
35123607
)
35133608
return urldecode_header(resp, "Location")
35143609

3515-
# GET /projects/{project_name}/repositories/{repository_name}/artifacts
3610+
# GET /projects/{project_name_or_id}/repositories/{repository_name}/artifacts
35163611
async def get_artifacts(
35173612
self,
35183613
project_name: str,
@@ -3525,10 +3620,11 @@ async def get_artifacts(
35253620
with_tag: bool = True,
35263621
with_label: bool = False,
35273622
with_scan_overview: bool = False,
3623+
with_sbom_overview: bool = False,
35283624
with_signature: bool = False,
35293625
with_immutable_status: bool = False,
35303626
with_accessory: bool = False,
3531-
mime_type: str = "application/vnd.security.vulnerability.report; version=1.1",
3627+
mime_type: Union[str, Sequence[str]] = DEFAULT_MIME_TYPES,
35323628
**kwargs: Any,
35333629
) -> List[Artifact]:
35343630
"""Get the artifacts in a repository.
@@ -3572,21 +3668,18 @@ async def get_artifacts(
35723668
Whether to include the labels of the artifact in the response
35733669
with_scan_overview : bool
35743670
Whether to include the scan overview of the artifact in the response
3671+
with_sbom_overview : bool
3672+
Whether to include the SBOM overview of the artifacts
35753673
with_signature : bool
35763674
Whether the signature is included inside the tags of the returning artifacts.
35773675
Only works when setting `with_tag==True`.
35783676
with_immutable_status : bool
35793677
Whether the immutable status is included inside the tags of the returning artifacts.
35803678
with_accessory : bool
35813679
Whether the accessories are included of the returning artifacts.
3582-
mime_type : str
3583-
A comma-separated lists of MIME types for the scan report or scan summary.
3584-
The first mime type will be used when the report found for it.
3585-
Currently the mime type supports:
3586-
3587-
* application/vnd.scanner.adapter.vuln.report.harbor+json; version=1.0
3588-
* application/vnd.security.vulnerability.report; version=1.1
3589-
3680+
mime_type : Union[str, Sequence[str]]
3681+
MIME types for the scan report or scan summary. The first mime type will be used when a report is found for it.
3682+
Can be a list of MIME types or a single MIME type.
35903683
Returns
35913684
-------
35923685
List[Artifact]
@@ -3601,14 +3694,15 @@ async def get_artifacts(
36013694
with_tag=with_tag,
36023695
with_label=with_label,
36033696
with_scan_overview=with_scan_overview,
3697+
with_sbom_overview=with_scan_overview,
36043698
with_signature=with_signature,
36053699
with_immutable_status=with_immutable_status,
36063700
with_accessory=with_accessory,
36073701
)
36083702
resp = await self.get(
36093703
path,
36103704
params=params,
3611-
headers={"X-Accept-Vulnerabilities": mime_type},
3705+
headers=get_mime_type_header(mime_type),
36123706
limit=limit,
36133707
)
36143708
return self.construct_model(Artifact, resp, is_list=True)
@@ -3655,7 +3749,8 @@ async def get_artifact(
36553749
with_signature: bool = False,
36563750
with_immutable_status: bool = False,
36573751
with_accessory: bool = False,
3658-
mime_type: str = "application/vnd.security.vulnerability.report; version=1.1",
3752+
with_sbom_overview: bool = False,
3753+
mime_type: Union[str, Sequence[str]] = DEFAULT_MIME_TYPES,
36593754
) -> Artifact:
36603755
"""Get an artifact.
36613756
@@ -3684,6 +3779,8 @@ async def get_artifact(
36843779
Whether the immutable status is included inside the tags of the returning artifact.
36853780
with_accessory : bool
36863781
Whether the accessories are included of the returning artifact.
3782+
with_sbom_overview : bool
3783+
Whether the sbom overview is included of the returning artifact.
36873784
mime_type : str
36883785
A comma-separated lists of MIME types for the scan report or scan summary.
36893786
The first mime type will be used when the report found for it.
@@ -3709,8 +3806,9 @@ async def get_artifact(
37093806
"with_signature": with_signature,
37103807
"with_immutable_status": with_immutable_status,
37113808
"with_accessory": with_accessory,
3809+
"with_sbom_overview": with_sbom_overview,
37123810
},
3713-
headers={"X-Accept-Vulnerabilities": mime_type},
3811+
headers=get_mime_type_header(mime_type),
37143812
)
37153813
return self.construct_model(Artifact, resp)
37163814

@@ -3841,15 +3939,7 @@ async def get_artifact_vulnerability_reports(
38413939
"""
38423940
path = get_artifact_path(project_name, repository_name, reference)
38433941
url = f"{path}/additions/vulnerabilities"
3844-
# NOTE: in the offical API spec, a comma AND space is used to separate:
3845-
# https://github.com/goharbor/harbor/blob/df4ab856c7597e6fe28b466ba8419257de8a1af7/api/v2.0/swagger.yaml#L6256
3846-
if not isinstance(mime_type, str):
3847-
mime_type_param = ", ".join(mime_type)
3848-
else:
3849-
mime_type_param = mime_type
3850-
resp = await self.get(
3851-
url, headers={"X-Accept-Vulnerabilities": mime_type_param}
3852-
)
3942+
resp = await self.get(url, headers=get_mime_type_header(mime_type))
38533943
if not isinstance(resp, dict):
38543944
raise UnprocessableEntity(f"Unable to process response from {url}: {resp}")
38553945
reports: FirstDict[str, HarborVulnerabilityReport] = FirstDict()

harborapi/utils.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from json import JSONDecodeError
66
from typing import Dict
77
from typing import Optional
8+
from typing import Sequence
89
from typing import Union
910
from typing import cast
1011
from urllib.parse import quote_plus
@@ -255,6 +256,16 @@ def get_project_headers(project_name_or_id: Union[str, int]) -> Dict[str, str]:
255256
return {"X-Is-Resource-Name": str(isinstance(project_name_or_id, str)).lower()}
256257

257258

259+
def get_mime_type_header(mime_type: Union[str, Sequence[str]]) -> Dict[str, str]:
260+
# NOTE: in the offical API spec, a comma AND space is used to separate:
261+
# https://github.com/goharbor/harbor/blob/df4ab856c7597e6fe28b466ba8419257de8a1af7/api/v2.0/swagger.yaml#L6256
262+
if not isinstance(mime_type, str):
263+
mime_type_param = ", ".join(mime_type)
264+
else:
265+
mime_type_param = mime_type
266+
return {"X-Accept-Vulnerabilities": mime_type_param}
267+
268+
258269
def get_params(**kwargs: QueryParamValue) -> QueryParamMapping:
259270
"""Get parameters for an API call as a dict, where `None` values are ignored.
260271

tests/endpoints/test_projects.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from pytest_httpserver import HTTPServer
1212

1313
from harborapi.client import HarborAsyncClient
14+
from harborapi.models.models import Artifact
1415
from harborapi.models.models import AuditLog
1516
from harborapi.models.models import Project
1617
from harborapi.models.models import ProjectDeletable
@@ -207,6 +208,23 @@ async def test_get_project_summary_mock(
207208
assert resp == project_summary
208209

209210

211+
@pytest.mark.asyncio
212+
@given(st.lists(st.builds(Artifact)))
213+
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture])
214+
async def test_get_project_artifacts_mock(
215+
async_client: HarborAsyncClient,
216+
httpserver: HTTPServer,
217+
artifacts: List[Artifact],
218+
):
219+
httpserver.expect_oneshot_request(
220+
"/api/v2.0/projects/1234/artifacts",
221+
method="GET",
222+
).respond_with_data(json_from_list(artifacts), content_type="application/json")
223+
224+
resp = await async_client.get_project_artifacts("1234")
225+
assert resp == artifacts
226+
227+
210228
@pytest.mark.asyncio
211229
@given(st.builds(ProjectDeletable))
212230
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture])

0 commit comments

Comments
 (0)