From 1e1cd583774bcfd974937df48e88aec0ad81f5e9 Mon Sep 17 00:00:00 2001 From: Florimond Manca Date: Thu, 30 Jun 2022 12:45:42 +0200 Subject: [PATCH] Run payload validation as part of dataset endpoints (#319) --- server/api/app.py | 2 - server/api/datasets/schemas.py | 8 +++- server/api/errors.py | 24 ---------- server/application/datasets/commands.py | 56 ++-------------------- server/application/datasets/validation.py | 57 +++++++++++++++++++++++ 5 files changed, 68 insertions(+), 79 deletions(-) delete mode 100644 server/api/errors.py create mode 100644 server/application/datasets/validation.py diff --git a/server/api/app.py b/server/api/app.py index 35b1874e..9b6185f6 100644 --- a/server/api/app.py +++ b/server/api/app.py @@ -5,7 +5,6 @@ from server.config import Settings from server.config.di import resolve -from . import errors from .auth.middleware import AuthMiddleware from .resources import auth_backend from .routes import router @@ -32,7 +31,6 @@ def create_app(settings: Settings = None) -> App: app = App( debug=settings.debug, docs_url=settings.docs_url, - exception_handlers=errors.exception_handlers, ) app.add_middleware( diff --git a/server/api/datasets/schemas.py b/server/api/datasets/schemas.py index cdba126e..92ee4a12 100644 --- a/server/api/datasets/schemas.py +++ b/server/api/datasets/schemas.py @@ -4,6 +4,10 @@ from fastapi import Query from pydantic import BaseModel, EmailStr, Field +from server.application.datasets.validation import ( + CreateDatasetValidationMixin, + UpdateDatasetValidationMixin, +) from server.domain.common.types import ID from server.domain.datasets.entities import ( DataFormat, @@ -34,7 +38,7 @@ def __init__( self.tag_id = tag_id -class DatasetCreate(BaseModel): +class DatasetCreate(CreateDatasetValidationMixin, BaseModel): title: str description: str service: str @@ -49,7 +53,7 @@ class DatasetCreate(BaseModel): tag_ids: List[ID] = Field(default_factory=list) -class DatasetUpdate(BaseModel): +class DatasetUpdate(UpdateDatasetValidationMixin, BaseModel): title: str description: str service: str diff --git a/server/api/errors.py b/server/api/errors.py deleted file mode 100644 index 4ee3bb26..00000000 --- a/server/api/errors.py +++ /dev/null @@ -1,24 +0,0 @@ -from fastapi.encoders import jsonable_encoder -from pydantic import BaseConfig, ValidationError -from pydantic.error_wrappers import flatten_errors -from starlette.requests import Request -from starlette.responses import JSONResponse, Response - - -async def handle_validation_error(_: Request, exc: ValidationError) -> Response: - """ - Handle validation errors that occurred beyond at the application layer, i.e. - beyond basic data marshalling performed by schemas. - """ - # Prepend 'body' in error locations, for consistency with API-level errors. - errors = list(flatten_errors(exc.raw_errors, BaseConfig, loc=("body",))) - - return JSONResponse( - status_code=422, - content={"detail": jsonable_encoder(errors)}, - ) - - -exception_handlers: dict = { - ValidationError: handle_validation_error, -} diff --git a/server/application/datasets/commands.py b/server/application/datasets/commands.py index dfaf0463..68af865b 100644 --- a/server/application/datasets/commands.py +++ b/server/application/datasets/commands.py @@ -1,7 +1,7 @@ import datetime as dt from typing import List, Optional -from pydantic import EmailStr, Field, validator +from pydantic import EmailStr, Field from server.domain.common.types import ID from server.domain.datasets.entities import ( @@ -11,8 +11,10 @@ ) from server.seedwork.application.commands import Command +from .validation import CreateDatasetValidationMixin, UpdateDatasetValidationMixin -class CreateDataset(Command[ID]): + +class CreateDataset(CreateDatasetValidationMixin, Command[ID]): title: str description: str service: str @@ -26,20 +28,8 @@ class CreateDataset(Command[ID]): published_url: Optional[str] = None tag_ids: List[ID] = Field(default_factory=list) - @validator("formats") - def check_formats_at_least_one(cls, value: List[DataFormat]) -> List[DataFormat]: - if not value: - raise ValueError("formats must contain at least one item") - return value - - @validator("contact_emails") - def check_contact_emails_at_least_one(cls, value: List[str]) -> List[str]: - if not value: - raise ValueError("contact_emails must contain at least one item") - return value - -class UpdateDataset(Command[None]): +class UpdateDataset(UpdateDatasetValidationMixin, Command[None]): id: ID title: str description: str @@ -54,42 +44,6 @@ class UpdateDataset(Command[None]): published_url: Optional[str] = Field(...) tag_ids: List[ID] - @validator("title") - def check_title_not_empty(cls, value: str) -> str: - if not value: - raise ValueError("title must not be empty") - return value - - @validator("description") - def check_description_not_empty(cls, value: str) -> str: - if not value: - raise ValueError("description must not be empty") - return value - - @validator("service") - def check_service_not_empty(cls, value: str) -> str: - if not value: - raise ValueError("service must not be empty") - return value - - @validator("formats") - def check_formats_at_least_one(cls, value: List[DataFormat]) -> List[DataFormat]: - if not value: - raise ValueError("formats must contain at least one item") - return value - - @validator("contact_emails") - def check_contact_emails_at_least_one(cls, value: List[str]) -> List[str]: - if not value: - raise ValueError("contact_emails must contain at least one item") - return value - - @validator("published_url") - def check_published_url_not_empty(cls, value: Optional[str]) -> Optional[str]: - if value is not None and not value: - raise ValueError("published_url must not be empty") - return value - class DeleteDataset(Command[None]): id: ID diff --git a/server/application/datasets/validation.py b/server/application/datasets/validation.py new file mode 100644 index 00000000..ada56e17 --- /dev/null +++ b/server/application/datasets/validation.py @@ -0,0 +1,57 @@ +from typing import List, Optional + +from pydantic import BaseModel, validator + +from server.domain.datasets.entities import DataFormat + + +class CreateDatasetValidationMixin(BaseModel): + @validator("formats", check_fields=False) + def check_formats_at_least_one(cls, value: List[DataFormat]) -> List[DataFormat]: + if not value: + raise ValueError("formats must contain at least one item") + return value + + @validator("contact_emails", check_fields=False) + def check_contact_emails_at_least_one(cls, value: List[str]) -> List[str]: + if not value: + raise ValueError("contact_emails must contain at least one item") + return value + + +class UpdateDatasetValidationMixin(BaseModel): + @validator("title", check_fields=False) + def check_title_not_empty(cls, value: str) -> str: + if not value: + raise ValueError("title must not be empty") + return value + + @validator("description", check_fields=False) + def check_description_not_empty(cls, value: str) -> str: + if not value: + raise ValueError("description must not be empty") + return value + + @validator("service", check_fields=False) + def check_service_not_empty(cls, value: str) -> str: + if not value: + raise ValueError("service must not be empty") + return value + + @validator("formats", check_fields=False) + def check_formats_at_least_one(cls, value: List[DataFormat]) -> List[DataFormat]: + if not value: + raise ValueError("formats must contain at least one item") + return value + + @validator("contact_emails", check_fields=False) + def check_contact_emails_at_least_one(cls, value: List[str]) -> List[str]: + if not value: + raise ValueError("contact_emails must contain at least one item") + return value + + @validator("published_url", check_fields=False) + def check_published_url_not_empty(cls, value: Optional[str]) -> Optional[str]: + if value is not None and not value: + raise ValueError("published_url must not be empty") + return value