Enhanced query endpoints with filtering and global artifacts/tags APIs (#18)

- Add search and tag filters to /api/v1/uploads endpoint
- Add sort/order parameters to /api/v1/uploads endpoint
- Add min_size/max_size filters to package artifacts endpoint
- Add sort/order parameters to package artifacts endpoint
- Add from/to date filters to tags endpoint
- Add global /api/v1/artifacts endpoint with project/package/tag/size/date filters
- Add global /api/v1/tags endpoint with project/package/search/date filters
- Add GlobalArtifactResponse and GlobalTagResponse schemas
This commit is contained in:
Mondo Diaz
2026-01-06 15:57:57 -06:00
parent 8490c50e9c
commit 55517220cd
2 changed files with 341 additions and 10 deletions

View File

@@ -92,6 +92,8 @@ from .schemas import (
CrossProjectDeduplicationResponse, CrossProjectDeduplicationResponse,
TimeBasedStatsResponse, TimeBasedStatsResponse,
StatsReportResponse, StatsReportResponse,
GlobalArtifactResponse,
GlobalTagResponse,
) )
from .metadata import extract_metadata from .metadata import extract_metadata
from .config import get_settings from .config import get_settings
@@ -2034,6 +2036,12 @@ def list_tags(
search: Optional[str] = Query(default=None, description="Search by tag name"), search: Optional[str] = Query(default=None, description="Search by tag name"),
sort: str = Query(default="name", description="Sort field (name, created_at)"), sort: str = Query(default="name", description="Sort field (name, created_at)"),
order: str = Query(default="asc", description="Sort order (asc, desc)"), order: str = Query(default="asc", description="Sort order (asc, desc)"),
from_date: Optional[datetime] = Query(
default=None, alias="from", description="Filter tags created after this date"
),
to_date: Optional[datetime] = Query(
default=None, alias="to", description="Filter tags created before this date"
),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
project = db.query(Project).filter(Project.name == project_name).first() project = db.query(Project).filter(Project.name == project_name).first()
@@ -2079,6 +2087,12 @@ def list_tags(
) )
) )
# Apply date range filters
if from_date:
query = query.filter(Tag.created_at >= from_date)
if to_date:
query = query.filter(Tag.created_at <= to_date)
# Get total count before pagination # Get total count before pagination
total = query.count() total = query.count()
@@ -2455,9 +2469,19 @@ def list_package_artifacts(
created_before: Optional[datetime] = Query( created_before: Optional[datetime] = Query(
default=None, description="Filter artifacts created before this date" default=None, description="Filter artifacts created before this date"
), ),
min_size: Optional[int] = Query(
default=None, ge=0, description="Minimum artifact size in bytes"
),
max_size: Optional[int] = Query(
default=None, ge=0, description="Maximum artifact size in bytes"
),
sort: Optional[str] = Query(
default=None, description="Sort field: created_at, size, original_name"
),
order: Optional[str] = Query(default="desc", description="Sort order: asc or desc"),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""List all unique artifacts uploaded to a package""" """List all unique artifacts uploaded to a package with filtering and sorting."""
project = db.query(Project).filter(Project.name == project_name).first() project = db.query(Project).filter(Project.name == project_name).first()
if not project: if not project:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
@@ -2489,14 +2513,38 @@ def list_package_artifacts(
if created_before: if created_before:
query = query.filter(Artifact.created_at <= created_before) query = query.filter(Artifact.created_at <= created_before)
# Apply size range filters
if min_size is not None:
query = query.filter(Artifact.size >= min_size)
if max_size is not None:
query = query.filter(Artifact.size <= max_size)
# Validate and apply sorting
valid_sort_fields = {
"created_at": Artifact.created_at,
"size": Artifact.size,
"original_name": Artifact.original_name,
}
if sort and sort not in valid_sort_fields:
raise HTTPException(
status_code=400,
detail=f"Invalid sort field. Valid options: {', '.join(valid_sort_fields.keys())}",
)
sort_column = valid_sort_fields.get(sort, Artifact.created_at)
if order and order.lower() not in ("asc", "desc"):
raise HTTPException(
status_code=400, detail="Invalid order. Valid options: asc, desc"
)
sort_order = (
sort_column.asc() if order and order.lower() == "asc" else sort_column.desc()
)
# Get total count before pagination # Get total count before pagination
total = query.count() total = query.count()
# Apply pagination # Apply pagination
offset = (page - 1) * limit offset = (page - 1) * limit
artifacts = ( artifacts = query.order_by(sort_order).offset(offset).limit(limit).all()
query.order_by(Artifact.created_at.desc()).offset(offset).limit(limit).all()
)
# Calculate total pages # Calculate total pages
total_pages = math.ceil(total / limit) if total > 0 else 1 total_pages = math.ceil(total / limit) if total > 0 else 1
@@ -2537,6 +2585,224 @@ def list_package_artifacts(
) )
# Global artifacts listing
@router.get(
"/api/v1/artifacts",
response_model=PaginatedResponse[GlobalArtifactResponse],
)
def list_all_artifacts(
project: Optional[str] = Query(None, description="Filter by project name"),
package: Optional[str] = Query(None, description="Filter by package name"),
tag: Optional[str] = Query(None, description="Filter by tag name"),
content_type: Optional[str] = Query(None, description="Filter by content type"),
min_size: Optional[int] = Query(None, ge=0, description="Minimum size in bytes"),
max_size: Optional[int] = Query(None, ge=0, description="Maximum size in bytes"),
from_date: Optional[datetime] = Query(
None, alias="from", description="Created after"
),
to_date: Optional[datetime] = Query(None, alias="to", description="Created before"),
sort: Optional[str] = Query(None, description="Sort field: created_at, size"),
order: Optional[str] = Query("desc", description="Sort order: asc or desc"),
page: int = Query(1, ge=1),
limit: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db),
):
"""
List all artifacts globally with filtering by project, package, tag, etc.
Returns artifacts with context about which projects/packages/tags reference them.
"""
# Start with base query
query = db.query(Artifact)
# If filtering by project/package/tag, need to join through tags
if project or package or tag:
# Subquery to get artifact IDs that match the filters
tag_query = (
db.query(Tag.artifact_id)
.join(Package, Tag.package_id == Package.id)
.join(Project, Package.project_id == Project.id)
)
if project:
tag_query = tag_query.filter(Project.name == project)
if package:
tag_query = tag_query.filter(Package.name == package)
if tag:
tag_query = tag_query.filter(Tag.name == tag)
artifact_ids = tag_query.distinct().subquery()
query = query.filter(Artifact.id.in_(artifact_ids))
# Apply content type filter
if content_type:
query = query.filter(Artifact.content_type == content_type)
# Apply size filters
if min_size is not None:
query = query.filter(Artifact.size >= min_size)
if max_size is not None:
query = query.filter(Artifact.size <= max_size)
# Apply date filters
if from_date:
query = query.filter(Artifact.created_at >= from_date)
if to_date:
query = query.filter(Artifact.created_at <= to_date)
# Validate and apply sorting
valid_sort_fields = {"created_at": Artifact.created_at, "size": Artifact.size}
if sort and sort not in valid_sort_fields:
raise HTTPException(
status_code=400,
detail=f"Invalid sort field. Valid options: {', '.join(valid_sort_fields.keys())}",
)
sort_column = valid_sort_fields.get(sort, Artifact.created_at)
if order and order.lower() not in ("asc", "desc"):
raise HTTPException(
status_code=400, detail="Invalid order. Valid options: asc, desc"
)
sort_order = (
sort_column.asc() if order and order.lower() == "asc" else sort_column.desc()
)
total = query.count()
total_pages = math.ceil(total / limit) if total > 0 else 1
artifacts = query.order_by(sort_order).offset((page - 1) * limit).limit(limit).all()
# Build responses with context
items = []
for artifact in artifacts:
# Get all tags referencing this artifact with project/package info
tags_info = (
db.query(Tag, Package, Project)
.join(Package, Tag.package_id == Package.id)
.join(Project, Package.project_id == Project.id)
.filter(Tag.artifact_id == artifact.id)
.all()
)
projects = list(set(proj.name for _, _, proj in tags_info))
packages = list(set(f"{proj.name}/{pkg.name}" for _, pkg, proj in tags_info))
tags = [f"{proj.name}/{pkg.name}:{t.name}" for t, pkg, proj in tags_info]
items.append(
GlobalArtifactResponse(
id=artifact.id,
sha256=artifact.id,
size=artifact.size,
content_type=artifact.content_type,
original_name=artifact.original_name,
created_at=artifact.created_at,
created_by=artifact.created_by,
format_metadata=artifact.artifact_metadata,
ref_count=artifact.ref_count,
projects=projects,
packages=packages,
tags=tags,
)
)
return PaginatedResponse(
items=items,
pagination=PaginationMeta(
page=page,
limit=limit,
total=total,
total_pages=total_pages,
has_more=page < total_pages,
),
)
# Global tags listing
@router.get(
"/api/v1/tags",
response_model=PaginatedResponse[GlobalTagResponse],
)
def list_all_tags(
project: Optional[str] = Query(None, description="Filter by project name"),
package: Optional[str] = Query(None, description="Filter by package name"),
search: Optional[str] = Query(None, description="Search by tag name"),
from_date: Optional[datetime] = Query(
None, alias="from", description="Created after"
),
to_date: Optional[datetime] = Query(None, alias="to", description="Created before"),
sort: Optional[str] = Query(None, description="Sort field: name, created_at"),
order: Optional[str] = Query("desc", description="Sort order: asc or desc"),
page: int = Query(1, ge=1),
limit: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db),
):
"""
List all tags globally with filtering by project, package, name, etc.
"""
query = (
db.query(Tag, Package, Project, Artifact)
.join(Package, Tag.package_id == Package.id)
.join(Project, Package.project_id == Project.id)
.join(Artifact, Tag.artifact_id == Artifact.id)
)
# Apply filters
if project:
query = query.filter(Project.name == project)
if package:
query = query.filter(Package.name == package)
if search:
query = query.filter(Tag.name.ilike(f"%{search}%"))
if from_date:
query = query.filter(Tag.created_at >= from_date)
if to_date:
query = query.filter(Tag.created_at <= to_date)
# Validate and apply sorting
valid_sort_fields = {"name": Tag.name, "created_at": Tag.created_at}
if sort and sort not in valid_sort_fields:
raise HTTPException(
status_code=400,
detail=f"Invalid sort field. Valid options: {', '.join(valid_sort_fields.keys())}",
)
sort_column = valid_sort_fields.get(sort, Tag.created_at)
if order and order.lower() not in ("asc", "desc"):
raise HTTPException(
status_code=400, detail="Invalid order. Valid options: asc, desc"
)
sort_order = (
sort_column.asc() if order and order.lower() == "asc" else sort_column.desc()
)
total = query.count()
total_pages = math.ceil(total / limit) if total > 0 else 1
results = query.order_by(sort_order).offset((page - 1) * limit).limit(limit).all()
items = [
GlobalTagResponse(
id=tag.id,
name=tag.name,
artifact_id=tag.artifact_id,
created_at=tag.created_at,
created_by=tag.created_by,
project_name=proj.name,
package_name=pkg.name,
artifact_size=artifact.size,
artifact_content_type=artifact.content_type,
)
for tag, pkg, proj, artifact in results
]
return PaginatedResponse(
items=items,
pagination=PaginationMeta(
page=page,
limit=limit,
total=total,
total_pages=total_pages,
has_more=page < total_pages,
),
)
# Artifact by ID # Artifact by ID
@router.get("/api/v1/artifact/{artifact_id}", response_model=ArtifactDetailResponse) @router.get("/api/v1/artifact/{artifact_id}", response_model=ArtifactDetailResponse)
def get_artifact(artifact_id: str, db: Session = Depends(get_db)): def get_artifact(artifact_id: str, db: Session = Depends(get_db)):
@@ -3744,6 +4010,12 @@ def list_all_uploads(
deduplicated: Optional[bool] = Query( deduplicated: Optional[bool] = Query(
None, description="Filter by deduplication status" None, description="Filter by deduplication status"
), ),
search: Optional[str] = Query(None, description="Search by original filename"),
tag: Optional[str] = Query(None, description="Filter by tag name"),
sort: Optional[str] = Query(
None, description="Sort field: uploaded_at, original_name, size"
),
order: Optional[str] = Query("desc", description="Sort order: asc or desc"),
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
limit: int = Query(20, ge=1, le=100), limit: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -3757,6 +4029,8 @@ def list_all_uploads(
- uploaded_by: Filter by user ID - uploaded_by: Filter by user ID
- from/to: Filter by timestamp range - from/to: Filter by timestamp range
- deduplicated: Filter by deduplication status - deduplicated: Filter by deduplication status
- search: Search by original filename (case-insensitive)
- tag: Filter by tag name
""" """
query = ( query = (
db.query(Upload, Package, Project, Artifact) db.query(Upload, Package, Project, Artifact)
@@ -3778,16 +4052,35 @@ def list_all_uploads(
query = query.filter(Upload.uploaded_at <= to_date) query = query.filter(Upload.uploaded_at <= to_date)
if deduplicated is not None: if deduplicated is not None:
query = query.filter(Upload.deduplicated == deduplicated) query = query.filter(Upload.deduplicated == deduplicated)
if search:
query = query.filter(Upload.original_name.ilike(f"%{search}%"))
if tag:
query = query.filter(Upload.tag_name == tag)
# Validate and apply sorting
valid_sort_fields = {
"uploaded_at": Upload.uploaded_at,
"original_name": Upload.original_name,
"size": Artifact.size,
}
if sort and sort not in valid_sort_fields:
raise HTTPException(
status_code=400,
detail=f"Invalid sort field. Valid options: {', '.join(valid_sort_fields.keys())}",
)
sort_column = valid_sort_fields.get(sort, Upload.uploaded_at)
if order and order.lower() not in ("asc", "desc"):
raise HTTPException(
status_code=400, detail="Invalid order. Valid options: asc, desc"
)
sort_order = (
sort_column.asc() if order and order.lower() == "asc" else sort_column.desc()
)
total = query.count() total = query.count()
total_pages = math.ceil(total / limit) if total > 0 else 1 total_pages = math.ceil(total / limit) if total > 0 else 1
results = ( results = query.order_by(sort_order).offset((page - 1) * limit).limit(limit).all()
query.order_by(Upload.uploaded_at.desc())
.offset((page - 1) * limit)
.limit(limit)
.all()
)
items = [ items = [
UploadHistoryResponse( UploadHistoryResponse(

View File

@@ -343,6 +343,44 @@ class PackageArtifactResponse(BaseModel):
from_attributes = True from_attributes = True
class GlobalArtifactResponse(BaseModel):
"""Artifact with project/package context for global listing"""
id: str
sha256: str
size: int
content_type: Optional[str]
original_name: Optional[str]
created_at: datetime
created_by: str
format_metadata: Optional[Dict[str, Any]] = None
ref_count: int = 0
# Context from tags/packages
projects: List[str] = [] # List of project names containing this artifact
packages: List[str] = [] # List of "project/package" paths
tags: List[str] = [] # List of "project/package:tag" references
class Config:
from_attributes = True
class GlobalTagResponse(BaseModel):
"""Tag with project/package context for global listing"""
id: UUID
name: str
artifact_id: str
created_at: datetime
created_by: str
project_name: str
package_name: str
artifact_size: Optional[int] = None
artifact_content_type: Optional[str] = None
class Config:
from_attributes = True
# Upload response # Upload response
class UploadResponse(BaseModel): class UploadResponse(BaseModel):
artifact_id: str artifact_id: str