Skip to content

Commit 558fe97

Browse files
committed
refactor: add validation to project blueprints
1 parent 1d44114 commit 558fe97

File tree

20 files changed

+115
-159
lines changed

20 files changed

+115
-159
lines changed

components/renku_data_services/base_api/auth.py

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

4646

47-
def validate_path_project_id(
48-
f: Callable[Concatenate[Request, _P], Awaitable[_T]],
49-
) -> Callable[Concatenate[Request, _P], Awaitable[_T]]:
50-
"""Decorator for a Sanic handler that validates the project_id path parameter."""
51-
_path_project_id_regex = re.compile(r"^[A-Za-z0-9]{26}$")
52-
53-
@wraps(f)
54-
async def decorated_function(request: Request, *args: _P.args, **kwargs: _P.kwargs) -> _T:
55-
project_id = cast(str | None, kwargs.get("project_id"))
56-
if not project_id:
57-
raise errors.ProgrammingError(
58-
message="Could not find 'project_id' in the keyword arguments for the handler in order to validate it."
59-
)
60-
if not _path_project_id_regex.match(project_id):
61-
raise errors.ValidationError(
62-
message=f"The 'project_id' path parameter {project_id} does not match the required "
63-
f"regex {_path_project_id_regex}"
64-
)
65-
66-
return await f(request, *args, **kwargs)
67-
68-
return decorated_function
69-
70-
7147
def validate_path_user_id(
7248
f: Callable[Concatenate[Request, _P], Awaitable[_T]],
7349
) -> Callable[Concatenate[Request, _P], Awaitable[_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-12T06:46:18+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/namespace/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-12T06:46:16+00:00
3+
# timestamp: 2024-08-20T07:15:21+00:00
44

55
from __future__ import annotations
66

components/renku_data_services/notebooks/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:51+00:00
3+
# timestamp: 2024-08-20T07:15:24+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.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-12T06:46:15+00:00
3+
# timestamp: 2024-08-20T07:15:20+00:00
44

55
from __future__ import annotations
66

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: 56 additions & 53 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,13 +13,13 @@
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
2019
from renku_data_services.base_api.etag import if_match_required
2120
from renku_data_services.base_api.misc import validate_query
2221
from renku_data_services.base_api.pagination import PaginationRequest, paginate
22+
from renku_data_services.base_models.validation import validate_and_dump, validated_json
2323
from renku_data_services.errors import errors
2424
from renku_data_services.project import apispec
2525
from renku_data_services.project import models as project_models
@@ -48,22 +48,25 @@ async def _get_all(
4848
projects, total_num = await self.project_repo.get_projects(
4949
user=user, pagination=pagination, namespace=query.namespace
5050
)
51-
return [
52-
dict(
53-
id=str(p.id),
54-
name=p.name,
55-
namespace=p.namespace.slug,
56-
slug=p.slug,
57-
creation_date=p.creation_date.isoformat(),
58-
created_by=p.created_by,
59-
repositories=p.repositories,
60-
visibility=p.visibility.value,
61-
description=p.description,
62-
etag=p.etag,
63-
keywords=p.keywords or [],
64-
)
65-
for p in projects
66-
], total_num
51+
return validate_and_dump(
52+
apispec.ProjectsList,
53+
[
54+
dict(
55+
id=p.id,
56+
name=p.name,
57+
namespace=p.namespace.slug,
58+
slug=p.slug,
59+
creation_date=p.creation_date.isoformat(),
60+
created_by=p.created_by,
61+
repositories=p.repositories,
62+
visibility=p.visibility.value,
63+
description=p.description,
64+
etag=p.etag,
65+
keywords=p.keywords or [],
66+
)
67+
for p in projects
68+
],
69+
), total_num
6770

6871
return "/projects", ["GET"], _get_all
6972

@@ -86,9 +89,10 @@ async def _post(_: Request, user: base_models.APIUser, body: apispec.ProjectPost
8689
keywords=keywords,
8790
)
8891
result = await self.project_repo.insert_project(user, project)
89-
return json(
92+
return validated_json(
93+
apispec.Project,
9094
dict(
91-
id=str(result.id),
95+
id=result.id,
9296
name=result.name,
9397
namespace=result.namespace.slug,
9498
slug=result.slug,
@@ -109,18 +113,20 @@ def get_one(self) -> BlueprintFactoryResponse:
109113
"""Get a specific project."""
110114

111115
@authenticate(self.authenticator)
112-
@validate_path_project_id
113-
async def _get_one(request: Request, user: base_models.APIUser, project_id: str) -> JSONResponse | HTTPResponse:
114-
project = await self.project_repo.get_project(user=user, project_id=ULID.from_str(project_id))
116+
async def _get_one(
117+
request: Request, user: base_models.APIUser, project_id: ULID
118+
) -> JSONResponse | HTTPResponse:
119+
project = await self.project_repo.get_project(user=user, project_id=project_id)
115120

116121
etag = request.headers.get("If-None-Match")
117122
if project.etag is not None and project.etag == etag:
118123
return HTTPResponse(status=304)
119124

120125
headers = {"ETag": project.etag} if project.etag is not None else None
121-
return json(
126+
return validated_json(
127+
apispec.Project,
122128
dict(
123-
id=str(project.id),
129+
id=project.id,
124130
name=project.name,
125131
namespace=project.namespace.slug,
126132
slug=project.slug,
@@ -135,7 +141,7 @@ async def _get_one(request: Request, user: base_models.APIUser, project_id: str)
135141
headers=headers,
136142
)
137143

138-
return "/projects/<project_id>", ["GET"], _get_one
144+
return "/projects/<project_id:ulid>", ["GET"], _get_one
139145

140146
def get_one_by_namespace_slug(self) -> BlueprintFactoryResponse:
141147
"""Get a specific project by namespace/slug."""
@@ -151,9 +157,10 @@ async def _get_one_by_namespace_slug(
151157
return HTTPResponse(status=304)
152158

153159
headers = {"ETag": project.etag} if project.etag is not None else None
154-
return json(
160+
return validated_json(
161+
apispec.Project,
155162
dict(
156-
id=str(project.id),
163+
id=project.id,
157164
name=project.name,
158165
namespace=project.namespace.slug,
159166
slug=project.slug,
@@ -168,35 +175,33 @@ async def _get_one_by_namespace_slug(
168175
headers=headers,
169176
)
170177

171-
return "/projects/<namespace>/<slug:renku_slug>", ["GET"], _get_one_by_namespace_slug
178+
return "/namespaces/<namespace>/projects/<slug:renku_slug>", ["GET"], _get_one_by_namespace_slug
172179

173180
def delete(self) -> BlueprintFactoryResponse:
174181
"""Delete a specific project."""
175182

176183
@authenticate(self.authenticator)
177184
@only_authenticated
178-
@validate_path_project_id
179-
async def _delete(_: Request, user: base_models.APIUser, project_id: str) -> HTTPResponse:
180-
await self.project_repo.delete_project(user=user, project_id=ULID.from_str(project_id))
185+
async def _delete(_: Request, user: base_models.APIUser, project_id: ULID) -> HTTPResponse:
186+
await self.project_repo.delete_project(user=user, project_id=project_id)
181187
return HTTPResponse(status=204)
182188

183-
return "/projects/<project_id>", ["DELETE"], _delete
189+
return "/projects/<project_id:ulid>", ["DELETE"], _delete
184190

185191
def patch(self) -> BlueprintFactoryResponse:
186192
"""Partially update a specific project."""
187193

188194
@authenticate(self.authenticator)
189195
@only_authenticated
190-
@validate_path_project_id
191196
@if_match_required
192197
@validate(json=apispec.ProjectPatch)
193198
async def _patch(
194-
_: Request, user: base_models.APIUser, project_id: str, body: apispec.ProjectPatch, etag: str
199+
_: Request, user: base_models.APIUser, project_id: ULID, body: apispec.ProjectPatch, etag: str
195200
) -> JSONResponse:
196201
body_dict = body.model_dump(exclude_none=True)
197202

198203
project_update = await self.project_repo.update_project(
199-
user=user, project_id=ULID.from_str(project_id), etag=etag, payload=body_dict
204+
user=user, project_id=project_id, etag=etag, payload=body_dict
200205
)
201206
if not isinstance(project_update, project_models.ProjectUpdate):
202207
raise errors.ProgrammingError(
@@ -205,9 +210,10 @@ async def _patch(
205210
)
206211

207212
updated_project = project_update.new
208-
return json(
213+
return validated_json(
214+
apispec.Project,
209215
dict(
210-
id=str(updated_project.id),
216+
id=updated_project.id,
211217
name=updated_project.name,
212218
namespace=updated_project.namespace.slug,
213219
slug=updated_project.slug,
@@ -222,15 +228,14 @@ async def _patch(
222228
200,
223229
)
224230

225-
return "/projects/<project_id>", ["PATCH"], _patch
231+
return "/projects/<project_id:ulid>", ["PATCH"], _patch
226232

227233
def get_all_members(self) -> BlueprintFactoryResponse:
228234
"""List all project members."""
229235

230236
@authenticate(self.authenticator)
231-
@validate_path_project_id
232-
async def _get_all_members(_: Request, user: base_models.APIUser, project_id: str) -> JSONResponse:
233-
members = await self.project_member_repo.get_members(user, ULID.from_str(project_id))
237+
async def _get_all_members(_: Request, user: base_models.APIUser, project_id: ULID) -> JSONResponse:
238+
members = await self.project_member_repo.get_members(user, project_id)
234239

235240
users = []
236241

@@ -250,33 +255,31 @@ async def _get_all_members(_: Request, user: base_models.APIUser, project_id: st
250255
).model_dump(exclude_none=True, mode="json")
251256
users.append(user_with_id)
252257

253-
return json(users)
258+
return validated_json(apispec.ProjectMemberListResponse, users)
254259

255-
return "/projects/<project_id>/members", ["GET"], _get_all_members
260+
return "/projects/<project_id:ulid>/members", ["GET"], _get_all_members
256261

257262
def update_members(self) -> BlueprintFactoryResponse:
258263
"""Update or add project members."""
259264

260265
@authenticate(self.authenticator)
261-
@validate_path_project_id
262-
async def _update_members(request: Request, user: base_models.APIUser, project_id: str) -> HTTPResponse:
266+
async def _update_members(request: Request, user: base_models.APIUser, project_id: ULID) -> HTTPResponse:
263267
body_dump = apispec.ProjectMemberListPatchRequest.model_validate(request.json)
264-
members = [Member(Role(i.role.value), i.id, project_id) for i in body_dump.root]
265-
await self.project_member_repo.update_members(user, ULID.from_str(project_id), members)
268+
members = [Member(Role(i.role.value), i.id, str(project_id)) for i in body_dump.root]
269+
await self.project_member_repo.update_members(user, project_id, members)
266270
return HTTPResponse(status=200)
267271

268-
return "/projects/<project_id>/members", ["PATCH"], _update_members
272+
return "/projects/<project_id:ulid>/members", ["PATCH"], _update_members
269273

270274
def delete_member(self) -> BlueprintFactoryResponse:
271275
"""Delete a specific project."""
272276

273277
@authenticate(self.authenticator)
274-
@validate_path_project_id
275278
@validate_path_user_id
276279
async def _delete_member(
277-
_: Request, user: base_models.APIUser, project_id: str, member_id: str
280+
_: Request, user: base_models.APIUser, project_id: ULID, member_id: str
278281
) -> HTTPResponse:
279-
await self.project_member_repo.delete_members(user, ULID.from_str(project_id), [member_id])
282+
await self.project_member_repo.delete_members(user, project_id, [member_id])
280283
return HTTPResponse(status=204)
281284

282-
return "/projects/<project_id>/members/<member_id>", ["DELETE"], _delete_member
285+
return "/projects/<project_id:ulid>/members/<member_id>", ["DELETE"], _delete_member

components/renku_data_services/repositories/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-12T06:46:19+00:00
3+
# timestamp: 2024-08-20T07:15:23+00:00
44

55
from __future__ import annotations
66

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-12T06:46:17+00:00
3+
# timestamp: 2024-08-20T07:15:21+00:00
44

55
from __future__ import annotations
66

0 commit comments

Comments
 (0)