Skip to content

Commit bcc2a96

Browse files
committed
feat(api): Refactor validating manifests
feat(api): Refactor campaign and node routes
1 parent 9102836 commit bcc2a96

6 files changed

Lines changed: 140 additions & 87 deletions

File tree

Lines changed: 90 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,101 @@
1-
"""Module for models representing generic CM Service manifests."""
1+
"""Module for models representing generic CM Service manifests.
22
3-
from pydantic import AliasChoices
4-
from sqlmodel import Field, SQLModel
3+
These manifests are used in APIs, especially when creating resources. They do
4+
not necessarily represent the object's database or ORM model.
5+
"""
56

6-
from ..common.enums import ManifestKind
7+
from typing import Self
8+
9+
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, ValidationInfo, model_validator
10+
11+
from ..common.enums import DEFAULT_NAMESPACE, ManifestKind
712
from ..common.types import KindField
813

914

10-
class ManifestWrapper(SQLModel):
11-
"""A model for an object's Manifest wrapper, used by APIs where the `spec`
12-
should be the kind's table model, more or less.
15+
class Manifest[MetadataT, SpecT](BaseModel):
16+
"""A parameterized model for an object's Manifest, used by APIs where the
17+
`spec` should be the kind's table model, more or less.
1318
"""
1419

1520
apiversion: str = Field(default="io.lsst.cmservice/v1")
1621
kind: KindField = Field(default=ManifestKind.other)
17-
metadata_: dict = Field(
18-
default_factory=dict,
19-
schema_extra={
20-
"validation_alias": AliasChoices("metadata", "metadata_"),
21-
"serialization_alias": "metadata",
22-
},
22+
metadata_: MetadataT = Field(
23+
validation_alias=AliasChoices("metadata", "metadata_"),
24+
serialization_alias="metadata",
2325
)
24-
spec: dict = Field(
25-
default_factory=dict,
26-
schema_extra={
27-
"validation_alias": AliasChoices("spec", "configuration", "data"),
28-
"serialization_alias": "spec",
29-
},
26+
spec: SpecT = Field(
27+
validation_alias=AliasChoices("spec", "configuration", "data"),
28+
serialization_alias="spec",
3029
)
30+
31+
32+
class ManifestMetadata(BaseModel):
33+
"""Generic metadata model for Manifests.
34+
35+
Conventionally denormalized fields are excluded from the model_dump when
36+
serialized for ORM use.
37+
"""
38+
39+
name: str
40+
namespace: str
41+
42+
43+
class ManifestSpec(BaseModel):
44+
"""Generic spec model for Manifests.
45+
46+
Notes
47+
-----
48+
Any spec body is allowed via config, but any fields that aren't first-class
49+
fields won't be subject to validation or available as model attributes
50+
except in the ``__pydantic_extra__`` dictionary. The full spec will be
51+
expressed via ``model_dump()``.
52+
"""
53+
54+
model_config = ConfigDict(extra="allow")
55+
56+
57+
class VersionedMetadata(ManifestMetadata):
58+
"""Metadata model for versioned Manifests."""
59+
60+
version: int = 0
61+
62+
63+
class ManifestModelMetadata(VersionedMetadata):
64+
"""Manifest model for general Manifests. These manifests are versioned but
65+
a namespace is optional.
66+
"""
67+
68+
namespace: str = Field(default=str(DEFAULT_NAMESPACE))
69+
70+
71+
class ManifestModel(Manifest[ManifestModelMetadata, ManifestSpec]):
72+
"""Manifest model for generic Manifest handling."""
73+
74+
@model_validator(mode="after")
75+
def custom_model_validator(self, info: ValidationInfo) -> Self:
76+
"""Validate an Campaign Manifest after a model has been created."""
77+
if self.kind in [ManifestKind.campaign, ManifestKind.node, ManifestKind.edge]:
78+
raise ValueError(f"Manifests may not be a {self.kind.name} kind.")
79+
80+
return self
81+
82+
83+
class CampaignMetadata(BaseModel):
84+
"""Metadata model for a Campaign Manifest.
85+
86+
Campaign metadata does not require a namespace field.
87+
"""
88+
89+
name: str
90+
91+
92+
class CampaignManifest(Manifest[CampaignMetadata, ManifestSpec]):
93+
"""validating model for campaigns"""
94+
95+
@model_validator(mode="after")
96+
def custom_model_validator(self, info: ValidationInfo) -> Self:
97+
"""Validate an Campaign Manifest after a model has been created."""
98+
if self.kind is not ManifestKind.campaign:
99+
raise ValueError("Campaigns may only be created from a <campaign> manifest")
100+
101+
return self

src/lsst/cmservice/routers/v2/campaigns.py

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,9 @@
1313
from sqlmodel import col, select
1414
from sqlmodel.ext.asyncio.session import AsyncSession
1515

16-
from ...common.enums import ManifestKind
1716
from ...common.logging import LOGGER
18-
from ...db.campaigns_v2 import Campaign, CampaignModel, CampaignUpdate, Edge, Node
19-
from ...db.manifests_v2 import ManifestWrapper
17+
from ...db.campaigns_v2 import Campaign, CampaignUpdate, Edge, Node
18+
from ...db.manifests_v2 import CampaignManifest
2019
from ...db.session import db_session_dependency
2120

2221
# TODO should probably bind a logger to the fastapi app or something
@@ -40,7 +39,7 @@ async def read_campaign_collection(
4039
session: Annotated[AsyncSession, Depends(db_session_dependency)],
4140
limit: Annotated[int, Query(le=100)] = 10,
4241
offset: Annotated[int, Query()] = 0,
43-
) -> Sequence[CampaignModel]:
42+
) -> Sequence[Campaign]:
4443
"""..."""
4544
try:
4645
campaigns = await session.exec(select(Campaign).offset(offset).limit(limit))
@@ -300,32 +299,23 @@ async def create_campaign_resource(
300299
request: Request,
301300
response: Response,
302301
session: Annotated[AsyncSession, Depends(db_session_dependency)],
303-
manifest: ManifestWrapper,
302+
manifest: CampaignManifest,
304303
) -> Campaign:
305-
# Validate the input by checking the "kind" of manifest is a campaign
306-
if manifest.kind is not ManifestKind.campaign:
307-
raise HTTPException(
308-
status_code=422, detail="Campaigns may only be created from a 'campaign' manifest"
309-
)
310-
# and that the manifest includes any required fields, though this could
311-
# just as well be a try/except ValueError around `_.model_validate()`
312-
elif (campaign_name := manifest.metadata_.pop("name", None)) is None:
313-
raise HTTPException(status_code=400, detail="Campaigns must have a name set in '.metadata.name'")
314-
315304
# Create a campaign spec from the manifest, delegating the creation of new
316305
# dynamic fields to the model validation method, -OR- create new dynamic
317306
# fields here.
318307
campaign = Campaign.model_validate(
319308
dict(
320-
name=campaign_name,
321-
metadata_=manifest.metadata_,
309+
name=manifest.metadata_.name,
310+
metadata_=manifest.metadata_.model_dump(),
322311
# owner = ... # TODO Get username from gafaelfawr # noqa: ERA001
323312
)
324313
)
325314

326315
# A new campaign comes with a START and END node
327316
start_node = Node.model_validate(dict(name="START", namespace=campaign.id))
328317
end_node = Node.model_validate(dict(name="END", namespace=campaign.id))
318+
329319
# Put the campaign in the database
330320
session.add(campaign)
331321
session.add(start_node)

src/lsst/cmservice/routers/v2/manifests.py

Lines changed: 12 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,10 @@
1212
from sqlmodel import col, select
1313
from sqlmodel.ext.asyncio.session import AsyncSession
1414

15-
from ...common.enums import ManifestKind
1615
from ...common.jsonpatch import JSONPatch, JSONPatchError, apply_json_patch
1716
from ...common.logging import LOGGER
1817
from ...db.campaigns_v2 import Campaign, Manifest, _default_campaign_namespace
19-
from ...db.manifests_v2 import ManifestWrapper
18+
from ...db.manifests_v2 import ManifestModel
2019
from ...db.session import db_session_dependency
2120

2221
# TODO should probably bind a logger to the fastapi app or something
@@ -103,39 +102,23 @@ async def read_single_resource(
103102
summary="Add a manifest resource",
104103
status_code=204,
105104
)
106-
async def create_one_or_more_resources(
105+
async def create_one_or_more_manifests(
107106
request: Request,
108107
response: Response,
109108
session: Annotated[AsyncSession, Depends(db_session_dependency)],
110-
manifests: ManifestWrapper | list[ManifestWrapper],
109+
manifests: ManifestModel | list[ManifestModel],
111110
) -> None:
112-
# TODO should support query parameters that scope the namespace, such that
113-
# response headers from a campaign-create operation can immediately
114-
# follow a link to node-create for that campaign.
115-
116111
# We could be given a single manifest or a list of them. In the singleton
117112
# case, wrap it in a list so we can treat everything equally
118113
if not isinstance(manifests, list):
119114
manifests = [manifests]
120115

121116
for manifest in manifests:
122-
# Validate the input by checking the "kind" of manifest.
123-
# The difference between a "manifest" and a "node" is iffy, but all we
124-
# want to assert here is that nodes, campaigns, and edges don't go in
125-
# the manifest table
126-
if manifest.kind in [ManifestKind.campaign, ManifestKind.node, ManifestKind.edge]:
127-
raise HTTPException(status_code=422, detail=f"Manifests may not be a {manifest.kind.name} kind.")
128-
129-
# and that the manifest includes any required fields, though this could
130-
# just as well be a try/except ValueError around `_.model_validate()`
131-
elif (_name := manifest.metadata_.pop("name", None)) is None:
132-
raise HTTPException(status_code=400, detail="Manifests must have a name set in '.metadata.name'")
133-
134-
# TODO match node with jsonschema and validate
117+
_name = manifest.metadata_.name
135118

136119
# A manifest must exist in the namespace of an existing campaign
137120
# or the default namespace
138-
_namespace: str | None = manifest.metadata_.pop("namespace", None)
121+
_namespace: str | None = manifest.metadata_.namespace
139122
if _namespace is None:
140123
_namespace_uuid = _default_campaign_namespace
141124
else:
@@ -153,9 +136,8 @@ async def create_one_or_more_resources(
153136
raise HTTPException(status_code=422, detail="Requested namespace does not exist.")
154137
_namespace_uuid = _campaign_id
155138

156-
# A node must be a new version if name+namespace already exists
157-
# - check db for node as name+namespace, get version and increment
158-
_version = int(manifest.metadata_.pop("version", 0))
139+
# A manifest must be a new version if name+namespace already exists
140+
# check db for manifest as name+namespace, get version and increment
159141

160142
s = (
161143
select(Manifest)
@@ -164,18 +146,18 @@ async def create_one_or_more_resources(
164146
.order_by(col(Manifest.version).desc())
165147
.limit(1)
166148
)
167-
_previous = (await session.exec(s)).one_or_none()
168149

169-
_version = _previous.version if _previous else _version
150+
_previous = (await session.exec(s)).one_or_none()
151+
_version = _previous.version if _previous else manifest.metadata_.version
170152
_version += 1
171153
_manifest = Manifest(
172154
id=uuid5(_namespace_uuid, f"{_name}.{_version}"),
173155
name=_name,
174156
namespace=_namespace_uuid,
175157
kind=manifest.kind,
176158
version=_version,
177-
metadata_=manifest.metadata_,
178-
spec=manifest.spec,
159+
metadata_=manifest.metadata_.model_dump(),
160+
spec=manifest.spec.model_dump(),
179161
)
180162

181163
# Put the node in the database
@@ -214,7 +196,7 @@ async def update_manifest_resource(
214196
- This API always targets the latest version of a manifest when applying
215197
a patch. This requires and maintains a "linear" sequence of versions;
216198
it is not permissible to "patch" a previous version and create a "tree"-
217-
like history of manifests. For exmaple, every manifest may be diffed
199+
like history of manifests. For example, every manifest may be diffed
218200
against any previous version without having to consider branches.
219201
"""
220202
use_rfc6902 = False

