Skip to content

Commit 1561bc0

Browse files
committed
feat: Added new package operation controller
1 parent 3ab03c7 commit 1561bc0

File tree

8 files changed

+205
-9
lines changed

8 files changed

+205
-9
lines changed

app/controllers/file_operation_controller.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
)
1414
from app.services import (
1515
read_data_for_smt_transform,
16-
read_graph_for_info_operation,
16+
read_graph_for_req_file_info_operation,
1717
read_operation_result,
1818
read_requirement_file_moment,
1919
read_smt_text,
@@ -52,7 +52,7 @@ async def requirement_file_info(
5252
if operation_result is not None and operation_result["moment"].replace(tzinfo=UTC) > req_file_moment.replace(tzinfo=UTC):
5353
result = operation_result["result"]
5454
else:
55-
result = await read_graph_for_info_operation(
55+
result = await read_graph_for_req_file_info_operation(
5656
file_info_request.node_type.value,
5757
file_info_request.requirement_file_id,
5858
file_info_request.max_depth
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
from typing import Annotated
2+
3+
from fastapi import APIRouter, Body, Depends, Request, status
4+
from fastapi.responses import JSONResponse
5+
6+
from app.limiter import limiter
7+
from app.schemas import PackageInfoRequest
8+
from app.services import (
9+
read_graph_for_package_info_operation,
10+
read_operation_result,
11+
replace_operation_result,
12+
)
13+
from app.utils import (
14+
JWTBearer,
15+
filter_versions,
16+
json_encoder,
17+
)
18+
19+
router = APIRouter()
20+
21+
@router.post(
22+
"/operation/package/package_info",
23+
summary="Get Package Information",
24+
description="Retrieve information about dependnecy graph of a specific package.",
25+
response_description="Package information.",
26+
dependencies=[Depends(JWTBearer())],
27+
tags=["Secure Chain Depex - Operation/Package"]
28+
)
29+
@limiter.limit("5/minute")
30+
async def package_info(
31+
request: Request,
32+
package_info_request: Annotated[PackageInfoRequest, Body()]
33+
) -> JSONResponse:
34+
operation_result_id = f"{package_info_request.node_type.value}:{package_info_request.package_name}:{package_info_request.max_depth}"
35+
operation_result = await read_operation_result(operation_result_id)
36+
if operation_result is not None:
37+
result = operation_result["result"]
38+
else:
39+
result = await read_graph_for_package_info_operation(
40+
package_info_request.node_type.value,
41+
package_info_request.package_name,
42+
package_info_request.max_depth
43+
)
44+
if result["total_direct_dependencies"] != 0:
45+
for direct_package in result["direct_dependencies"]:
46+
direct_package["versions"] = await filter_versions(
47+
package_info_request.node_type.value,
48+
direct_package["versions"],
49+
direct_package["package_constraints"]
50+
)
51+
for _, indirect_packages in result["indirect_dependencies_by_depth"].items():
52+
for indirect_package in indirect_packages:
53+
indirect_package["versions"] = await filter_versions(
54+
package_info_request.node_type.value,
55+
indirect_package["versions"],
56+
indirect_package["package_constraints"]
57+
)
58+
else:
59+
return JSONResponse(
60+
status_code=status.HTTP_200_OK,
61+
content= await json_encoder(
62+
{
63+
"detail": "no_dependencies",
64+
}
65+
),
66+
)
67+
await replace_operation_result(operation_result_id, result)
68+
return JSONResponse(
69+
status_code=status.HTTP_200_OK, content= await json_encoder(
70+
{
71+
"result": result,
72+
"detail": "file_info_success",
73+
}
74+
)
75+
)
76+

app/router.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@
55
file_operation_controller,
66
graph_controller,
77
health_controller,
8+
package_operation_controller,
89
)
910

1011
api_router = APIRouter()
1112
api_router.include_router(health_controller.router, tags=["Secure Chain Depex Health"])
1213
api_router.include_router(graph_controller.router, tags=["Secure Chain Depex - Graph"])
14+
api_router.include_router(package_operation_controller.router, tags=["Secure Chain Depex - Operation/Package"])
1315
api_router.include_router(file_operation_controller.router, tags=["Secure Chain Depex - Operation/File"])
1416
api_router.include_router(config_operation_controller.router, tags=["Secure Chain Depex - Operation/Config"])

app/schemas/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
FileInfoRequest,
1313
FilterConfigsRequest,
1414
MinMaxImpactRequest,
15+
PackageInfoRequest,
1516
ValidConfigRequest,
1617
ValidGraphRequest,
1718
)
@@ -28,6 +29,7 @@
2829
"InitRepositoryRequest",
2930
"InitVersionRequest",
3031
"MinMaxImpactRequest",
32+
"PackageInfoRequest",
3133
"ValidConfigRequest",
32-
"ValidGraphRequest",
34+
"ValidGraphRequest"
3335
]

app/schemas/operations/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from .file_info_request import FileInfoRequest
44
from .filter_configs_request import FilterConfigsRequest
55
from .min_max_impact_request import MinMaxImpactRequest
6+
from .package_info_request import PackageInfoRequest
67
from .valid_config_request import ValidConfigRequest
78
from .valid_graph_request import ValidGraphRequest
89

@@ -12,6 +13,7 @@
1213
"FileInfoRequest",
1314
"FilterConfigsRequest",
1415
"MinMaxImpactRequest",
16+
"PackageInfoRequest",
1517
"ValidConfigRequest",
1618
"ValidGraphRequest"
1719
]
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from pydantic import BaseModel, Field, field_validator, model_validator
2+
3+
from app.schemas.enums import NodeType
4+
from app.schemas.validators import validate_max_depth
5+
6+
7+
class PackageInfoRequest(BaseModel):
8+
package_name: str = Field(...)
9+
max_depth: int = Field(...)
10+
node_type: NodeType = Field(...)
11+
12+
@field_validator("max_depth")
13+
def validate_max_depth(cls, value):
14+
return validate_max_depth(value)
15+
16+
@model_validator(mode='before')
17+
def set_max_depth_to_square(cls, values):
18+
if values.get('max_depth') != -1:
19+
values['max_depth'] = values.get('max_depth', 1) * 2
20+
return values

