Skip to content

Commit ceef2c4

Browse files
committed
refactor: add validation to storage, repo and session blueprints
1 parent c42f324 commit ceef2c4

File tree

7 files changed

+69
-74
lines changed

7 files changed

+69
-74
lines changed

components/renku_data_services/repositories/blueprints.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@
33
from dataclasses import dataclass
44
from urllib.parse import unquote
55

6-
from sanic import HTTPResponse, Request, json
6+
from sanic import HTTPResponse, Request
77
from sanic.response import JSONResponse
88

99
import renku_data_services.base_models as base_models
1010
from renku_data_services import errors
1111
from renku_data_services.base_api.auth import authenticate
1212
from renku_data_services.base_api.blueprint import BlueprintFactoryResponse, CustomBlueprint
1313
from renku_data_services.base_api.etag import extract_if_none_match
14+
from renku_data_services.base_models.validation import validated_json
1415
from renku_data_services.repositories import apispec
1516
from renku_data_services.repositories.apispec_base import RepositoryParams
1617
from renku_data_services.repositories.db import GitRepositoriesRepository
@@ -53,10 +54,7 @@ async def get_internal_gitlab_user() -> base_models.APIUser:
5354
if result.repository_metadata and result.repository_metadata.etag is not None
5455
else None
5556
)
56-
return json(
57-
apispec.RepositoryProviderMatch.model_validate(result).model_dump(exclude_none=True, mode="json"),
58-
headers=headers,
59-
)
57+
return validated_json(apispec.RepositoryProviderMatch, result, headers=headers)
6058

6159
return "/repositories/<repository_url>", ["GET"], _get_one_repository
6260

components/renku_data_services/session/blueprints.py

Lines changed: 11 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from dataclasses import dataclass
44
from datetime import UTC, datetime
55

6-
from sanic import HTTPResponse, Request, json
6+
from sanic import HTTPResponse, Request
77
from sanic.response import JSONResponse
88
from sanic_ext import validate
99
from ulid import ULID
@@ -12,6 +12,7 @@
1212
from renku_data_services import errors
1313
from renku_data_services.base_api.auth import authenticate, only_authenticated
1414
from renku_data_services.base_api.blueprint import BlueprintFactoryResponse, CustomBlueprint
15+
from renku_data_services.base_models.validation import validated_json
1516
from renku_data_services.session import apispec, models
1617
from renku_data_services.session.db import SessionRepository
1718

@@ -28,9 +29,7 @@ def get_all(self) -> BlueprintFactoryResponse:
2829

2930
async def _get_all(_: Request) -> JSONResponse:
3031
environments = await self.session_repo.get_environments()
31-
return json(
32-
[apispec.Environment.model_validate(e).model_dump(exclude_none=True, mode="json") for e in environments]
33-
)
32+
return validated_json(apispec.EnvironmentList, environments)
3433

3534
return "/environments", ["GET"], _get_all
3635

@@ -39,7 +38,7 @@ def get_one(self) -> BlueprintFactoryResponse:
3938

4039
async def _get_one(_: Request, environment_id: ULID) -> JSONResponse:
4140
environment = await self.session_repo.get_environment(environment_id=environment_id)
42-
return json(apispec.Environment.model_validate(environment).model_dump(exclude_none=True, mode="json"))
41+
return validated_json(apispec.Environment, environment)
4342

4443
return "/environments/<environment_id:ulid>", ["GET"], _get_one
4544

@@ -59,7 +58,7 @@ async def _post(_: Request, user: base_models.APIUser, body: apispec.Environment
5958
creation_date=datetime.now(UTC).replace(microsecond=0),
6059
)
6160
environment = await self.session_repo.insert_environment(user=user, new_environment=environment_model)
62-
return json(apispec.Environment.model_validate(environment).model_dump(exclude_none=True, mode="json"), 201)
61+
return validated_json(apispec.Environment, environment, 201)
6362

6463
return "/environments", ["POST"], _post
6564

