Skip to content

Commit 308e6a3

Browse files
Panaetiusolevski
authored andcommitted
refactor: add validation to project, storage, repo and session blueprints (#347)
1 parent 29d0944 commit 308e6a3

File tree

22 files changed

+164
-161
lines changed

22 files changed

+164
-161
lines changed

components/renku_data_services/authz/authz.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -586,7 +586,7 @@ async def _get_members_helper(
586586
member = Member(
587587
user_id=response.relationship.subject.object.object_id,
588588
role=member_role,
589-
resource_id=response.relationship.resource.object_id,
589+
resource_id=ULID.from_str(response.relationship.resource.object_id),
590590
)
591591

592592
yield member

components/renku_data_services/authz/models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ def with_group(self, group_id: ULID) -> "Member":
7474
class Member(UnsavedMember):
7575
"""Member stored in the database."""
7676

77-
resource_id: str | ULID
77+
resource_id: ULID
7878

7979

8080
class Change(Enum):

components/renku_data_services/base_api/auth.py

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -71,30 +71,6 @@ async def decorated_function(request: Request, *args: _P.args, **kwargs: _P.kwar
7171
return decorator
7272

7373

74-
def validate_path_project_id(
75-
f: Callable[Concatenate[Request, _P], Coroutine[Any, Any, _T]],
76-
) -> Callable[Concatenate[Request, _P], Coroutine[Any, Any, _T]]:
77-
"""Decorator for a Sanic handler that validates the project_id path parameter."""
78-
_path_project_id_regex = re.compile(r"^[A-Za-z0-9]{26}$")
79-
80-
@wraps(f)
81-
async def decorated_function(request: Request, *args: _P.args, **kwargs: _P.kwargs) -> _T:
82-
project_id = cast(str | None, kwargs.get("project_id"))
83-
if not project_id:
84-
raise errors.ProgrammingError(
85-
message="Could not find 'project_id' in the keyword arguments for the handler in order to validate it."
86-
)
87-
if not _path_project_id_regex.match(project_id):
88-
raise errors.ValidationError(
89-
message=f"The 'project_id' path parameter {project_id} does not match the required "
90-
f"regex {_path_project_id_regex}"
91-
)
92-
93-
return await f(request, *args, **kwargs)
94-
95-
return decorated_function
96-
97-
9874
def validate_path_user_id(
9975
f: Callable[Concatenate[Request, _P], Coroutine[Any, Any, _T]],
10076
) -> Callable[Concatenate[Request, _P], Coroutine[Any, Any, _T]]:

components/renku_data_services/crc/apispec.py

Lines changed: 1 addition & 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-10-18T11:06:20+00:00
3+
# timestamp: 2024-08-20T07:15:17+00:00
44

55
from __future__ import annotations
66

components/renku_data_services/project/api.spec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ paths:
143143
$ref: "#/components/responses/Error"
144144
tags:
145145
- projects
146-
/projects/{namespace}/{slug}:
146+
/namespaces/{namespace}/projects/{slug}:
147147
get:
148148
summary: Get a project by namespace and project slug
149149
parameters:

components/renku_data_services/project/apispec_base.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Base models for API specifications."""
22

3-
from pydantic import BaseModel
3+
from pydantic import BaseModel, field_validator
4+
from ulid import ULID
45

56

67
class BaseAPISpec(BaseModel):
@@ -13,3 +14,9 @@ class Config:
1314
# NOTE: By default the pydantic library does not use python for regex but a rust crate
1415
# this rust crate does not support lookahead regex syntax but we need it in this component
1516
regex_engine = "python-re"
17+
18+
@field_validator("id", mode="before", check_fields=False)
19+
@classmethod
20+
def serialize_id(cls, id: str | ULID) -> str:
21+
"""Custom serializer that can handle ULIDs."""
22+
return str(id)

components/renku_data_services/project/blueprints.py

Lines changed: 18 additions & 24 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, json
6+
from sanic import HTTPResponse, Request
77
from sanic.response import JSONResponse
88
from sanic_ext import validate
99
from ulid import ULID
@@ -13,7 +13,6 @@
1313
from renku_data_services.base_api.auth import (
1414
authenticate,
1515
only_authenticated,
16-
validate_path_project_id,
1716
validate_path_user_id,
1817
)
1918
from renku_data_services.base_api.blueprint import BlueprintFactoryResponse, CustomBlueprint
@@ -94,7 +93,7 @@ async def _get_one(
9493
headers = {"ETag": project.etag} if project.etag is not None else None
9594
return validated_json(apispec.Project, self._dump_project(project), headers=headers)
9695

97-
return "/projects/<project_id>", ["GET"], _get_one
96+
return "/projects/<project_id:ulid>", ["GET"], _get_one
9897

9998
def get_one_by_namespace_slug(self) -> BlueprintFactoryResponse:
10099
"""Get a specific project by namespace/slug."""
@@ -112,34 +111,32 @@ async def _get_one_by_namespace_slug(
112111
headers = {"ETag": project.etag} if project.etag is not None else None
113112
return validated_json(apispec.Project, self._dump_project(project), headers=headers)
114113

115-
return "/projects/<namespace>/<slug:renku_slug>", ["GET"], _get_one_by_namespace_slug
114+
return "/namespaces/<namespace>/projects/<slug:renku_slug>", ["GET"], _get_one_by_namespace_slug
116115

117116
def delete(self) -> BlueprintFactoryResponse:
118117
"""Delete a specific project."""
119118

120119
@authenticate(self.authenticator)
121120
@only_authenticated
122-
@validate_path_project_id
123-
async def _delete(_: Request, user: base_models.APIUser, project_id: str) -> HTTPResponse:
124-
await self.project_repo.delete_project(user=user, project_id=ULID.from_str(project_id))
121+
async def _delete(_: Request, user: base_models.APIUser, project_id: ULID) -> HTTPResponse:
122+
await self.project_repo.delete_project(user=user, project_id=project_id)
125123
return HTTPResponse(status=204)
126124

127-
return "/projects/<project_id>", ["DELETE"], _delete
125+
return "/projects/<project_id:ulid>", ["DELETE"], _delete
128126

129127
def patch(self) -> BlueprintFactoryResponse:
130128
"""Partially update a specific project."""
131129

132130
@authenticate(self.authenticator)
133131
@only_authenticated
134-
@validate_path_project_id
135132
@if_match_required
136133
@validate(json=apispec.ProjectPatch)
137134
async def _patch(
138-
_: Request, user: base_models.APIUser, project_id: str, body: apispec.ProjectPatch, etag: str
135+
_: Request, user: base_models.APIUser, project_id: ULID, body: apispec.ProjectPatch, etag: str
139136
) -> JSONResponse:
140137
project_patch = validate_project_patch(body)
141138
project_update = await self.project_repo.update_project(
142-
user=user, project_id=ULID.from_str(project_id), etag=etag, patch=project_patch
139+
user=user, project_id=project_id, etag=etag, patch=project_patch
143140
)
144141

145142
if not isinstance(project_update, project_models.ProjectUpdate):
@@ -151,15 +148,14 @@ async def _patch(
151148
updated_project = project_update.new
152149
return validated_json(apispec.Project, self._dump_project(updated_project))
153150

154-
return "/projects/<project_id>", ["PATCH"], _patch
151+
return "/projects/<project_id:ulid>", ["PATCH"], _patch
155152

156153
def get_all_members(self) -> BlueprintFactoryResponse:
157154
"""List all project members."""
158155

159156
@authenticate(self.authenticator)
160-
@validate_path_project_id
161-
async def _get_all_members(_: Request, user: base_models.APIUser, project_id: str) -> JSONResponse:
162-
members = await self.project_member_repo.get_members(user, ULID.from_str(project_id))
157+
async def _get_all_members(_: Request, user: base_models.APIUser, project_id: ULID) -> JSONResponse:
158+
members = await self.project_member_repo.get_members(user, project_id)
163159

164160
users = []
165161

@@ -179,35 +175,33 @@ async def _get_all_members(_: Request, user: base_models.APIUser, project_id: st
179175
).model_dump(exclude_none=True, mode="json")
180176
users.append(user_with_id)
181177

182-
return json(users)
178+
return validated_json(apispec.ProjectMemberListResponse, users)
183179

184-
return "/projects/<project_id>/members", ["GET"], _get_all_members
180+
return "/projects/<project_id:ulid>/members", ["GET"], _get_all_members
185181

186182
def update_members(self) -> BlueprintFactoryResponse:
187183
"""Update or add project members."""
188184

189185
@authenticate(self.authenticator)
190-
@validate_path_project_id
191186
@validate_body_root_model(json=apispec.ProjectMemberListPatchRequest)
192187
async def _update_members(
193-
_: Request, user: base_models.APIUser, project_id: str, body: apispec.ProjectMemberListPatchRequest
188+
_: Request, user: base_models.APIUser, project_id: ULID, body: apispec.ProjectMemberListPatchRequest
194189
) -> HTTPResponse:
195190
members = [Member(Role(i.role.value), i.id, project_id) for i in body.root]
196-
await self.project_member_repo.update_members(user, ULID.from_str(project_id), members)
191+
await self.project_member_repo.update_members(user, project_id, members)
197192
return HTTPResponse(status=200)
198193

199-
return "/projects/<project_id>/members", ["PATCH"], _update_members
194+
return "/projects/<project_id:ulid>/members", ["PATCH"], _update_members
200195

201196
def delete_member(self) -> BlueprintFactoryResponse:
202197
"""Delete a specific project."""
203198

204199
@authenticate(self.authenticator)
205-
@validate_path_project_id
206200
@validate_path_user_id
207201
async def _delete_member(
208-
_: Request, user: base_models.APIUser, project_id: str, member_id: str
202+
_: Request, user: base_models.APIUser, project_id: ULID, member_id: str
209203
) -> HTTPResponse:
210-
await self.project_member_repo.delete_members(user, ULID.from_str(project_id), [member_id])
204+
await self.project_member_repo.delete_members(user, project_id, [member_id])
211205
return HTTPResponse(status=204)
212206

213207
return "/projects/<project_id>/members/<member_id>", ["DELETE"], _delete_member

components/renku_data_services/project/db.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,6 @@ async def update_project(
221221
session: AsyncSession | None = None,
222222
) -> models.ProjectUpdate:
223223
"""Update a project entry."""
224-
project_id_str: str = str(project_id)
225224
if not session:
226225
raise errors.ProgrammingError(message="A database session is required")
227226
result = await session.scalars(select(schemas.ProjectORM).where(schemas.ProjectORM.id == project_id))

components/renku_data_services/project/orm.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ class ProjectRepositoryORM(BaseORM):
8181

8282
id: Mapped[int] = mapped_column("id", Integer, Identity(always=True), primary_key=True, default=None, init=False)
8383
url: Mapped[str] = mapped_column("url", String(2000))
84-
project_id: Mapped[Optional[str]] = mapped_column(
84+
project_id: Mapped[Optional[ULID]] = mapped_column(
8585
ForeignKey("projects.id", ondelete="CASCADE"), default=None, index=True
8686
)
8787
project: Mapped[Optional[ProjectORM]] = relationship(back_populates="repositories", default=None, repr=False)

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_2
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_one_repository(
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/secrets/apispec.py

Lines changed: 1 addition & 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-13T13:29:49+00:00
3+
# timestamp: 2024-08-20T07:15:21+00:00
44

55
from __future__ import annotations
66

components/renku_data_services/session/apispec_base.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
from pydantic import BaseModel, field_validator
77
from ulid import ULID
88

9+
from renku_data_services.session import models
10+
911

1012
class BaseAPISpec(BaseModel):
1113
"""Base API specification."""
@@ -23,6 +25,28 @@ def serialize_ulid(cls, value: Any) -> Any:
2325
return str(value)
2426
return value
2527

28+
@field_validator("project_id", mode="before", check_fields=False)
29+
@classmethod
30+
def serialize_project_id(cls, project_id: str | ULID) -> str:
31+
"""Custom serializer that can handle ULIDs."""
32+
return str(project_id)
33+
34+
@field_validator("environment_id", mode="before", check_fields=False)
35+
@classmethod
36+
def serialize_environment_id(cls, environment_id: str | ULID | None) -> str | None:
37+
"""Custom serializer that can handle ULIDs."""
38+
if environment_id is None:
39+
return None
40+
return str(environment_id)
41+
42+
@field_validator("environment_kind", mode="before", check_fields=False)
43+
@classmethod
44+
def serialize_environment_kind(cls, environment_kind: models.EnvironmentKind | str) -> str:
45+
"""Custom serializer that can handle ULIDs."""
46+
if isinstance(environment_kind, models.EnvironmentKind):
47+
return environment_kind.value
48+
return environment_kind
49+
2650
@field_validator("working_directory", "mount_directory", check_fields=False, mode="before")
2751
@classmethod
2852
def convert_path_to_string(cls, val: str | PurePosixPath) -> str:

components/renku_data_services/session/blueprints.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ def post(self) -> BlueprintFactoryResponse:
5050
"""Create a new session environment."""
5151

5252
@authenticate(self.authenticator)
53+
@only_authenticated
5354
@validate(json=apispec.EnvironmentPost)
5455
async def _post(_: Request, user: base_models.APIUser, body: apispec.EnvironmentPost) -> JSONResponse:
5556
new_environment = validate_unsaved_environment(body)
@@ -62,6 +63,7 @@ def patch(self) -> BlueprintFactoryResponse:
6263
"""Partially update a specific session environment."""
6364

6465
@authenticate(self.authenticator)
66+
@only_authenticated
6567
@validate(json=apispec.EnvironmentPatch)
6668
async def _patch(
6769
_: Request, user: base_models.APIUser, environment_id: ULID, body: apispec.EnvironmentPatch
@@ -78,6 +80,7 @@ def delete(self) -> BlueprintFactoryResponse:
7880
"""Delete a specific session environment."""
7981

8082
@authenticate(self.authenticator)
83+
@only_authenticated
8184
async def _delete(_: Request, user: base_models.APIUser, environment_id: ULID) -> HTTPResponse:
8285
await self.session_repo.delete_environment(user=user, environment_id=environment_id)
8386
return HTTPResponse(status=204)
@@ -116,6 +119,7 @@ def post(self) -> BlueprintFactoryResponse:
116119
"""Create a new session launcher."""
117120

118121
@authenticate(self.authenticator)
122+
@only_authenticated
119123
@validate(json=apispec.SessionLauncherPost)
120124
async def _post(_: Request, user: base_models.APIUser, body: apispec.SessionLauncherPost) -> JSONResponse:
121125
new_launcher = validate_unsaved_session_launcher(body)
@@ -128,6 +132,7 @@ def patch(self) -> BlueprintFactoryResponse:
128132
"""Partially update a specific session launcher."""
129133

130134
@authenticate(self.authenticator)
135+
@only_authenticated
131136
@validate(json=apispec.SessionLauncherPatch)
132137
async def _patch(
133138
_: Request, user: base_models.APIUser, launcher_id: ULID, body: apispec.SessionLauncherPatch
@@ -146,6 +151,7 @@ def delete(self) -> BlueprintFactoryResponse:
146151
"""Delete a specific session launcher."""
147152

148153
@authenticate(self.authenticator)
154+
@only_authenticated
149155
async def _delete(_: Request, user: base_models.APIUser, launcher_id: ULID) -> HTTPResponse:
150156
await self.session_repo.delete_launcher(user=user, launcher_id=launcher_id)
151157
return HTTPResponse(status=204)
@@ -156,9 +162,8 @@ def get_project_launchers(self) -> BlueprintFactoryResponse:
156162
"""Get all launchers belonging to a project."""
157163

158164
@authenticate(self.authenticator)
159-
@validate_path_project_id
160-
async def _get_launcher(_: Request, user: base_models.APIUser, project_id: str) -> JSONResponse:
165+
async def _get_launcher(_: Request, user: base_models.APIUser, project_id: ULID) -> JSONResponse:
161166
launchers = await self.session_repo.get_project_launchers(user=user, project_id=project_id)
162167
return validated_json(apispec.SessionLaunchersList, launchers)
163168

164-
return "/projects/<project_id>/session_launchers", ["GET"], _get_launcher
169+
return "/projects/<project_id:ulid>/session_launchers", ["GET"], _get_launcher

0 commit comments

Comments
 (0)