tests/v2/conftest.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@
44
import os
55
from collections.abc import AsyncGenerator, Generator
66
from typing import TYPE_CHECKING
7-
from uuid import uuid4
7+
from uuid import NAMESPACE_DNS, uuid4
88

99
import pytest
1010
import pytest_asyncio
1111
from fastapi.testclient import TestClient
1212
from httpx import ASGITransport, AsyncClient
13+
from sqlalchemy import insert
1314
from sqlalchemy.pool import NullPool
1415
from sqlalchemy.schema import CreateSchema, DropSchema
1516
from testcontainers.postgres import PostgresContainer
@@ -86,6 +87,14 @@ async def testdb(rawdb: DatabaseSessionDependency) -> AsyncGenerator[DatabaseSes
8687
await aconn.run_sync(metadata.drop_all)
8788
await aconn.execute(CreateSchema(config.db.table_schema, if_not_exists=True))
8889
await aconn.run_sync(metadata.create_all)
90+
await aconn.execute(
91+
insert(metadata.tables[f"{metadata.schema}.campaigns_v2"]).values(
92+
id="dda54a0c-6878-5c95-ac4f-007f6808049e",
93+
namespace=str(NAMESPACE_DNS),
94+
name="DEFAULT",
95+
owner="root",
96+
)
97+
)
8998
await aconn.commit()
9099
yield rawdb
91100
async with rawdb.engine.begin() as aconn:

tests/v2/test_campaign_routes.py

Lines changed: 15 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,20 @@
33
from uuid import NAMESPACE_DNS, uuid4, uuid5
44

55
import pytest
6-
from httpx import AsyncClient, HTTPStatusError
6+
from httpx import AsyncClient
77

88
pytestmark = pytest.mark.asyncio(loop_scope="module")
99
"""All tests in this module will run in the same event loop."""
1010

1111

12-
async def test_async_list_campaigns(aclient: AsyncClient) -> None:
12+
async def test_list_campaigns(aclient: AsyncClient) -> None:
13+
"""Tests listing the set of all campaigns."""
14+
# initially, only the default campaign should be available.
1315
x = await aclient.get("/cm-service/v2/campaigns")
14-
assert len(x.json()) == 0
15-
assert True
16+
assert len(x.json()) == 1
1617

1718

18-
async def test_async_list_campaign(aclient: AsyncClient) -> None:
19+
async def test_list_campaign(aclient: AsyncClient) -> None:
1920
"""Tests lookup of a single campaign by name and by ID"""
2021
campaign_name = uuid4().hex[-8:]
2122

@@ -47,9 +48,11 @@ async def test_async_list_campaign(aclient: AsyncClient) -> None:
4748
assert x.is_success
4849

4950

50-
async def test_async_create_campaign(aclient: AsyncClient) -> None:
51+
async def test_negative_campaign(aclient: AsyncClient) -> None:
52+
"""Tests campaign api negative results."""
5153
# Test failure to create campaign with invalid manifest (wrong kind)
5254
campaign_name = uuid4().hex[-8:]
55+
5356
x = await aclient.post(
5457
"/cm-service/v2/campaigns",
5558
json={
@@ -61,11 +64,7 @@ async def test_async_create_campaign(aclient: AsyncClient) -> None:
6164
"spec": {},
6265
},
6366
)
64-
with pytest.raises(HTTPStatusError):
65-
assert x.status_code == 422
66-
x.raise_for_status()
67-
68-
del x
67+
assert x.is_client_error
6968

7069
# Test failure to create campaign with incomplete manifest (missing name)
7170
x = await aclient.post(
@@ -77,11 +76,11 @@ async def test_async_create_campaign(aclient: AsyncClient) -> None:
7776
"spec": {},
7877
},
7978
)
80-
with pytest.raises(HTTPStatusError):
81-
assert x.status_code == 400
82-
x.raise_for_status()
79+
assert x.is_client_error
8380

84-
del x
81+
82+
async def test_create_campaign(aclient: AsyncClient) -> None:
83+
campaign_name = uuid4().hex[-8:]
8584

8685
# Test successful campaign creation
8786
x = await aclient.post(
@@ -93,7 +92,6 @@ async def test_async_create_campaign(aclient: AsyncClient) -> None:
9392
"spec": {},
9493
},
9594
)
96-
9795
assert x.is_success
9896

9997
campaign = x.json()
@@ -124,7 +122,7 @@ async def test_async_create_campaign(aclient: AsyncClient) -> None:
124122
assert len(edges) == 0
125123

126124

127-
async def test_async_patch_campaign(aclient: AsyncClient) -> None:
125+
async def test_patch_campaign(aclient: AsyncClient) -> None:
128126
# Create a new campaign with spec data
129127
campaign_name = uuid4().hex[-8:]
130128
x = await aclient.post(

0 commit comments

Comments
 (0)