Skip to content

Commit 440a149

Browse files
Panaetiusolevski
authored andcommitted
refactor: add validation to project, storage, repo and session blueprints (#347)
1 parent d07a613 commit 440a149

File tree

25 files changed

+182
-178
lines changed

25 files changed

+182
-178
lines changed

components/renku_data_services/authz/authz.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -559,7 +559,7 @@ async def _get_members_helper(
559559
member = Member(
560560
user_id=response.relationship.subject.object.object_id,
561561
role=member_role,
562-
resource_id=response.relationship.resource.object_id,
562+
resource_id=ULID.from_str(response.relationship.resource.object_id),
563563
)
564564

565565
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/connected_services/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:50+00:00
3+
# timestamp: 2024-08-20T07:15:22+00:00
44

55
from __future__ import annotations
66

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-08-13T13:29:45+00:00
3+
# timestamp: 2024-08-20T07:15:17+00:00
44

55
from __future__ import annotations
66

components/renku_data_services/platform/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:52+00:00
3+
# timestamp: 2024-08-20T07:15:25+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
@@ -144,7 +144,7 @@ paths:
144144
$ref: "#/components/responses/Error"
145145
tags:
146146
- projects
147-
/projects/{namespace}/{slug}:
147+
/namespaces/{namespace}/projects/{slug}:
148148
get:
149149
summary: Get a project by namespace and project slug
150150
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
@@ -93,7 +92,7 @@ async def _get_one(
9392
headers = {"ETag": project.etag} if project.etag is not None else None
9493
return validated_json(apispec.Project, self._dump_project(project), headers=headers)
9594

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

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

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

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

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

126-
return "/projects/<project_id>", ["DELETE"], _delete
124+
return "/projects/<project_id:ulid>", ["DELETE"], _delete
127125

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

131129
@authenticate(self.authenticator)
132130
@only_authenticated
133-
@validate_path_project_id
134131
@if_match_required
135132
@validate(json=apispec.ProjectPatch)
136133
async def _patch(
137-
_: Request, user: base_models.APIUser, project_id: str, body: apispec.ProjectPatch, etag: str
134+
_: Request, user: base_models.APIUser, project_id: ULID, body: apispec.ProjectPatch, etag: str
138135
) -> JSONResponse:
139136
body_dict = body.model_dump(exclude_none=True)
140137

141138
project_update = await self.project_repo.update_project(
142-
user=user, project_id=ULID.from_str(project_id), etag=etag, payload=body_dict
139+
user=user, project_id=project_id, etag=etag, payload=body_dict
143140
)
144141
if not isinstance(project_update, project_models.ProjectUpdate):
145142
raise errors.ProgrammingError(
@@ -150,15 +147,14 @@ async def _patch(
150147
updated_project = project_update.new
151148
return validated_json(apispec.Project, self._dump_project(updated_project))
152149

153-
return "/projects/<project_id>", ["PATCH"], _patch
150+
return "/projects/<project_id:ulid>", ["PATCH"], _patch
154151

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

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

163159
users = []
164160

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

181-
return json(users)
177+
return validated_json(apispec.ProjectMemberListResponse, users)
182178

183-
return "/projects/<project_id>/members", ["GET"], _get_all_members
179+
return "/projects/<project_id:ulid>/members", ["GET"], _get_all_members
184180

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

188184
@authenticate(self.authenticator)
189-
@validate_path_project_id
190185
@validate_body_root_model(json=apispec.ProjectMemberListPatchRequest)
191186
async def _update_members(
192-
_: Request, user: base_models.APIUser, project_id: str, body: apispec.ProjectMemberListPatchRequest
187+
_: Request, user: base_models.APIUser, project_id: ULID, body: apispec.ProjectMemberListPatchRequest
193188
) -> HTTPResponse:
194189
members = [Member(Role(i.role.value), i.id, project_id) for i in body.root]
195-
await self.project_member_repo.update_members(user, ULID.from_str(project_id), members)
190+
await self.project_member_repo.update_members(user, project_id, members)
196191
return HTTPResponse(status=200)
197192

198-
return "/projects/<project_id>/members", ["PATCH"], _update_members
193+
return "/projects/<project_id:ulid>/members", ["PATCH"], _update_members
199194

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

203198
@authenticate(self.authenticator)
204-
@validate_path_project_id
205199
@validate_path_user_id
206200
async def _delete_member(
207-
_: Request, user: base_models.APIUser, project_id: str, member_id: str
201+
_: Request, user: base_models.APIUser, project_id: ULID, member_id: str
208202
) -> HTTPResponse:
209-
await self.project_member_repo.delete_members(user, ULID.from_str(project_id), [member_id])
203+
await self.project_member_repo.delete_members(user, project_id, [member_id])
210204
return HTTPResponse(status=204)
211205

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

components/renku_data_services/project/db.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,6 @@ async def update_project(
212212
session: AsyncSession | None = None,
213213
) -> models.ProjectUpdate:
214214
"""Update a project entry."""
215-
project_id_str: str = str(project_id)
216215
if not session:
217216
raise errors.ProgrammingError(message="A database session is required")
218217
result = await session.scalars(select(schemas.ProjectORM).where(schemas.ProjectORM.id == project_id))
@@ -243,7 +242,7 @@ async def update_project(
243242

244243
if "repositories" in payload:
245244
payload["repositories"] = [
246-
schemas.ProjectRepositoryORM(url=r, project_id=project_id_str, project=project)
245+
schemas.ProjectRepositoryORM(url=r, project_id=project_id, project=project)
247246
for r in payload["repositories"]
248247
]
249248
# Trigger update for ``updated_at`` column

0 commit comments

Comments
 (0)