app/services/__init__.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818
create_repository,
1919
create_user_repository_rel,
2020
read_data_for_smt_transform,
21-
read_graph_for_info_operation,
21+
read_graph_for_package_info_operation,
22+
read_graph_for_req_file_info_operation,
2223
read_repositories,
2324
read_repositories_by_user_id,
2425
read_repositories_update,
@@ -60,7 +61,8 @@
6061
"exists_package",
6162
"exists_version",
6263
"read_data_for_smt_transform",
63-
"read_graph_for_info_operation",
64+
"read_graph_for_package_info_operation",
65+
"read_graph_for_req_file_info_operation",
6466
"read_operation_result",
6567
"read_package_by_name",
6668
"read_package_status_by_name",

app/services/repository_service.py

Lines changed: 96 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111

1212
@unit_of_work(timeout=3)
13-
async def read_graph(tx, query, requirement_file_id, max_depth):
13+
async def read_graph_req_file(tx, query, requirement_file_id, max_depth):
1414
result = await tx.run(
1515
query,
1616
requirement_file_id=requirement_file_id,
@@ -19,6 +19,16 @@ async def read_graph(tx, query, requirement_file_id, max_depth):
1919
return await result.single()
2020

2121

22+
@unit_of_work(timeout=3)
23+
async def read_graph_package(tx, query, package_name, max_depth):
24+
result = await tx.run(
25+
query,
26+
package_name=package_name,
27+
max_depth=max_depth
28+
)
29+
return await result.single()
30+
31+
2232
async def create_repository(repository: dict[str, Any]) -> str:
2333
query = """
2434
MATCH(u:User) WHERE u._id = $user_id
@@ -84,7 +94,7 @@ async def read_repository_by_id(repository_id: str) -> dict[str, str]:
8494
return record[0] if record else None
8595

8696

87-
async def read_graph_for_info_operation(
97+
async def read_graph_for_req_file_info_operation(
8898
node_type: str,
8999
requirement_file_id: str,
90100
max_depth: int
@@ -151,7 +161,7 @@ async def read_graph_for_info_operation(
151161
try:
152162
async with get_graph_db_driver().session() as session:
153163
record = await session.execute_read(
154-
read_graph,
164+
read_graph_req_file,
155165
query,
156166
requirement_file_id,
157167
max_depth
@@ -167,6 +177,88 @@ async def read_graph_for_info_operation(
167177
raise MemoryOutException() from err
168178

169179

180+
async def read_graph_for_package_info_operation(
181+
node_type: str,
182+
package_name: str,
183+
max_depth: int
184+
) -> dict[str, Any]:
185+
query = f"""
186+
MATCH (p:{node_type}{{name:$package_name}})
187+
CALL apoc.path.expandConfig(
188+
p,
189+
{{
190+
relationshipFilter: 'REQUIRE>|HAVE>',
191+
labelFilter: 'Version|{node_type}',
192+
maxLevel: $max_depth,
193+
bfs: true,
194+
uniqueness: 'NODE_GLOBAL'
195+
}}
196+
) YIELD path
197+
WITH
198+
last(nodes(path)) AS pkg,
199+
(length(path) - 1) / 2 AS depth,
200+
last(relationships(path)) AS rel
201+
WHERE '{node_type}' IN labels(pkg) AND type(rel) = 'REQUIRE'
202+
OPTIONAL MATCH (pkg:{node_type})-[:HAVE]->(v:Version)
203+
WITH
204+
pkg,
205+
depth,
206+
collect(DISTINCT {{
207+
name: v.name,
208+
mean: v.mean,
209+
serial_number: v.serial_number,
210+
weighted_mean: v.weighted_mean,
211+
vulnerability_count: v.vulnerabilities
212+
}}) AS versions,
213+
rel.constraints AS constraints
214+
WITH {{
215+
package_name: pkg.name,
216+
package_vendor: pkg.vendor,
217+
package_constraints: constraints,
218+
versions: versions
219+
}} AS enriched_pkg,
220+
depth
221+
WITH
222+
collect(CASE WHEN depth = 0 THEN enriched_pkg END) AS direct_deps,
223+
collect(CASE WHEN depth > 1 THEN {{node: enriched_pkg, depth: depth}} END) AS indirect_info
224+
WITH
225+
direct_deps,
226+
indirect_info,
227+
reduce(
228+
map = {{}},
229+
entry IN indirect_info |
230+
apoc.map.setKey(
231+
map,
232+
toString(entry.depth),
233+
coalesce(map[toString(entry.depth)], []) + entry.node
234+
)
235+
) AS indirect_by_depth
236+
RETURN {{
237+
direct_dependencies: direct_deps,
238+
total_direct_dependencies: size(direct_deps),
239+
indirect_dependencies_by_depth: apoc.map.removeKey(indirect_by_depth, null),
240+
total_indirect_dependencies: size(indirect_info)
241+
}}
242+
"""
243+
try:
244+
async with get_graph_db_driver().session() as session:
245+
record = await session.execute_read(
246+
read_graph_package,
247+
query,
248+
package_name,
249+
max_depth
250+
)
251+
return record[0] if record else None
252+
except Neo4jError as err:
253+
code = getattr(err, "code", "") or ""
254+
if (
255+
code == "Neo.TransientError.General.MemoryPoolOutOfMemoryError"
256+
or code == "Neo.ClientError.Transaction.TransactionTimedOutClientConfiguration"
257+
or code == "Neo.ClientError.Transaction.TransactionTimedOut"
258+
):
259+
raise MemoryOutException() from err
260+
261+
170262
async def read_data_for_smt_transform(
171263
requirement_file_id: str,
172264
max_depth: int
@@ -205,7 +297,7 @@ async def read_data_for_smt_transform(
205297
try:
206298
async with get_graph_db_driver().session() as session:
207299
record = await session.execute_read(
208-
read_graph,
300+
read_graph_req_file,
209301
query,
210302
requirement_file_id,
211303
max_depth

0 commit comments

Comments
 (0)