Skip to content

Commit b7dbdd9

Browse files
committed
feat(api): Refactor campaign and node routes
1 parent 184e51b commit b7dbdd9

5 files changed

Lines changed: 48 additions & 67 deletions

File tree

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: 11 additions & 29 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

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(

tests/v2/test_manifest_routes.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,14 @@
99
"""All tests in this module will run in the same event loop."""
1010

1111

12-
async def test_async_list_manifests(aclient: AsyncClient) -> None:
12+
async def test_list_manifests(aclient: AsyncClient) -> None:
1313
x = await aclient.get("/cm-service/v2/manifests")
1414
assert x.is_success
1515
assert len(x.json()) == 0
1616
assert True
1717

1818

19-
async def test_async_load_manifests(aclient: AsyncClient) -> None:
19+
async def test_load_manifests(aclient: AsyncClient) -> None:
2020
campaign_name = uuid4().hex[-8:]
2121

2222
# Try to create a campaign manifest
@@ -82,6 +82,7 @@ async def test_async_load_manifests(aclient: AsyncClient) -> None:
8282
json={
8383
"kind": "campaign",
8484
"metadata": {"name": campaign_name},
85+
"spec": {},
8586
},
8687
)
8788
assert x.is_success
@@ -128,9 +129,10 @@ async def test_async_load_manifests(aclient: AsyncClient) -> None:
128129
assert x.is_success
129130
manifests = x.json()
130131
assert len(manifests) == 3
132+
assert manifests[-1]["spec"]["one"] == 1
131133

132134

133-
async def test_async_patch_manifest(aclient: AsyncClient) -> None:
135+
async def test_patch_manifest(aclient: AsyncClient) -> None:
134136
"""Tests partial update of manifests and single resource retrieval."""
135137
campaign_name = uuid4().hex[-8:]
136138
manifest_name = uuid4().hex[-8:]

0 commit comments

Comments
 (0)