Skip to content

Commit f7dc9bc

Browse files
committed
feat: Create Pydantic models for Search Pipeline Run API
1 parent 44babd7 commit f7dc9bc

6 files changed

Lines changed: 419 additions & 1 deletion

File tree

cloud_pipelines_backend/api_router.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,24 @@ def handle_item_already_exists_error(
125125
content={"message": str(exc)},
126126
)
127127

128+
@app.exception_handler(errors.ApiValidationError)
129+
def handle_api_validation_error(
130+
request: fastapi.Request, exc: errors.ApiValidationError
131+
):
132+
return fastapi.responses.JSONResponse(
133+
status_code=422,
134+
content={"detail": str(exc)},
135+
)
136+
137+
@app.exception_handler(NotImplementedError)
138+
def handle_not_implemented_error(
139+
request: fastapi.Request, exc: NotImplementedError
140+
):
141+
return fastapi.responses.JSONResponse(
142+
status_code=501,
143+
content={"detail": str(exc)},
144+
)
145+
128146
get_user_details_dependency = fastapi.Depends(user_details_getter)
129147

130148
def get_user_name(

cloud_pipelines_backend/api_server_sql.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from . import backend_types_sql as bts
1313
from . import component_structures as structures
1414
from . import errors
15+
from . import filter_query_models
1516

1617
if typing.TYPE_CHECKING:
1718
from cloud_pipelines.orchestration.storage_providers import (
@@ -169,10 +170,20 @@ def list(
169170
session: orm.Session,
170171
page_token: str | None = None,
171172
filter: str | None = None,
173+
filter_query: str | None = None,
172174
current_user: str | None = None,
173175
include_pipeline_names: bool = False,
174176
include_execution_stats: bool = False,
175177
) -> ListPipelineJobsResponse:
178+
if filter and filter_query:
179+
raise errors.MutuallyExclusiveFilterError(
180+
"Cannot use both 'filter' and 'filter_query'. Use one or the other."
181+
)
182+
183+
if filter_query:
184+
filter_query_models.FilterQuery.model_validate_json(filter_query)
185+
raise NotImplementedError("filter_query is not yet implemented.")
186+
176187
filter_value, offset = _resolve_filter_value(
177188
filter=filter,
178189
page_token=page_token,

cloud_pipelines_backend/errors.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,13 @@ class ItemAlreadyExistsError(Exception):
88

99
class PermissionError(Exception):
1010
pass
11+
12+
13+
class ApiValidationError(Exception):
14+
"""Base for all filter/annotation validation errors -> 422."""
15+
16+
pass
17+
18+
19+
class MutuallyExclusiveFilterError(ApiValidationError):
20+
pass
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
from __future__ import annotations
2+
3+
from typing import Annotated
4+
5+
import pydantic
6+
7+
NonEmptyStr = Annotated[str, pydantic.StringConstraints(min_length=1)]
8+
9+
10+
class _BaseModel(pydantic.BaseModel):
11+
model_config = {"extra": "forbid"}
12+
13+
14+
# --- Leaf argument models ---
15+
16+
17+
class KeyExists(_BaseModel):
18+
key: NonEmptyStr
19+
20+
21+
class ValueContains(_BaseModel):
22+
key: NonEmptyStr
23+
value_substring: NonEmptyStr
24+
25+
26+
class ValueIn(_BaseModel):
27+
key: NonEmptyStr
28+
values: list[NonEmptyStr] = pydantic.Field(min_length=1)
29+
30+
31+
class ValueEquals(_BaseModel):
32+
key: NonEmptyStr
33+
value: NonEmptyStr
34+
35+
36+
class TimeRange(_BaseModel):
37+
"""AwareDatetime requires timezone info (e.g. "2024-01-01T00:00:00Z").
38+
Naive datetimes like "2024-01-01T00:00:00" are rejected, preventing
39+
ambiguous timestamps that could silently resolve to the wrong timezone."""
40+
41+
key: NonEmptyStr
42+
start_time: pydantic.AwareDatetime
43+
end_time: pydantic.AwareDatetime | None = None
44+
45+
46+
# --- Predicate wrapper models (one field each) ---
47+
48+
49+
class KeyExistsPredicate(_BaseModel):
50+
key_exists: KeyExists
51+
52+
53+
class ValueContainsPredicate(_BaseModel):
54+
value_contains: ValueContains
55+
56+
57+
class ValueInPredicate(_BaseModel):
58+
value_in: ValueIn
59+
60+
61+
class ValueEqualsPredicate(_BaseModel):
62+
value_equals: ValueEquals
63+
64+
65+
class TimeRangePredicate(_BaseModel):
66+
time_range: TimeRange
67+
68+
69+
LeafPredicate = (
70+
KeyExistsPredicate
71+
| ValueContainsPredicate
72+
| ValueInPredicate
73+
| ValueEqualsPredicate
74+
| TimeRangePredicate
75+
)
76+
77+
78+
class NotPredicate(_BaseModel):
79+
not_: LeafPredicate = pydantic.Field(alias="not")
80+
81+
82+
class AndPredicate(_BaseModel):
83+
and_: list["Predicate"] = pydantic.Field(alias="and", min_length=1)
84+
85+
86+
class OrPredicate(_BaseModel):
87+
or_: list["Predicate"] = pydantic.Field(alias="or", min_length=1)
88+
89+
90+
Predicate = (
91+
KeyExistsPredicate
92+
| ValueContainsPredicate
93+
| ValueInPredicate
94+
| ValueEqualsPredicate
95+
| TimeRangePredicate
96+
| NotPredicate
97+
| AndPredicate
98+
| OrPredicate
99+
)
100+
101+
# Resolve forward reference to "Predicate" in recursive and/or models
102+
AndPredicate.model_rebuild()
103+
OrPredicate.model_rebuild()
104+
105+
106+
class FilterQuery(_BaseModel):
107+
"""Root: must be exactly one of {"and": [...]} or {"or": [...]}."""
108+
109+
and_: list[Predicate] | None = pydantic.Field(None, alias="and", min_length=1)
110+
or_: list[Predicate] | None = pydantic.Field(None, alias="or", min_length=1)
111+
112+
@pydantic.model_validator(mode="after")
113+
def _exactly_one_root_operator(self) -> FilterQuery:
114+
has_and = self.and_ is not None
115+
has_or = self.or_ is not None
116+
if has_and == has_or:
117+
raise ValueError("FilterQuery root must have exactly one of 'and' or 'or'.")
118+
return self

tests/test_api_server_sql.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22
import sqlalchemy
33
from sqlalchemy import orm
44

5+
from cloud_pipelines_backend import api_server_sql
56
from cloud_pipelines_backend import backend_types_sql as bts
67
from cloud_pipelines_backend import component_structures as structures
7-
from cloud_pipelines_backend import api_server_sql
8+
from cloud_pipelines_backend import errors
89

910

1011
class TestExecutionStatusSummary:
@@ -541,3 +542,36 @@ def test_text_search_raises(self):
541542
filter_value="some_text_without_colon",
542543
current_user=None,
543544
)
545+
546+
547+
class TestFilterQueryApiWiring:
548+
def test_filter_query_returns_not_implemented(self, session_factory, service):
549+
valid_json = '{"and": [{"key_exists": {"key": "team"}}]}'
550+
with session_factory() as session:
551+
with pytest.raises(NotImplementedError, match="not yet implemented"):
552+
service.list(
553+
session=session,
554+
filter_query=valid_json,
555+
)
556+
557+
def test_filter_query_validates_before_501(self, session_factory, service):
558+
from pydantic import ValidationError
559+
560+
invalid_json = '{"bad_key": "not_valid"}'
561+
with session_factory() as session:
562+
with pytest.raises(ValidationError):
563+
service.list(
564+
session=session,
565+
filter_query=invalid_json,
566+
)
567+
568+
def test_mutual_exclusivity_rejected(self, session_factory, service):
569+
with session_factory() as session:
570+
with pytest.raises(
571+
errors.MutuallyExclusiveFilterError, match="Cannot use both"
572+
):
573+
service.list(
574+
session=session,
575+
filter="created_by:alice",
576+
filter_query='{"and": [{"key_exists": {"key": "team"}}]}',
577+
)

0 commit comments

Comments
 (0)