From 7160ae5b7d81ed452d0551eb50bf8d234d5c1313 Mon Sep 17 00:00:00 2001 From: Caleb Salt Date: Fri, 13 Dec 2024 11:13:43 -0500 Subject: [PATCH] feat: maintain pydantic.v1 model compatibility while using pydantic v2 (#393) * We have pydantic V2 installed in our environemnt, but have been using `from pydantic.v1 import ...`. With the new changes to support pydantic V2 we are wanting to upgrade Spectree to latest and begin writing new serializers, however, we are not ready to update all of our current models to pydantic be pydantic v2 BaseModels. Currently if a ValidationError is raised, the falcon plug in does not catch it, since ValidationError in the plugin is coming from pydantic, not pydantic.v1. Since you are aliasing the pydantic.v1.ValidationError as InternalValidationError, catching it as well allows us to continue to support our v1 models while we migrate to v2 models. Looking at the codebase, this looks like a similar issue in the other plugins. Let me know if you would like me to submit this update for them as well. I'm not sure where you would like a test for this in the project. * update other plugins * test to verify functionality * bump version * update test * make test routes local to test * ruff formatting --- pyproject.toml | 2 +- spectree/config.py | 1 + spectree/plugins/falcon_plugin.py | 10 +++---- spectree/plugins/flask_plugin.py | 6 ++-- spectree/plugins/quart_plugin.py | 6 ++-- spectree/plugins/starlette_plugin.py | 5 ++-- tests/test_plugin_falcon.py | 42 ++++++++++++++++++++++++++++ 7 files changed, 58 insertions(+), 14 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 815eaf8..dbff134 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "spectree" -version = "1.4.1" +version = "1.4.2" dynamic = [] description = "generate OpenAPI document and validate request&response with Python annotations." readme = "README.md" diff --git a/spectree/config.py b/spectree/config.py index a25a155..46342fe 100644 --- a/spectree/config.py +++ b/spectree/config.py @@ -40,6 +40,7 @@ class License(InternalBaseModel): class Configuration(InternalBaseModel): """Global configuration.""" + # OpenAPI configurations #: title of the service title: str = "Service API Document" diff --git a/spectree/plugins/falcon_plugin.py b/spectree/plugins/falcon_plugin.py index 5290ef3..5f2be2a 100644 --- a/spectree/plugins/falcon_plugin.py +++ b/spectree/plugins/falcon_plugin.py @@ -6,7 +6,7 @@ from falcon import HTTP_400, HTTP_415, HTTPError from falcon.routing.compiled import _FIELD_PATTERN as FALCON_FIELD_PATTERN -from spectree._pydantic import ValidationError +from spectree._pydantic import InternalValidationError, ValidationError from spectree._types import ModelType from spectree.plugins.base import BasePlugin, validate_response from spectree.response import Response @@ -211,7 +211,7 @@ def validate( try: self.request_validation(_req, query, json, form, headers, cookies) - except ValidationError as err: + except (InternalValidationError, ValidationError) as err: req_validation_error = err _resp.status = f"{validation_error_status} Validation Error" _resp.media = err.errors() @@ -235,7 +235,7 @@ def validate( validation_model=resp.find_model(status), response_payload=_resp.media, ) - except ValidationError as err: + except (InternalValidationError, ValidationError) as err: resp_validation_error = err _resp.status = HTTP_500 _resp.media = err.errors() @@ -317,7 +317,7 @@ async def validate( try: await self.request_validation(_req, query, json, form, headers, cookies) - except ValidationError as err: + except (InternalValidationError, ValidationError) as err: req_validation_error = err _resp.status = f"{validation_error_status} Validation Error" _resp.media = err.errors() @@ -345,7 +345,7 @@ async def validate( validation_model=resp.find_model(status) if resp else None, response_payload=_resp.media, ) - except ValidationError as err: + except (InternalValidationError, ValidationError) as err: resp_validation_error = err _resp.status = HTTP_500 _resp.media = err.errors() diff --git a/spectree/plugins/flask_plugin.py b/spectree/plugins/flask_plugin.py index dddd565..2b00531 100644 --- a/spectree/plugins/flask_plugin.py +++ b/spectree/plugins/flask_plugin.py @@ -4,7 +4,7 @@ from flask import Blueprint, abort, current_app, jsonify, make_response, request from werkzeug.routing import parse_converter_args -from spectree._pydantic import ValidationError +from spectree._pydantic import InternalValidationError, ValidationError from spectree._types import ModelType from spectree.plugins.base import BasePlugin, Context, validate_response from spectree.response import Response @@ -185,7 +185,7 @@ def validate( if not skip_validation: try: self.request_validation(request, query, json, form, headers, cookies) - except ValidationError as err: + except (InternalValidationError, ValidationError) as err: req_validation_error = err response = make_response(jsonify(err.errors()), validation_error_status) @@ -223,7 +223,7 @@ def validate( validation_model=resp.find_model(status), response_payload=payload, ) - except ValidationError as err: + except (InternalValidationError, ValidationError) as err: response = make_response(err.errors(), 500) resp_validation_error = err else: diff --git a/spectree/plugins/quart_plugin.py b/spectree/plugins/quart_plugin.py index 64e9e37..fe82a6c 100644 --- a/spectree/plugins/quart_plugin.py +++ b/spectree/plugins/quart_plugin.py @@ -5,7 +5,7 @@ from quart import Blueprint, abort, current_app, jsonify, make_response, request from werkzeug.routing import parse_converter_args -from spectree._pydantic import ValidationError +from spectree._pydantic import InternalValidationError, ValidationError from spectree._types import ModelType from spectree.plugins.base import BasePlugin, Context, validate_response from spectree.response import Response @@ -195,7 +195,7 @@ async def validate( await self.request_validation( request, query, json, form, headers, cookies ) - except ValidationError as err: + except (InternalValidationError, ValidationError) as err: req_validation_error = err response = await make_response( jsonify(err.errors()), validation_error_status @@ -240,7 +240,7 @@ async def validate( validation_model=resp.find_model(status), response_payload=payload, ) - except ValidationError as err: + except (InternalValidationError, ValidationError) as err: response = await make_response(err.errors(), 500) resp_validation_error = err else: diff --git a/spectree/plugins/starlette_plugin.py b/spectree/plugins/starlette_plugin.py index 465c0d3..dd8e1a6 100644 --- a/spectree/plugins/starlette_plugin.py +++ b/spectree/plugins/starlette_plugin.py @@ -10,6 +10,7 @@ from starlette.routing import compile_path from spectree._pydantic import ( + InternalValidationError, ValidationError, generate_root_model, serialize_model_instance, @@ -114,7 +115,7 @@ async def validate( await self.request_validation( request, query, json, form, headers, cookies ) - except ValidationError as err: + except (InternalValidationError, ValidationError) as err: req_validation_error = err response = JSONResponse(err.errors(), validation_error_status) except JSONDecodeError as err: @@ -160,7 +161,7 @@ async def validate( validation_model=resp.find_model(response.status_code), response_payload=RawResponsePayload(payload=response.body), ) - except ValidationError as err: + except (InternalValidationError, ValidationError) as err: response = JSONResponse(err.errors(), 500) resp_validation_error = err diff --git a/tests/test_plugin_falcon.py b/tests/test_plugin_falcon.py index fe37a59..88f598c 100644 --- a/tests/test_plugin_falcon.py +++ b/tests/test_plugin_falcon.py @@ -6,6 +6,11 @@ from falcon import HTTP_202, App, testing from spectree import Response, SpecTree +from spectree._pydantic import ( + PYDANTIC2, + BaseModel, + InternalBaseModel, +) from .common import ( JSON, @@ -545,3 +550,40 @@ def test_falcon_optional_alias_response(client): ) assert resp.status_code == 200, resp.json assert resp.json == {"schema": "test"}, resp.json + + +@pytest.mark.skipif(not PYDANTIC2, reason="only matters if using both model types") +def test_falcon_validate_both_v1_and_v2_validation_errors(client): + class CompatibilityView: + name = "validation works for both pydantic v1 and v2 models simultaneously" + + class V1(InternalBaseModel): + value: int + + class V2(BaseModel): + value: int + + @api.validate( + resp=Response(HTTP_200=Resp), + ) + def on_post_v1(self, req, resp, json: V1): + resp.media = Resp(name="falcon v1", score=[1, 2, 3]) + + @api.validate( + resp=Response(HTTP_200=Resp), + ) + def on_post_v2(self, req, resp, json: V2): + resp.media = Resp(name="falcon v2", score=[1, 2, 3]) + + app.add_route("/api/compatibility/v1", CompatibilityView(), suffix="v1") + app.add_route("/api/compatibility/v2", CompatibilityView(), suffix="v2") + + resp = client.simulate_request( + "POST", "/api/compatibility/v1", json={"value": "invalid"} + ) + assert resp.status_code == 422 + + resp = client.simulate_request( + "POST", "/api/compatibility/v2", json={"value": "invalid"} + ) + assert resp.status_code == 422