@@ -75,7 +74,7 @@ async def _patch(
7574
environment = await self.session_repo.update_environment(
7675
user=user, environment_id=environment_id, **body_dict
7776
)
78-
return json(apispec.Environment.model_validate(environment).model_dump(exclude_none=True, mode="json"))
77+
return validated_json(apispec.Environment, environment)
7978

8079
return "/environments/<environment_id:ulid>", ["PATCH"], _patch
8180

@@ -103,12 +102,7 @@ def get_all(self) -> BlueprintFactoryResponse:
103102
@authenticate(self.authenticator)
104103
async def _get_all(_: Request, user: base_models.APIUser) -> JSONResponse:
105104
launchers = await self.session_repo.get_launchers(user=user)
106-
return json(
107-
[
108-
apispec.SessionLauncher.model_validate(item).model_dump(exclude_none=True, mode="json")
109-
for item in launchers
110-
]
111-
)
105+
return validated_json(apispec.SessionLaunchersList, launchers)
112106

113107
return "/session_launchers", ["GET"], _get_all
114108

@@ -118,7 +112,7 @@ def get_one(self) -> BlueprintFactoryResponse:
118112
@authenticate(self.authenticator)
119113
async def _get_one(_: Request, user: base_models.APIUser, launcher_id: ULID) -> JSONResponse:
120114
launcher = await self.session_repo.get_launcher(user=user, launcher_id=launcher_id)
121-
return json(apispec.SessionLauncher.model_validate(launcher).model_dump(exclude_none=True, mode="json"))
115+
return validated_json(apispec.SessionLauncher, launcher)
122116

123117
return "/session_launchers/<launcher_id:ulid>", ["GET"], _get_one
124118

@@ -150,9 +144,7 @@ async def _post(_: Request, user: base_models.APIUser, body: apispec.SessionLaun
150144
creation_date=datetime.now(UTC).replace(microsecond=0),
151145
)
152146
launcher = await self.session_repo.insert_launcher(user=user, new_launcher=launcher_model)
153-
return json(
154-
apispec.SessionLauncher.model_validate(launcher).model_dump(exclude_none=True, mode="json"), 201
155-
)
147+
return validated_json(apispec.SessionLauncher, launcher, 201)
156148

157149
return "/session_launchers", ["POST"], _post
158150

@@ -166,7 +158,7 @@ async def _patch(
166158
) -> JSONResponse:
167159
body_dict = body.model_dump(exclude_none=True)
168160
launcher = await self.session_repo.update_launcher(user=user, launcher_id=launcher_id, **body_dict)
169-
return json(apispec.SessionLauncher.model_validate(launcher).model_dump(exclude_none=True, mode="json"))
161+
return validated_json(apispec.SessionLauncher, launcher)
170162

171163
return "/session_launchers/<launcher_id:ulid>", ["PATCH"], _patch
172164

@@ -186,11 +178,6 @@ def get_project_launchers(self) -> BlueprintFactoryResponse:
186178
@authenticate(self.authenticator)
187179
async def _get_launcher(_: Request, user: base_models.APIUser, project_id: ULID) -> JSONResponse:
188180
launchers = await self.session_repo.get_project_launchers(user=user, project_id=project_id)
189-
return json(
190-
[
191-
apispec.SessionLauncher.model_validate(item).model_dump(exclude_none=True, mode="json")
192-
for item in launchers
193-
]
194-
)
181+
return validated_json(apispec.SessionLaunchersList, launchers)
195182

196183
return "/projects/<project_id:ulid>/session_launchers", ["GET"], _get_launcher

components/renku_data_services/session/models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ class UnsavedEnvironment(BaseModel):
4141
class Environment(UnsavedEnvironment): # type: ignore[misc]
4242
"""Session environment model."""
4343

44-
id: str
44+
id: ULID
4545

4646

4747
@dataclass(frozen=True, eq=True, kw_only=True)

components/renku_data_services/session/orm.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from renku_data_services.crc.orm import ResourceClassORM
1111
from renku_data_services.project.orm import ProjectORM
1212
from renku_data_services.session import models
13+
from renku_data_services.utils.sqlalchemy import ULIDType
1314

1415
metadata_obj = MetaData(schema="sessions") # Has to match alembic ini section name
1516

@@ -25,7 +26,7 @@ class EnvironmentORM(BaseORM):
2526

2627
__tablename__ = "environments"
2728

28-
id: Mapped[str] = mapped_column("id", String(26), primary_key=True, default_factory=lambda: str(ULID()), init=False)
29+
id: Mapped[ULID] = mapped_column("id", ULIDType, primary_key=True, default_factory=lambda: str(ULID()), init=False)
2930
"""Id of this session environment object."""
3031

3132
name: Mapped[str] = mapped_column("name", String(99))

components/renku_data_services/storage/api.spec.yaml

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,7 @@ paths:
262262
content:
263263
application/json:
264264
schema:
265-
$ref: "#/components/schemas/RCloneConfig"
265+
$ref: "#/components/schemas/RCloneConfigValidate"
266266
responses:
267267
"204":
268268
description: The configuration is valid
@@ -346,6 +346,16 @@ components:
346346
nullable: true
347347
- type: boolean
348348
- type: object
349+
RCloneConfigValidate: #this is the same as RCloneConfig but duplicated so a class gets generated
350+
type: object
351+
description: Dictionary of rclone key:value pairs (based on schema from '/storage_schema')
352+
additionalProperties:
353+
oneOf:
354+
- type: integer
355+
- type: string
356+
nullable: true
357+
- type: boolean
358+
- type: object
349359
CloudStorageUrl:
350360
allOf:
351361
- $ref: "#/components/schemas/GitRequest"

components/renku_data_services/storage/apispec.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# generated by datamodel-codegen:
22
# filename: api.spec.yaml
3-
# timestamp: 2024-08-06T05:55:29+00:00
3+
# timestamp: 2024-08-09T12:39:58+00:00
44

55
from __future__ import annotations
66

@@ -11,6 +11,12 @@
1111
from renku_data_services.storage.apispec_base import BaseAPISpec
1212

1313

14+
class RCloneConfigValidate(
15+
RootModel[Optional[Dict[str, Union[int, Optional[str], bool, Dict[str, Any]]]]]
16+
):
17+
root: Optional[Dict[str, Union[int, Optional[str], bool, Dict[str, Any]]]] = None
18+
19+
1420
class Example(BaseAPISpec):
1521
value: Optional[str] = Field(
1622
None, description="a potential value for the option (think enum)"

components/renku_data_services/storage/blueprints.py

Lines changed: 34 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from dataclasses import dataclass
44
from typing import Any
55

6-
from sanic import HTTPResponse, Request, empty, json
6+
from sanic import HTTPResponse, Request, empty
77
from sanic.response import JSONResponse
88
from sanic_ext import validate
99
from ulid import ULID
@@ -13,6 +13,7 @@
1313
from renku_data_services.base_api.auth import authenticate
1414
from renku_data_services.base_api.blueprint import BlueprintFactoryResponse, CustomBlueprint
1515
from renku_data_services.base_api.misc import validate_query
16+
from renku_data_services.base_models.validation import validated_json
1617
from renku_data_services.storage import apispec, models
1718
from renku_data_services.storage.db import StorageRepository, StorageV2Repository
1819
from renku_data_services.storage.rclone import RCloneValidator
@@ -49,7 +50,9 @@ async def _get(
4950
storage: list[models.CloudStorage]
5051
storage = await self.storage_repo.get_storage(user=user, project_id=query.project_id)
5152

52-
return json([dump_storage_with_sensitive_fields(s, validator) for s in storage])
53+
return validated_json(
54+
apispec.StorageGetResponse, [dump_storage_with_sensitive_fields(s, validator) for s in storage]
55+
)
5356

5457
return "/storage", ["GET"], _get
5558

@@ -65,7 +68,7 @@ async def _get_one(
6568
) -> JSONResponse:
6669
storage = await self.storage_repo.get_storage_by_id(storage_id, user=user)
6770

68-
return json(dump_storage_with_sensitive_fields(storage, validator))
71+
return validated_json(apispec.CloudStorageGet, dump_storage_with_sensitive_fields(storage, validator))
6972

7073
return "/storage/<storage_id:ulid>", ["GET"], _get_one
7174

@@ -96,7 +99,7 @@ async def _post(request: Request, user: base_models.APIUser, validator: RCloneVa
9699
validator.validate(storage.configuration.model_dump())
97100

98101
res = await self.storage_repo.insert_storage(storage=storage, user=user)
99-
return json(dump_storage_with_sensitive_fields(res, validator), 201)
102+
return validated_json(apispec.CloudStorageGet, dump_storage_with_sensitive_fields(res, validator), 201)
100103

101104
return "/storage", ["POST"], _post
102105

@@ -131,7 +134,7 @@ async def _put(
131134
body_dict = new_storage.model_dump()
132135
del body_dict["storage_id"]
133136
res = await self.storage_repo.update_storage(storage_id=storage_id, user=user, **body_dict)
134-
return json(dump_storage_with_sensitive_fields(res, validator))
137+
return validated_json(apispec.CloudStorageGet, dump_storage_with_sensitive_fields(res, validator))
135138

136139
return "/storage/<storage_id:ulid>", ["PUT"], _put
137140

@@ -161,7 +164,7 @@ async def _patch(
161164
body_dict = body.model_dump(exclude_none=True)
162165

163166
res = await self.storage_repo.update_storage(storage_id=storage_id, user=user, **body_dict)
164-
return json(dump_storage_with_sensitive_fields(res, validator))
167+
return validated_json(apispec.CloudStorageGet, dump_storage_with_sensitive_fields(res, validator))
165168

166169
return "/storage/<storage_id:ulid>", ["PATCH"], _patch
167170

@@ -195,9 +198,11 @@ async def _get(
195198
query: apispec.StorageV2Params,
196199
) -> JSONResponse:
197200
storage: list[models.CloudStorage]
198-
storage = await self.storage_v2_repo.get_storage(user=user, project_id=query.project_id)
201+
storage = await self.storage_v2_repo.get_storage(user=user, project_id=ULID.from_str(query.project_id))
199202

200-
return json([dump_storage_with_sensitive_fields(s, validator) for s in storage])
203+
return validated_json(
204+
apispec.StoragesV2GetResponse, [dump_storage_with_sensitive_fields(s, validator) for s in storage]
205+
)
201206

202207
return "/storages_v2", ["GET"], _get
203208

@@ -213,7 +218,7 @@ async def _get_one(
213218
) -> JSONResponse:
214219
storage = await self.storage_v2_repo.get_storage_by_id(storage_id, user=user)
215220

216-
return json(dump_storage_with_sensitive_fields(storage, validator))
221+
return validated_json(apispec.CloudStorageGet, dump_storage_with_sensitive_fields(storage, validator))
217222

218223
return "/storages_v2/<storage_id:ulid>", ["GET"], _get_one
219224

@@ -244,7 +249,7 @@ async def _post(request: Request, user: base_models.APIUser, validator: RCloneVa
244249
validator.validate(storage.configuration.model_dump())
245250

246251
res = await self.storage_v2_repo.insert_storage(storage=storage, user=user)
247-
return json(dump_storage_with_sensitive_fields(res, validator), 201)
252+
return validated_json(apispec.CloudStorageGet, dump_storage_with_sensitive_fields(res, validator), 201)
248253

249254
return "/storages_v2", ["POST"], _post
250255

@@ -274,7 +279,7 @@ async def _patch(
274279
body_dict = body.model_dump(exclude_none=True)
275280

276281
res = await self.storage_v2_repo.update_storage(storage_id=storage_id, user=user, **body_dict)
277-
return json(dump_storage_with_sensitive_fields(res, validator))
282+
return validated_json(apispec.CloudStorageGet, dump_storage_with_sensitive_fields(res, validator))
278283

279284
return "/storages_v2/<storage_id:ulid>", ["PATCH"], _patch
280285

@@ -297,29 +302,19 @@ def get(self) -> BlueprintFactoryResponse:
297302
"""Get cloud storage for a repository."""
298303

299304
async def _get(_: Request, validator: RCloneValidator) -> JSONResponse:
300-
return json(validator.asdict())
305+
return validated_json(apispec.RCloneSchema, validator.asdict())
301306

302307
return "/storage_schema", ["GET"], _get
303308

304309
def test_connection(self) -> BlueprintFactoryResponse:
305310
"""Validate an RClone config."""
306311

307-
async def _test_connection(request: Request, validator: RCloneValidator) -> HTTPResponse:
308-
if not request.json:
309-
raise errors.ValidationError(message="The request body is empty. Please provide a valid JSON object.")
310-
if not isinstance(request.json, dict):
311-
raise errors.ValidationError(message="The request body is not a valid JSON object.")
312-
if not request.json.get("configuration"):
313-
raise errors.ValidationError(message="No 'configuration' sent.")
314-
if not isinstance(request.json.get("configuration"), dict):
315-
config_type = type(request.json.get("configuration"))
316-
raise errors.ValidationError(
317-
message=f"The R clone configuration should be a dictionary, not {config_type.__name__}"
318-
)
319-
if not request.json.get("source_path"):
320-
raise errors.ValidationError(message="'source_path' is required to test the connection.")
321-
validator.validate(request.json["configuration"], keep_sensitive=True)
322-
result = await validator.test_connection(request.json["configuration"], request.json["source_path"])
312+
@validate(json=apispec.StorageSchemaTestConnectionPostRequest)
313+
async def _test_connection(
314+
request: Request, validator: RCloneValidator, body: apispec.StorageSchemaTestConnectionPostRequest
315+
) -> HTTPResponse:
316+
validator.validate(body.configuration, keep_sensitive=True)
317+
result = await validator.test_connection(body.configuration, body.source_path)
323318
if not result.success:
324319
raise errors.ValidationError(message=result.error)
325320
return empty(204)
@@ -329,25 +324,23 @@ async def _test_connection(request: Request, validator: RCloneValidator) -> HTTP
329324
def validate(self) -> BlueprintFactoryResponse:
330325
"""Validate an RClone config."""
331326

332-
async def _validate(request: Request, validator: RCloneValidator) -> HTTPResponse:
333-
if not request.json:
334-
raise errors.ValidationError(message="The request body is empty. Please provide a valid JSON object.")
335-
if not isinstance(request.json, dict):
336-
raise errors.ValidationError(message="The request body is not a valid JSON object.")
337-
validator.validate(request.json, keep_sensitive=True)
327+
@validate(json=apispec.RCloneConfigValidate)
328+
async def _validate(
329+
request: Request, validator: RCloneValidator, body: apispec.RCloneConfigValidate
330+
) -> HTTPResponse:
331+
validator.validate(body.root, keep_sensitive=True) # type: ignore[arg-type]
338332
return empty(204)
339333

340334
return "/storage_schema/validate", ["POST"], _validate
341335

342336
def obscure(self) -> BlueprintFactoryResponse:
343337
"""Obscure values in config."""
344338

345-
async def _obscure(request: Request, validator: RCloneValidator) -> JSONResponse:
346-
if not request.json:
347-
raise errors.ValidationError(message="The request body is empty. Please provide a valid JSON object.")
348-
if not isinstance(request.json, dict):
349-
raise errors.ValidationError(message="The request body is not a valid JSON object.")
350-
config = await validator.obscure_config(request.json)
351-
return json(config)
339+
@validate(json=apispec.StorageSchemaObscurePostRequest)
340+
async def _obscure(
341+
request: Request, validator: RCloneValidator, body: apispec.StorageSchemaObscurePostRequest
342+
) -> JSONResponse:
343+
config = await validator.obscure_config(body.configuration)
344+
return validated_json(apispec.RCloneConfigValidate, config)
352345

353346
return "/storage_schema/obscure", ["POST"], _obscure

0 commit comments

Comments
 (0)