├── .circleci └── config.yml ├── .github └── workflows │ └── tests.yml ├── .gitignore ├── .sonarcloud.properties ├── HISTORY.md ├── LICENSE ├── README.md ├── example_app ├── app.py └── example.py ├── flask_pydantic ├── __init__.py ├── converters.py ├── core.py ├── exceptions.py └── version.py ├── requirements ├── base.pip └── test.pip ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── conftest.py ├── func ├── __init__.py └── test_app.py └── unit ├── __init__.py └── test_core.py /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Python CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-python/ for more details 4 | # 5 | version: 2 6 | jobs: 7 | build: 8 | docker: 9 | # specify the version you desire here 10 | # use `-browsers` prefix for selenium tests, e.g. `3.6.1-browsers` 11 | - image: circleci/python:3.7 12 | 13 | working_directory: ~/repo 14 | 15 | steps: 16 | - checkout 17 | 18 | # Download and cache dependencies 19 | - restore_cache: 20 | keys: 21 | - v1-dependencies-{{ checksum "requirements/base.pip" }}-{{ checksum "requirements/test.pip" }} 22 | # fallback to using the latest cache if no exact match is found 23 | - v1-dependencies- 24 | 25 | - run: 26 | name: install dependencies 27 | command: | 28 | python3 -m venv venv 29 | . venv/bin/activate 30 | pip install -r requirements/test.pip 31 | 32 | - save_cache: 33 | paths: 34 | - ./venv 35 | key: v1-dependencies-{{ checksum "requirements/base.pip" }}-{{ checksum "requirements/test.pip" }} 36 | 37 | # run tests! 38 | # this example uses Django's built-in test-runner 39 | # other common Python testing frameworks include pytest and nose 40 | # https://pytest.org 41 | # https://nose.readthedocs.io 42 | - run: 43 | name: run tests 44 | command: | 45 | . venv/bin/activate 46 | python -m pytest 47 | 48 | - store_artifacts: 49 | path: htmlcov 50 | destination: test-reports 51 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | python-version: ["3.7", "3.8", "3.9", "3.10"] 12 | os: [ubuntu-latest, macOS-latest] 13 | 14 | steps: 15 | - uses: actions/checkout@v1 16 | - name: Set up Python ${{ matrix.python-version }} 17 | uses: actions/setup-python@v1 18 | with: 19 | python-version: ${{ matrix.python-version }} 20 | - name: Install dependencies 21 | if: steps.cache-pip.outputs.cache-hit != 'true' 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install -r requirements/test.pip 25 | - name: Test with pytest 26 | run: | 27 | python3 -m pytest 28 | - name: Lint with flake8 29 | run: | 30 | flake8 . 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # VS Code settings 132 | .vscode 133 | 134 | docker-compose.yaml 135 | .dockerignore 136 | .idea/ -------------------------------------------------------------------------------- /.sonarcloud.properties: -------------------------------------------------------------------------------- 1 | sonar.tests=tests 2 | sonar.sources=flask_pydantic 3 | sonar.exclusions=example_app/**/* 4 | -------------------------------------------------------------------------------- /HISTORY.md: -------------------------------------------------------------------------------- 1 | # Release history 2 | 3 | ## 0.11.0 (2022-09-25) 4 | ### Features 5 | - Allow raising `flask_pydantic.ValidationError` by setting `FLASK_PYDANTIC_VALIDATION_ERROR_RAISE=True` 6 | 7 | ## 0.10.0 (2022-07-31) 8 | ### Features 9 | - Add validation for form data 10 | - Handle extra headers returned by route functions 11 | 12 | ### Internal 13 | - Cleanup pipelines, drop python 3.6 tests, test on MacOS images 14 | 15 | ## 0.9.0 (2021-10-28) 16 | ### Features 17 | - Support for passing parameters to [`flask.Request.get_json`](https://tedboy.github.io/flask/generated/generated/flask.Request.get_json.html) function via `validate`'s `get_json_params` parameter 18 | 19 | ### Internal 20 | - Add tests for Python 3.10 to pipeline 21 | 22 | ## 0.8.0 (2021-05-09) 23 | ### Features 24 | - Return `400` response when model's `__root__` validation fails 25 | 26 | ## 0.7.2 (2021-04-26) 27 | ### Bugfixes 28 | - ignore return-type annotations 29 | 30 | ## 0.7.1 (2021-04-08) 31 | ### Bugfixes 32 | - recognize mime types with character encoding standard 33 | 34 | ## 0.7.0 (2021-04-05) 35 | ### Features 36 | - add support for URL path parameters parsing and validation 37 | 38 | ## 0.6.3 (2021-03-26) 39 | - do pin specific versions of required packages 40 | 41 | ## 0.6.2 (2021-03-09) 42 | ### Bugfixes 43 | - fix type annotations of decorated method 44 | 45 | ## 0.6.1 (2021-02-18) 46 | ### Bugfixes 47 | - parsing of query parameters in older versions of python 3.6 48 | 49 | 50 | ## 0.6.0 (2021-01-31) 51 | ### Features 52 | - improve README, example app 53 | - add support for pydantic's [custom root types](https://pydantic-docs.helpmanual.io/usage/models/#custom-root-types) 54 | 55 | 56 | ## 0.5.0 (2021-01-17) 57 | ### Features 58 | - add `Flask` classifier 59 | 60 | ## 0.4.0 (2020-09-10) 61 | ### Features 62 | - add support for [alias feature](https://pydantic-docs.helpmanual.io/usage/model_config/#alias-generator) in response models 63 | 64 | 65 | ## 0.3.0 (2020-09-08) 66 | ### Features 67 | - add possibility to specify models using keyword arguments 68 | 69 | 70 | ## 0.2.0 (2020-08-07) 71 | ### Features 72 | - add support for python version `3.6` 73 | 74 | 75 | ## 0.1.0 (2020-08-02) 76 | ### Features 77 | - add proper parsing and validation of array query parameters 78 | 79 | 80 | ## 0.0.7 (2020-07-20) 81 | - add possibility to configure response status code after `ValidationError` using flask app config value `FLASK_PYDANTIC_VALIDATION_ERROR_STATUS_CODE` 82 | 83 | 84 | ## 0.0.6 (2020-06-11) 85 | ### Features 86 | - return `415 - Unsupported media type` response for requests to endpoints with specified body model with other content type than `application/json`. 87 | 88 | 89 | ## 0.0.5 (2020-01-15) 90 | ### Bugfixes 91 | - do not try to access query or body requests parameters unless model is provided 92 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Jiri Bauer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Flask-Pydantic 2 | 3 | [![Actions Status](https://github.com/bauerji/flask_pydantic/workflows/Tests/badge.svg?branch=master)](https://github.com/bauerji/flask_pydantic/actions/workflows/tests.yml) 4 | [![PyPI](https://img.shields.io/pypi/v/Flask-Pydantic?color=g)](https://pypi.org/project/Flask-Pydantic/) 5 | [![Language grade: Python](https://img.shields.io/lgtm/grade/python/g/bauerji/flask_pydantic.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/bauerji/flask_pydantic/context:python) 6 | [![License](https://img.shields.io/badge/license-MIT-purple)](https://github.com/bauerji/flask_pydantic/blob/master/LICENSE) 7 | [![Code style](https://img.shields.io/badge/code%20style-black-black)](https://github.com/psf/black) 8 | 9 | Flask extension for integration of the awesome [pydantic package](https://github.com/samuelcolvin/pydantic) with [Flask](https://palletsprojects.com/p/flask/). 10 | 11 | ## Installation 12 | 13 | `python3 -m pip install Flask-Pydantic` 14 | 15 | ## Basics 16 | ### URL query and body parameters 17 | 18 | `validate` decorator validates query, body and form-data request parameters and makes them accessible two ways: 19 | 20 | 1. [Using `validate` arguments, via flask's `request` variable](#basic-example) 21 | 22 | | **parameter type** | **`request` attribute name** | 23 | |:------------------:|:----------------------------:| 24 | | query | `query_params` | 25 | | body | `body_params` | 26 | | form | `form_params` | 27 | 28 | 2. [Using the decorated function argument parameters type hints](#using-the-decorated-function-kwargs) 29 | 30 | ### URL path parameter 31 | 32 | If you use annotated path URL path parameters as follows 33 | ```python 34 | 35 | @app.route("/users/", methods=["GET"]) 36 | @validate() 37 | def get_user(user_id: str): 38 | pass 39 | ``` 40 | flask_pydantic will parse and validate `user_id` variable in the same manner as for body and query parameters. 41 | 42 | --- 43 | 44 | ### Additional `validate` arguments 45 | 46 | - Success response status code can be modified via `on_success_status` parameter of `validate` decorator. 47 | - `response_many` parameter set to `True` enables serialization of multiple models (route function should therefore return iterable of models). 48 | - `request_body_many` parameter set to `False` analogically enables serialization of multiple models inside of the root level of request body. If the request body doesn't contain an array of objects `400` response is returned, 49 | - `get_json_params` - parameters to be passed to [`flask.Request.get_json`](https://tedboy.github.io/flask/generated/generated/flask.Request.get_json.html) function 50 | - If validation fails, `400` response is returned with failure explanation. 51 | 52 | For more details see in-code docstring or example app. 53 | 54 | ## Usage 55 | 56 | ### Example 1: Query parameters only 57 | 58 | Simply use `validate` decorator on route function. 59 | 60 | :exclamation: Be aware that `@app.route` decorator must precede `@validate` (i. e. `@validate` must be closer to the function declaration). 61 | 62 | ```python 63 | from typing import Optional 64 | from flask import Flask, request 65 | from pydantic import BaseModel 66 | 67 | from flask_pydantic import validate 68 | 69 | app = Flask("flask_pydantic_app") 70 | 71 | class QueryModel(BaseModel): 72 | age: int 73 | 74 | class ResponseModel(BaseModel): 75 | id: int 76 | age: int 77 | name: str 78 | nickname: Optional[str] 79 | 80 | # Example 1: query parameters only 81 | @app.route("/", methods=["GET"]) 82 | @validate() 83 | def get(query: QueryModel): 84 | age = query.age 85 | return ResponseModel( 86 | age=age, 87 | id=0, name="abc", nickname="123" 88 | ) 89 | ``` 90 | 91 | 92 | See the full example app here 93 | 94 | 95 | 96 | - `age` query parameter is a required `int` 97 | - `curl --location --request GET 'http://127.0.0.1:5000/'` 98 | - if none is provided the response contains: 99 | ```json 100 | { 101 | "validation_error": { 102 | "query_params": [ 103 | { 104 | "loc": ["age"], 105 | "msg": "field required", 106 | "type": "value_error.missing" 107 | } 108 | ] 109 | } 110 | } 111 | ``` 112 | - for incompatible type (e. g. string `/?age=not_a_number`) 113 | - `curl --location --request GET 'http://127.0.0.1:5000/?age=abc'` 114 | ```json 115 | { 116 | "validation_error": { 117 | "query_params": [ 118 | { 119 | "loc": ["age"], 120 | "msg": "value is not a valid integer", 121 | "type": "type_error.integer" 122 | } 123 | ] 124 | } 125 | } 126 | ``` 127 | - likewise for body parameters 128 | - example call with valid parameters: 129 | `curl --location --request GET 'http://127.0.0.1:5000/?age=20'` 130 | 131 | -> `{"id": 0, "age": 20, "name": "abc", "nickname": "123"}` 132 | 133 | 134 | ### Example 2: URL path parameter 135 | 136 | ```python 137 | @app.route("/character//", methods=["GET"]) 138 | @validate() 139 | def get_character(character_id: int): 140 | characters = [ 141 | ResponseModel(id=1, age=95, name="Geralt", nickname="White Wolf"), 142 | ResponseModel(id=2, age=45, name="Triss Merigold", nickname="sorceress"), 143 | ResponseModel(id=3, age=42, name="Julian Alfred Pankratz", nickname="Jaskier"), 144 | ResponseModel(id=4, age=101, name="Yennefer", nickname="Yenn"), 145 | ] 146 | try: 147 | return characters[character_id] 148 | except IndexError: 149 | return {"error": "Not found"}, 400 150 | ``` 151 | 152 | 153 | ### Example 3: Request body only 154 | 155 | ```python 156 | class RequestBodyModel(BaseModel): 157 | name: str 158 | nickname: Optional[str] 159 | 160 | # Example2: request body only 161 | @app.route("/", methods=["POST"]) 162 | @validate() 163 | def post(body: RequestBodyModel): 164 | name = body.name 165 | nickname = body.nickname 166 | return ResponseModel( 167 | name=name, nickname=nickname,id=0, age=1000 168 | ) 169 | ``` 170 | 171 | 172 | See the full example app here 173 | 174 | 175 | ### Example 4: BOTH query paramaters and request body 176 | 177 | ```python 178 | # Example 3: both query paramters and request body 179 | @app.route("/both", methods=["POST"]) 180 | @validate() 181 | def get_and_post(body: RequestBodyModel,query: QueryModel): 182 | name = body.name # From request body 183 | nickname = body.nickname # From request body 184 | age = query.age # from query parameters 185 | return ResponseModel( 186 | age=age, name=name, nickname=nickname, 187 | id=0 188 | ) 189 | ``` 190 | 191 | 192 | See the full example app here 193 | 194 | 195 | 196 | ### Example 5: Request form-data only 197 | 198 | ```python 199 | class RequestFormDataModel(BaseModel): 200 | name: str 201 | nickname: Optional[str] 202 | 203 | # Example2: request body only 204 | @app.route("/", methods=["POST"]) 205 | @validate() 206 | def post(form: RequestFormDataModel): 207 | name = form.name 208 | nickname = form.nickname 209 | return ResponseModel( 210 | name=name, nickname=nickname,id=0, age=1000 211 | ) 212 | ``` 213 | 214 | 215 | See the full example app here 216 | 217 | 218 | ### Modify response status code 219 | 220 | The default success status code is `200`. It can be modified in two ways 221 | 222 | - in return statement 223 | 224 | ```python 225 | # necessary imports, app and models definition 226 | ... 227 | 228 | @app.route("/", methods=["POST"]) 229 | @validate(body=BodyModel, query=QueryModel) 230 | def post(): 231 | return ResponseModel( 232 | id=id_, 233 | age=request.query_params.age, 234 | name=request.body_params.name, 235 | nickname=request.body_params.nickname, 236 | ), 201 237 | ``` 238 | 239 | - in `validate` decorator 240 | 241 | ```python 242 | @app.route("/", methods=["POST"]) 243 | @validate(body=BodyModel, query=QueryModel, on_success_status=201) 244 | def post(): 245 | ... 246 | ``` 247 | 248 | Status code in case of validation error can be modified using `FLASK_PYDANTIC_VALIDATION_ERROR_STATUS_CODE` flask configuration variable. 249 | 250 | ### Using the decorated function `kwargs` 251 | 252 | Instead of passing `body` and `query` to `validate`, it is possible to directly 253 | defined them by using type hinting in the decorated function. 254 | 255 | ```python 256 | # necessary imports, app and models definition 257 | ... 258 | 259 | @app.route("/", methods=["POST"]) 260 | @validate() 261 | def post(body: BodyModel, query: QueryModel): 262 | return ResponseModel( 263 | id=id_, 264 | age=query.age, 265 | name=body.name, 266 | nickname=body.nickname, 267 | ) 268 | ``` 269 | 270 | This way, the parsed data will be directly available in `body` and `query`. 271 | Furthermore, your IDE will be able to correctly type them. 272 | 273 | ### Model aliases 274 | 275 | Pydantic's [alias feature](https://pydantic-docs.helpmanual.io/usage/model_config/#alias-generator) is natively supported for query and body models. 276 | To use aliases in response modify response model 277 | ```python 278 | def modify_key(text: str) -> str: 279 | # do whatever you want with model keys 280 | return text 281 | 282 | 283 | class MyModel(BaseModel): 284 | ... 285 | class Config: 286 | alias_generator = modify_key 287 | allow_population_by_field_name = True 288 | 289 | ``` 290 | 291 | and set `response_by_alias=True` in `validate` decorator 292 | ``` 293 | @app.route(...) 294 | @validate(response_by_alias=True) 295 | def my_route(): 296 | ... 297 | return MyModel(...) 298 | ``` 299 | 300 | ### Example app 301 | 302 | For more complete examples see [example application](https://github.com/bauerji/flask_pydantic/tree/master/example_app). 303 | 304 | ### Configuration 305 | 306 | The behaviour can be configured using flask's application config 307 | `FLASK_PYDANTIC_VALIDATION_ERROR_STATUS_CODE` - response status code after validation error (defaults to `400`) 308 | 309 | Additionally, you can set `FLASK_PYDANTIC_VALIDATION_ERROR_RAISE` to `True` to cause 310 | `flask_pydantic.ValidationError` to be raised with either `body_params`, 311 | `form_params`, `path_params`, or `query_params` set as a list of error 312 | dictionaries. You can use `flask.Flask.register_error_handler` to catch that 313 | exception and fully customize the output response for a validation error. 314 | 315 | ## Contributing 316 | 317 | Feature requests and pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. 318 | 319 | - clone repository 320 | ```bash 321 | git clone https://github.com/bauerji/flask_pydantic.git 322 | cd flask_pydantic 323 | ``` 324 | - create virtual environment and activate it 325 | ```bash 326 | python3 -m venv venv 327 | source venv/bin/activate 328 | ``` 329 | - install development requirements 330 | ```bash 331 | python3 -m pip install -r requirements/test.pip 332 | ``` 333 | - checkout new branch and make your desired changes (don't forget to update tests) 334 | ```bash 335 | git checkout -b 336 | ``` 337 | - run tests 338 | ```bash 339 | python3 -m pytest 340 | ``` 341 | - if tests fails on Black tests, make sure You have your code compliant with style of [Black formatter](https://github.com/psf/black) 342 | - push your changes and create a pull request to master branch 343 | 344 | ## TODOs: 345 | 346 | - header request parameters 347 | - cookie request parameters 348 | -------------------------------------------------------------------------------- /example_app/app.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Optional 3 | 4 | from flask import Flask, jsonify, request 5 | from flask_pydantic import validate 6 | from pydantic import BaseModel 7 | 8 | app = Flask("flask_pydantic_app") 9 | 10 | 11 | @dataclass 12 | class Config: 13 | FLASK_PYDANTIC_VALIDATION_ERROR_STATUS_CODE: int = 422 14 | 15 | 16 | app.config.from_object(Config) 17 | 18 | 19 | class QueryModel(BaseModel): 20 | age: int 21 | 22 | 23 | class IndexParam(BaseModel): 24 | index: int 25 | 26 | 27 | class BodyModel(BaseModel): 28 | name: str 29 | nickname: Optional[str] 30 | 31 | 32 | class FormModel(BaseModel): 33 | name: str 34 | nickname: Optional[str] 35 | 36 | 37 | class ResponseModel(BaseModel): 38 | id: int 39 | age: int 40 | name: str 41 | nickname: Optional[str] 42 | 43 | 44 | @app.route("/", methods=["POST"]) 45 | @validate(body=BodyModel, query=QueryModel) 46 | def post(): 47 | """ 48 | Basic example with both query and body parameters, response object serialization. 49 | """ 50 | # save model to DB 51 | id_ = 2 52 | 53 | return ResponseModel( 54 | id=id_, 55 | age=request.query_params.age, 56 | name=request.body_params.name, 57 | nickname=request.body_params.nickname, 58 | ) 59 | 60 | 61 | @app.route("/form", methods=["POST"]) 62 | @validate(form=FormModel, query=QueryModel) 63 | def post(): 64 | """ 65 | Basic example with both query and form-data parameters, response object serialization. 66 | """ 67 | # save model to DB 68 | id_ = 2 69 | 70 | return ResponseModel( 71 | id=id_, 72 | age=request.query_params.age, 73 | name=request.form_params.name, 74 | nickname=request.form_params.nickname, 75 | ) 76 | 77 | 78 | @app.route("/kwargs", methods=["POST"]) 79 | @validate() 80 | def post_kwargs(body: BodyModel, query: QueryModel): 81 | """ 82 | Basic example with both query and body parameters, response object serialization. 83 | This time using the decorated function kwargs `body` and `query` type hinting 84 | """ 85 | # save model to DB 86 | id_ = 3 87 | 88 | return ResponseModel(id=id_, age=query.age, name=body.name, nickname=body.nickname) 89 | 90 | 91 | @app.route("/form/kwargs", methods=["POST"]) 92 | @validate() 93 | def post_kwargs(form: FormModel, query: QueryModel): 94 | """ 95 | Basic example with both query and form-data parameters, response object serialization. 96 | This time using the decorated function kwargs `form` and `query` type hinting 97 | """ 98 | # save model to DB 99 | id_ = 3 100 | 101 | return ResponseModel(id=id_, age=query.age, name=form.name, nickname=form.nickname) 102 | 103 | 104 | @app.route("/many", methods=["GET"]) 105 | @validate(response_many=True) 106 | def get_many(): 107 | """ 108 | This route returns response containing many serialized objects. 109 | """ 110 | return [ 111 | ResponseModel(id=1, age=95, name="Geralt", nickname="White Wolf"), 112 | ResponseModel(id=2, age=45, name="Triss Merigold", nickname="sorceress"), 113 | ResponseModel(id=3, age=42, name="Julian Alfred Pankratz", nickname="Jaskier"), 114 | ResponseModel(id=4, age=101, name="Yennefer", nickname="Yenn"), 115 | ] 116 | 117 | 118 | @app.route("/select", methods=["POST"]) 119 | @validate(request_body_many=True, query=IndexParam, body=BodyModel) 120 | def select_from_array(): 121 | """ 122 | This route takes array of objects in request body and returns the object on index 123 | (index is a url query parameter) 124 | """ 125 | try: 126 | return BodyModel(**request.body_params[request.query_params.index].dict()) 127 | except IndexError: 128 | return jsonify({"reason": "index out of bound"}), 400 129 | -------------------------------------------------------------------------------- /example_app/example.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from flask import Flask 4 | from flask_pydantic import validate 5 | from pydantic import BaseModel 6 | 7 | app = Flask("flask_pydantic_app") 8 | 9 | 10 | class RequestBodyModel(BaseModel): 11 | name: str 12 | nickname: Optional[str] 13 | 14 | 15 | class QueryModel(BaseModel): 16 | age: int 17 | 18 | 19 | class FormModel(BaseModel): 20 | name: str 21 | nickname: Optional[str] 22 | 23 | 24 | @app.route("/", methods=["GET"]) 25 | @validate() 26 | def get(query: QueryModel): 27 | age = query.age 28 | return ResponseModel(age=age, id=0, name="abc", nickname="123") 29 | 30 | 31 | """ 32 | curl --location --request GET 'http://127.0.0.1:5000/' 33 | curl --location --request GET 'http://127.0.0.1:5000/?ageeee=5' 34 | curl --location --request GET 'http://127.0.0.1:5000/?age=abc' 35 | 36 | curl --location --request GET 'http://127.0.0.1:5000/?age=5' 37 | """ 38 | 39 | 40 | class ResponseModel(BaseModel): 41 | id: int 42 | age: int 43 | name: str 44 | nickname: Optional[str] 45 | 46 | 47 | @app.route("/character//", methods=["GET"]) 48 | @validate() 49 | def get_character(character_id: int): 50 | characters = [ 51 | ResponseModel(id=1, age=95, name="Geralt", nickname="White Wolf"), 52 | ResponseModel(id=2, age=45, name="Triss Merigold", nickname="sorceress"), 53 | ResponseModel(id=3, age=42, name="Julian Alfred Pankratz", nickname="Jaskier"), 54 | ResponseModel(id=4, age=101, name="Yennefer", nickname="Yenn"), 55 | ] 56 | try: 57 | return characters[character_id] 58 | except IndexError: 59 | return {"error": "Not found"}, 400 60 | 61 | 62 | """ 63 | curl http://127.0.0.1:5000/character/2/ \ 64 | --header 'Content-Type: application/json' 65 | """ 66 | 67 | 68 | @app.route("/", methods=["POST"]) 69 | @validate() 70 | def post(body: RequestBodyModel): 71 | name = body.name 72 | nickname = body.nickname 73 | return ResponseModel(name=name, nickname=nickname, id=0, age=1000) 74 | 75 | 76 | """ 77 | curl --location --request POST 'http://127.0.0.1:5000/' 78 | 79 | curl --location --request POST 'http://127.0.0.1:5000/' \ 80 | --header 'Content-Type: application/json' \ 81 | --data-raw '{' 82 | 83 | curl --location --request POST 'http://127.0.0.1:5000/' \ 84 | --header 'Content-Type: application/json' \ 85 | --data-raw '{"nameee":123}' 86 | 87 | curl --location --request POST 'http://127.0.0.1:5000/' \ 88 | --header 'Content-Type: application/json' \ 89 | --data-raw '{"name":123}' 90 | """ 91 | 92 | 93 | @app.route("/form", methods=["POST"]) 94 | @validate() 95 | def post(form: FormModel): 96 | name = form.name 97 | nickname = form.nickname 98 | return ResponseModel(name=name, nickname=nickname, id=0, age=1000) 99 | 100 | 101 | """ 102 | curl --location --request POST 'http://127.0.0.1:5000/form' 103 | 104 | curl --location --request POST 'http://127.0.0.1:5000/form' \ 105 | -F name=123\ 106 | 107 | curl --location --request POST 'http://127.0.0.1:5000/form' \ 108 | -F name=some-name 109 | """ 110 | 111 | 112 | @app.route("/both", methods=["POST"]) 113 | @validate() 114 | def get_and_post(body: RequestBodyModel, query: QueryModel): 115 | name = body.name # From request body 116 | nickname = body.nickname # From request body 117 | age = query.age # from query parameters 118 | return ResponseModel(age=age, name=name, nickname=nickname, id=0) 119 | 120 | 121 | """ 122 | curl --location --request POST 'http://127.0.0.1:5000/both' \ 123 | --header 'Content-Type: application/json' \ 124 | --data-raw '{"name":123}' 125 | 126 | curl --location --request POST 'http://127.0.0.1:5000/both?age=40' \ 127 | --header 'Content-Type: application/json' \ 128 | --data-raw '{"name":123}' 129 | """ 130 | 131 | 132 | if __name__ == "__main__": 133 | app.run() 134 | -------------------------------------------------------------------------------- /flask_pydantic/__init__.py: -------------------------------------------------------------------------------- 1 | from .core import validate # noqa: F401 2 | from .exceptions import ValidationError # noqa: F401 3 | from .version import __version__ # noqa: F401 4 | -------------------------------------------------------------------------------- /flask_pydantic/converters.py: -------------------------------------------------------------------------------- 1 | from typing import Type 2 | 3 | from pydantic import BaseModel 4 | from werkzeug.datastructures import ImmutableMultiDict 5 | 6 | 7 | def convert_query_params( 8 | query_params: ImmutableMultiDict, model: Type[BaseModel] 9 | ) -> dict: 10 | """ 11 | group query parameters into lists if model defines them 12 | 13 | :param query_params: flasks request.args 14 | :param model: query parameter's model 15 | :return: resulting parameters 16 | """ 17 | return { 18 | **query_params.to_dict(), 19 | **{ 20 | key: value 21 | for key, value in query_params.to_dict(flat=False).items() 22 | if key in model.__fields__ and model.__fields__[key].is_complex() 23 | }, 24 | } 25 | -------------------------------------------------------------------------------- /flask_pydantic/core.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | from typing import Any, Callable, Iterable, List, Optional, Tuple, Type, Union 3 | 4 | from flask import Response, current_app, jsonify, make_response, request 5 | from pydantic import BaseModel, ValidationError 6 | from pydantic.tools import parse_obj_as 7 | 8 | from .converters import convert_query_params 9 | from .exceptions import ( 10 | InvalidIterableOfModelsException, 11 | JsonBodyParsingError, 12 | ManyModelValidationError, 13 | ) 14 | from .exceptions import ValidationError as FailedValidation 15 | 16 | try: 17 | from flask_restful import original_flask_make_response as make_response 18 | except ImportError: 19 | pass 20 | 21 | 22 | def make_json_response( 23 | content: Union[BaseModel, Iterable[BaseModel]], 24 | status_code: int, 25 | by_alias: bool, 26 | exclude_none: bool = False, 27 | many: bool = False, 28 | ) -> Response: 29 | """serializes model, creates JSON response with given status code""" 30 | if many: 31 | js = f"[{', '.join([model.json(exclude_none=exclude_none, by_alias=by_alias) for model in content])}]" 32 | else: 33 | js = content.json(exclude_none=exclude_none, by_alias=by_alias) 34 | response = make_response(js, status_code) 35 | response.mimetype = "application/json" 36 | return response 37 | 38 | 39 | def unsupported_media_type_response(request_cont_type: str) -> Response: 40 | body = { 41 | "detail": f"Unsupported media type '{request_cont_type}' in request. " 42 | "'application/json' is required." 43 | } 44 | return make_response(jsonify(body), 415) 45 | 46 | 47 | def is_iterable_of_models(content: Any) -> bool: 48 | try: 49 | return all(isinstance(obj, BaseModel) for obj in content) 50 | except TypeError: 51 | return False 52 | 53 | 54 | def validate_many_models(model: Type[BaseModel], content: Any) -> List[BaseModel]: 55 | try: 56 | return [model(**fields) for fields in content] 57 | except TypeError: 58 | # iteration through `content` fails 59 | err = [ 60 | { 61 | "loc": ["root"], 62 | "msg": "is not an array of objects", 63 | "type": "type_error.array", 64 | } 65 | ] 66 | raise ManyModelValidationError(err) 67 | except ValidationError as ve: 68 | raise ManyModelValidationError(ve.errors()) 69 | 70 | 71 | def validate_path_params(func: Callable, kwargs: dict) -> Tuple[dict, list]: 72 | errors = [] 73 | validated = {} 74 | for name, type_ in func.__annotations__.items(): 75 | if name in {"query", "body", "form", "return"}: 76 | continue 77 | try: 78 | value = parse_obj_as(type_, kwargs.get(name)) 79 | validated[name] = value 80 | except ValidationError as e: 81 | err = e.errors()[0] 82 | err["loc"] = [name] 83 | errors.append(err) 84 | kwargs = {**kwargs, **validated} 85 | return kwargs, errors 86 | 87 | 88 | def get_body_dict(**params): 89 | data = request.get_json(**params) 90 | if data is None and params.get("silent"): 91 | return {} 92 | return data 93 | 94 | 95 | def validate( 96 | body: Optional[Type[BaseModel]] = None, 97 | query: Optional[Type[BaseModel]] = None, 98 | on_success_status: int = 200, 99 | exclude_none: bool = False, 100 | response_many: bool = False, 101 | request_body_many: bool = False, 102 | response_by_alias: bool = False, 103 | get_json_params: Optional[dict] = None, 104 | form: Optional[Type[BaseModel]] = None, 105 | ): 106 | """ 107 | Decorator for route methods which will validate query, body and form parameters 108 | as well as serialize the response (if it derives from pydantic's BaseModel 109 | class). 110 | 111 | Request parameters are accessible via flask's `request` variable: 112 | - request.query_params 113 | - request.body_params 114 | - request.form_params 115 | 116 | Or directly as `kwargs`, if you define them in the decorated function. 117 | 118 | `exclude_none` whether to remove None fields from response 119 | `response_many` whether content of response consists of many objects 120 | (e. g. List[BaseModel]). Resulting response will be an array of serialized 121 | models. 122 | `request_body_many` whether response body contains array of given model 123 | (request.body_params then contains list of models i. e. List[BaseModel]) 124 | `response_by_alias` whether Pydantic's alias is used 125 | `get_json_params` - parameters to be passed to Request.get_json() function 126 | 127 | example:: 128 | 129 | from flask import request 130 | from flask_pydantic import validate 131 | from pydantic import BaseModel 132 | 133 | class Query(BaseModel): 134 | query: str 135 | 136 | class Body(BaseModel): 137 | color: str 138 | 139 | class Form(BaseModel): 140 | name: str 141 | 142 | class MyModel(BaseModel): 143 | id: int 144 | color: str 145 | description: str 146 | 147 | ... 148 | 149 | @app.route("/") 150 | @validate(query=Query, body=Body, form=Form) 151 | def test_route(): 152 | query = request.query_params.query 153 | color = request.body_params.query 154 | 155 | return MyModel(...) 156 | 157 | @app.route("/kwargs") 158 | @validate() 159 | def test_route_kwargs(query:Query, body:Body, form:Form): 160 | 161 | return MyModel(...) 162 | 163 | -> that will render JSON response with serialized MyModel instance 164 | """ 165 | 166 | def decorate(func: Callable) -> Callable: 167 | @wraps(func) 168 | def wrapper(*args, **kwargs): 169 | q, b, f, err = None, None, None, {} 170 | kwargs, path_err = validate_path_params(func, kwargs) 171 | if path_err: 172 | err["path_params"] = path_err 173 | query_in_kwargs = func.__annotations__.get("query") 174 | query_model = query_in_kwargs or query 175 | if query_model: 176 | query_params = convert_query_params(request.args, query_model) 177 | try: 178 | q = query_model(**query_params) 179 | except ValidationError as ve: 180 | err["query_params"] = ve.errors() 181 | body_in_kwargs = func.__annotations__.get("body") 182 | body_model = body_in_kwargs or body 183 | if body_model: 184 | body_params = get_body_dict(**(get_json_params or {})) 185 | if "__root__" in body_model.__fields__: 186 | try: 187 | b = body_model(__root__=body_params).__root__ 188 | except ValidationError as ve: 189 | err["body_params"] = ve.errors() 190 | elif request_body_many: 191 | try: 192 | b = validate_many_models(body_model, body_params) 193 | except ManyModelValidationError as e: 194 | err["body_params"] = e.errors() 195 | else: 196 | try: 197 | b = body_model(**body_params) 198 | except TypeError: 199 | content_type = request.headers.get("Content-Type", "").lower() 200 | media_type = content_type.split(";")[0] 201 | if media_type != "application/json": 202 | return unsupported_media_type_response(content_type) 203 | else: 204 | raise JsonBodyParsingError() 205 | except ValidationError as ve: 206 | err["body_params"] = ve.errors() 207 | form_in_kwargs = func.__annotations__.get("form") 208 | form_model = form_in_kwargs or form 209 | if form_model: 210 | form_params = request.form 211 | if "__root__" in form_model.__fields__: 212 | try: 213 | f = form_model(__root__=form_params).__root__ 214 | except ValidationError as ve: 215 | err["form_params"] = ve.errors() 216 | else: 217 | try: 218 | f = form_model(**form_params) 219 | except TypeError: 220 | content_type = request.headers.get("Content-Type", "").lower() 221 | media_type = content_type.split(";")[0] 222 | if media_type != "multipart/form-data": 223 | return unsupported_media_type_response(content_type) 224 | else: 225 | raise JsonBodyParsingError 226 | except ValidationError as ve: 227 | err["form_params"] = ve.errors() 228 | request.query_params = q 229 | request.body_params = b 230 | request.form_params = f 231 | if query_in_kwargs: 232 | kwargs["query"] = q 233 | if body_in_kwargs: 234 | kwargs["body"] = b 235 | if form_in_kwargs: 236 | kwargs["form"] = f 237 | 238 | if err: 239 | if current_app.config.get( 240 | "FLASK_PYDANTIC_VALIDATION_ERROR_RAISE", False 241 | ): 242 | raise FailedValidation(**err) 243 | else: 244 | status_code = current_app.config.get( 245 | "FLASK_PYDANTIC_VALIDATION_ERROR_STATUS_CODE", 400 246 | ) 247 | return make_response( 248 | jsonify({"validation_error": err}), 249 | status_code 250 | ) 251 | res = func(*args, **kwargs) 252 | 253 | if response_many: 254 | if is_iterable_of_models(res): 255 | return make_json_response( 256 | res, 257 | on_success_status, 258 | by_alias=response_by_alias, 259 | exclude_none=exclude_none, 260 | many=True, 261 | ) 262 | else: 263 | raise InvalidIterableOfModelsException(res) 264 | 265 | if isinstance(res, BaseModel): 266 | return make_json_response( 267 | res, 268 | on_success_status, 269 | exclude_none=exclude_none, 270 | by_alias=response_by_alias, 271 | ) 272 | 273 | if ( 274 | isinstance(res, tuple) 275 | and len(res) in [2, 3] 276 | and isinstance(res[0], BaseModel) 277 | ): 278 | headers = None 279 | status = on_success_status 280 | if isinstance(res[1], (dict, tuple, list)): 281 | headers = res[1] 282 | elif len(res) == 3 and isinstance(res[2], (dict, tuple, list)): 283 | status = res[1] 284 | headers = res[2] 285 | else: 286 | status = res[1] 287 | 288 | ret = make_json_response( 289 | res[0], 290 | status, 291 | exclude_none=exclude_none, 292 | by_alias=response_by_alias, 293 | ) 294 | if headers: 295 | ret.headers.update(headers) 296 | return ret 297 | 298 | return res 299 | 300 | return wrapper 301 | 302 | return decorate 303 | -------------------------------------------------------------------------------- /flask_pydantic/exceptions.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | 4 | class BaseFlaskPydanticException(Exception): 5 | """Base exc class for all exception from this library""" 6 | 7 | pass 8 | 9 | 10 | class InvalidIterableOfModelsException(BaseFlaskPydanticException): 11 | """This exception is raised if there is a failure during serialization of 12 | response object with `response_many=True`""" 13 | 14 | pass 15 | 16 | 17 | class JsonBodyParsingError(BaseFlaskPydanticException): 18 | """Exception for error occurring during parsing of request body""" 19 | 20 | pass 21 | 22 | 23 | class ManyModelValidationError(BaseFlaskPydanticException): 24 | """This exception is raised if there is a failure during validation of many 25 | models in an iterable""" 26 | 27 | def __init__(self, errors: List[dict], *args): 28 | self._errors = errors 29 | super().__init__(*args) 30 | 31 | def errors(self): 32 | return self._errors 33 | 34 | 35 | class ValidationError(BaseFlaskPydanticException): 36 | """This exception is raised if there is a failure during validation if the 37 | user has configured an exception to be raised instead of a response""" 38 | 39 | def __init__( 40 | self, 41 | body_params: Optional[List[dict]] = None, 42 | form_params: Optional[List[dict]] = None, 43 | path_params: Optional[List[dict]] = None, 44 | query_params: Optional[List[dict]] = None, 45 | ): 46 | super().__init__() 47 | self.body_params = body_params 48 | self.form_params = form_params 49 | self.path_params = path_params 50 | self.query_params = query_params 51 | -------------------------------------------------------------------------------- /flask_pydantic/version.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.11.0" 2 | -------------------------------------------------------------------------------- /requirements/base.pip: -------------------------------------------------------------------------------- 1 | Flask 2 | pydantic>=1.7 3 | -------------------------------------------------------------------------------- /requirements/test.pip: -------------------------------------------------------------------------------- 1 | -r base.pip 2 | 3 | pytest 4 | pytest-flask 5 | pytest-flake8 6 | pytest-coverage 7 | pytest-black 8 | pytest-mock 9 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | testpaths = tests 3 | addopts = -vv --black --cov --cov-config=setup.cfg -s 4 | flake8-ignore = E121 E122 E123 E124 E125 E126 E127 E128 E711 E712 F811 F841 H803 E501 E265 E741 W391 W503 E203 5 | 6 | [coverage:run] 7 | branch = True 8 | omit = 9 | example/* 10 | include = 11 | flask_pydantic/* 12 | 13 | [coverage:report] 14 | show_missing = True 15 | skip_covered = True 16 | 17 | [flake8] 18 | ignore = E121 E122 E123 E124 E125 E126 E127 E128 E711 E712 F811 F841 H803 E501 E265 E741 W391 W503 E203 19 | exclude = 20 | .circleci, 21 | .github, 22 | venv -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | Flask-Pydantic 3 | ------------- 4 | 5 | This library provides port of Pydantic library to Flask. 6 | It allows quick and easy-to-use way of data parsing and validation using python type 7 | hints. 8 | """ 9 | import re 10 | from pathlib import Path 11 | from setuptools import setup 12 | from typing import Generator 13 | 14 | 15 | CURRENT_FOLDER = Path(__file__).resolve().parent 16 | REQUIREMENTS_PATH = CURRENT_FOLDER / "requirements" / "base.pip" 17 | VERSION_FILE_PATH = CURRENT_FOLDER / "flask_pydantic" / "version.py" 18 | VERSION_REGEX = r"^__version__ = [\"|\']([0-9\.a-z]+)[\"|\']" 19 | README = (CURRENT_FOLDER / "README.md").read_text() 20 | 21 | 22 | def get_install_requires( 23 | req_file: Path = REQUIREMENTS_PATH, 24 | ) -> Generator[str, None, None]: 25 | with req_file.open("r") as f: 26 | for line in f: 27 | if line.startswith("#"): 28 | continue 29 | yield line.strip() 30 | 31 | 32 | def find_version(file_path: Path = VERSION_FILE_PATH) -> str: 33 | file_content = file_path.open("r").read() 34 | version_match = re.search(VERSION_REGEX, file_content, re.M) 35 | 36 | if version_match: 37 | return version_match.group(1) 38 | raise RuntimeError(f"Unable to find version string in {file_path}") 39 | 40 | 41 | setup( 42 | name="Flask-Pydantic", 43 | version=find_version(), 44 | url="https://github.com/bauerji/flask_pydantic.git", 45 | license="MIT", 46 | author="Jiri Bauer", 47 | author_email="baueji@gmail.com", 48 | description="Flask extension for integration with Pydantic library", 49 | long_description=README, 50 | long_description_content_type="text/markdown", 51 | packages=["flask_pydantic"], 52 | install_requires=list(get_install_requires()), 53 | python_requires=">=3.6", 54 | classifiers=[ 55 | "Environment :: Web Environment", 56 | "Framework :: Flask", 57 | "Intended Audience :: Developers", 58 | "License :: OSI Approved :: MIT License", 59 | "Operating System :: OS Independent", 60 | "Programming Language :: Python", 61 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content", 62 | "Topic :: Software Development :: Libraries :: Python Modules", 63 | ], 64 | ) 65 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onlinehub0808/flask_pydantic/96c6e1679ed198d80a45cd8de33762e411b7b9ea/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional, Type 2 | 3 | import pytest 4 | from flask import Flask, request 5 | from flask_pydantic import validate 6 | from pydantic import BaseModel 7 | 8 | 9 | @pytest.fixture 10 | def posts() -> List[dict]: 11 | return [ 12 | {"title": "title 1", "text": "random text", "views": 1}, 13 | {"title": "2", "text": "another text", "views": 2}, 14 | {"title": "3", "text": "longer text than usual", "views": 4}, 15 | {"title": "title 13", "text": "nothing", "views": 5}, 16 | ] 17 | 18 | 19 | @pytest.fixture 20 | def query_model() -> Type[BaseModel]: 21 | class Query(BaseModel): 22 | limit: int = 2 23 | min_views: Optional[int] 24 | 25 | return Query 26 | 27 | 28 | @pytest.fixture 29 | def body_model() -> Type[BaseModel]: 30 | class Body(BaseModel): 31 | search_term: str 32 | exclude: Optional[str] 33 | 34 | return Body 35 | 36 | 37 | @pytest.fixture 38 | def form_model() -> Type[BaseModel]: 39 | class Form(BaseModel): 40 | search_term: str 41 | exclude: Optional[str] 42 | 43 | return Form 44 | 45 | 46 | @pytest.fixture 47 | def post_model() -> Type[BaseModel]: 48 | class Post(BaseModel): 49 | title: str 50 | text: str 51 | views: int 52 | 53 | return Post 54 | 55 | 56 | @pytest.fixture 57 | def response_model(post_model: BaseModel) -> Type[BaseModel]: 58 | class Response(BaseModel): 59 | results: List[post_model] 60 | count: int 61 | 62 | return Response 63 | 64 | 65 | def is_excluded(post: dict, exclude: Optional[str]) -> bool: 66 | if exclude is None: 67 | return False 68 | return exclude in post["title"] or exclude in post["text"] 69 | 70 | 71 | def pass_search( 72 | post: dict, search_term: str, exclude: Optional[str], min_views: Optional[int] 73 | ) -> bool: 74 | return ( 75 | (search_term in post["title"] or search_term in post["text"]) 76 | and not is_excluded(post, exclude) 77 | and (min_views is None or post["views"] >= min_views) 78 | ) 79 | 80 | 81 | @pytest.fixture 82 | def app(posts, response_model, query_model, body_model, post_model, form_model): 83 | app = Flask("test_app") 84 | app.config["DEBUG"] = True 85 | app.config["TESTING"] = True 86 | 87 | @app.route("/search", methods=["POST"]) 88 | @validate(query=query_model, body=body_model) 89 | def post(): 90 | query_params = request.query_params 91 | body = request.body_params 92 | results = [ 93 | post_model(**p) 94 | for p in posts 95 | if pass_search(p, body.search_term, body.exclude, query_params.min_views) 96 | ] 97 | return response_model(results=results[: query_params.limit], count=len(results)) 98 | 99 | @app.route("/search/kwargs", methods=["POST"]) 100 | @validate() 101 | def post_kwargs(query: query_model, body: body_model): 102 | results = [ 103 | post_model(**p) 104 | for p in posts 105 | if pass_search(p, body.search_term, body.exclude, query.min_views) 106 | ] 107 | return response_model(results=results[: query.limit], count=len(results)) 108 | 109 | @app.route("/search/form/kwargs", methods=["POST"]) 110 | @validate() 111 | def post_kwargs_form(query: query_model, form: form_model): 112 | results = [ 113 | post_model(**p) 114 | for p in posts 115 | if pass_search(p, form.search_term, form.exclude, query.min_views) 116 | ] 117 | return response_model(results=results[: query.limit], count=len(results)) 118 | 119 | return app 120 | -------------------------------------------------------------------------------- /tests/func/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onlinehub0808/flask_pydantic/96c6e1679ed198d80a45cd8de33762e411b7b9ea/tests/func/__init__.py -------------------------------------------------------------------------------- /tests/func/test_app.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | import pytest 4 | from flask import jsonify, request 5 | from flask_pydantic import validate, ValidationError 6 | from pydantic import BaseModel 7 | 8 | 9 | class ArrayModel(BaseModel): 10 | arr1: List[str] 11 | arr2: Optional[List[int]] 12 | 13 | 14 | @pytest.fixture 15 | def app_with_array_route(app): 16 | @app.route("/arr", methods=["GET"]) 17 | @validate(query=ArrayModel, exclude_none=True) 18 | def pass_array(): 19 | return ArrayModel( 20 | arr1=request.query_params.arr1, arr2=request.query_params.arr2 21 | ) 22 | 23 | 24 | @pytest.fixture 25 | def app_with_optional_body(app): 26 | class Body(BaseModel): 27 | param: str 28 | 29 | @app.route("/no_params", methods=["POST"]) 30 | @validate() 31 | def no_params(body: Body): 32 | return body 33 | 34 | @app.route("/silent", methods=["POST"]) 35 | @validate(get_json_params={"silent": True}) 36 | def silent(body: Body): 37 | return body 38 | 39 | 40 | @pytest.fixture 41 | def app_raise_on_validation_error(app): 42 | app.config["FLASK_PYDANTIC_VALIDATION_ERROR_RAISE"] = True 43 | 44 | def validation_error(error: ValidationError): 45 | return ( 46 | jsonify( 47 | { 48 | "title": "validation error", 49 | "body": error.body_params, 50 | } 51 | ), 52 | 422, 53 | ) 54 | 55 | app.register_error_handler(ValidationError, validation_error) 56 | 57 | class Body(BaseModel): 58 | param: str 59 | 60 | @app.route("/silent", methods=["POST"]) 61 | @validate(get_json_params={"silent": True}) 62 | def silent(body: Body): 63 | return body 64 | 65 | 66 | @pytest.fixture 67 | def app_with_int_path_param_route(app): 68 | class IdObj(BaseModel): 69 | id: int 70 | 71 | @app.route("/path_param//", methods=["GET"]) 72 | @validate() 73 | def int_path_param(obj_id: int): 74 | return IdObj(id=obj_id) 75 | 76 | 77 | @pytest.fixture 78 | def app_with_untyped_path_param_route(app): 79 | class IdObj(BaseModel): 80 | id: str 81 | 82 | @app.route("/path_param//", methods=["GET"]) 83 | @validate() 84 | def int_path_param(obj_id): 85 | return IdObj(id=obj_id) 86 | 87 | 88 | @pytest.fixture 89 | def app_with_custom_root_type(app): 90 | class Person(BaseModel): 91 | name: str 92 | age: Optional[int] 93 | 94 | class PersonBulk(BaseModel): 95 | __root__: List[Person] 96 | 97 | @app.route("/root_type", methods=["POST"]) 98 | @validate() 99 | def root_type(body: PersonBulk): 100 | return {"number": len(body)} 101 | 102 | 103 | @pytest.fixture 104 | def app_with_custom_headers(app): 105 | @app.route("/custom_headers", methods=["GET"]) 106 | @validate() 107 | def custom_headers(): 108 | return {"test": 1}, {"CUSTOM_HEADER": "UNIQUE"} 109 | 110 | 111 | @pytest.fixture 112 | def app_with_custom_headers_status(app): 113 | @app.route("/custom_headers_status", methods=["GET"]) 114 | @validate() 115 | def custom_headers(): 116 | return {"test": 1}, 201, {"CUSTOM_HEADER": "UNIQUE"} 117 | 118 | 119 | @pytest.fixture 120 | def app_with_camel_route(app): 121 | def to_camel(x: str) -> str: 122 | first, *rest = x.split("_") 123 | return "".join([first] + [x.capitalize() for x in rest]) 124 | 125 | class RequestModel(BaseModel): 126 | x: int 127 | y: int 128 | 129 | class ResultModel(BaseModel): 130 | result_of_addition: int 131 | result_of_multiplication: int 132 | 133 | class Config: 134 | alias_generator = to_camel 135 | allow_population_by_field_name = True 136 | 137 | @app.route("/compute", methods=["GET"]) 138 | @validate(response_by_alias=True) 139 | def compute(query: RequestModel): 140 | return ResultModel( 141 | result_of_addition=query.x + query.y, 142 | result_of_multiplication=query.x * query.y, 143 | ) 144 | 145 | 146 | test_cases = [ 147 | pytest.param( 148 | "?limit=limit", 149 | {"search_term": "text"}, 150 | 400, 151 | { 152 | "validation_error": { 153 | "query_params": [ 154 | { 155 | "loc": ["limit"], 156 | "msg": "value is not a valid integer", 157 | "type": "type_error.integer", 158 | } 159 | ] 160 | } 161 | }, 162 | id="invalid limit", 163 | ), 164 | pytest.param( 165 | "?limit=2", 166 | {}, 167 | 400, 168 | { 169 | "validation_error": { 170 | "body_params": [ 171 | { 172 | "loc": ["search_term"], 173 | "msg": "field required", 174 | "type": "value_error.missing", 175 | } 176 | ] 177 | } 178 | }, 179 | id="missing required body parameter", 180 | ), 181 | pytest.param( 182 | "?limit=1&min_views=2", 183 | {"search_term": "text"}, 184 | 200, 185 | {"count": 2, "results": [{"title": "2", "text": "another text", "views": 2}]}, 186 | id="valid parameters", 187 | ), 188 | pytest.param( 189 | "", 190 | {"search_term": "text"}, 191 | 200, 192 | { 193 | "count": 3, 194 | "results": [ 195 | {"title": "title 1", "text": "random text", "views": 1}, 196 | {"title": "2", "text": "another text", "views": 2}, 197 | ], 198 | }, 199 | id="valid params, no query", 200 | ), 201 | ] 202 | 203 | form_test_cases = [ 204 | pytest.param( 205 | "?limit=2", 206 | {}, 207 | 400, 208 | { 209 | "validation_error": { 210 | "form_params": [ 211 | { 212 | "loc": ["search_term"], 213 | "msg": "field required", 214 | "type": "value_error.missing", 215 | } 216 | ] 217 | } 218 | }, 219 | id="missing required form parameter", 220 | ), 221 | pytest.param( 222 | "?limit=1&min_views=2", 223 | {"search_term": "text"}, 224 | 200, 225 | {"count": 2, "results": [{"title": "2", "text": "another text", "views": 2}]}, 226 | id="valid parameters", 227 | ), 228 | pytest.param( 229 | "", 230 | {"search_term": "text"}, 231 | 200, 232 | { 233 | "count": 3, 234 | "results": [ 235 | {"title": "title 1", "text": "random text", "views": 1}, 236 | {"title": "2", "text": "another text", "views": 2}, 237 | ], 238 | }, 239 | id="valid params, no query", 240 | ), 241 | ] 242 | 243 | 244 | class TestSimple: 245 | @pytest.mark.parametrize("query,body,expected_status,expected_response", test_cases) 246 | def test_post(self, client, query, body, expected_status, expected_response): 247 | response = client.post(f"/search{query}", json=body) 248 | assert response.json == expected_response 249 | assert response.status_code == expected_status 250 | 251 | @pytest.mark.parametrize("query,body,expected_status,expected_response", test_cases) 252 | def test_post_kwargs(self, client, query, body, expected_status, expected_response): 253 | response = client.post(f"/search/kwargs{query}", json=body) 254 | assert response.json == expected_response 255 | assert response.status_code == expected_status 256 | 257 | @pytest.mark.parametrize( 258 | "query,form,expected_status,expected_response", form_test_cases 259 | ) 260 | def test_post_kwargs_form( 261 | self, client, query, form, expected_status, expected_response 262 | ): 263 | response = client.post( 264 | f"/search/form/kwargs{query}", 265 | data=form, 266 | ) 267 | assert response.json == expected_response 268 | assert response.status_code == expected_status 269 | 270 | def test_error_status_code(self, app, mocker, client): 271 | mocker.patch.dict( 272 | app.config, {"FLASK_PYDANTIC_VALIDATION_ERROR_STATUS_CODE": 422} 273 | ) 274 | response = client.post("/search?limit=2", json={}) 275 | assert response.status_code == 422 276 | 277 | 278 | @pytest.mark.usefixtures("app_with_custom_root_type") 279 | def test_custom_root_types(client): 280 | response = client.post( 281 | "/root_type", 282 | json=[{"name": "Joshua Bardwell", "age": 46}, {"name": "Andrew Cambden"}], 283 | ) 284 | assert response.json == {"number": 2} 285 | 286 | 287 | @pytest.mark.usefixtures("app_with_custom_headers") 288 | def test_custom_headers(client): 289 | response = client.get("/custom_headers") 290 | assert response.json == {"test": 1} 291 | assert response.status_code == 200 292 | assert response.headers.get("CUSTOM_HEADER") == "UNIQUE" 293 | 294 | 295 | @pytest.mark.usefixtures("app_with_custom_headers_status") 296 | def test_custom_headers(client): 297 | response = client.get("/custom_headers_status") 298 | assert response.json == {"test": 1} 299 | assert response.status_code == 201 300 | assert response.headers.get("CUSTOM_HEADER") == "UNIQUE" 301 | 302 | 303 | @pytest.mark.usefixtures("app_with_array_route") 304 | class TestArrayQueryParam: 305 | def test_no_param_raises(self, client): 306 | response = client.get("/arr") 307 | assert response.json == { 308 | "validation_error": { 309 | "query_params": [ 310 | { 311 | "loc": ["arr1"], 312 | "msg": "field required", 313 | "type": "value_error.missing", 314 | } 315 | ] 316 | } 317 | } 318 | 319 | def test_correctly_returns_first_arr(self, client): 320 | response = client.get("/arr?arr1=first&arr1=second") 321 | assert response.json == {"arr1": ["first", "second"]} 322 | 323 | def test_correctly_returns_first_arr_one_element(self, client): 324 | response = client.get("/arr?arr1=first") 325 | assert response.json == {"arr1": ["first"]} 326 | 327 | def test_correctly_returns_both_arrays(self, client): 328 | response = client.get("/arr?arr1=first&arr1=second&arr2=1&arr2=10") 329 | assert response.json == {"arr1": ["first", "second"], "arr2": [1, 10]} 330 | 331 | 332 | aliases_test_cases = [ 333 | pytest.param(1, 2, {"resultOfMultiplication": 2, "resultOfAddition": 3}), 334 | pytest.param(10, 20, {"resultOfMultiplication": 200, "resultOfAddition": 30}), 335 | pytest.param(999, 0, {"resultOfMultiplication": 0, "resultOfAddition": 999}), 336 | ] 337 | 338 | 339 | @pytest.mark.usefixtures("app_with_camel_route") 340 | @pytest.mark.parametrize("x,y,expected_result", aliases_test_cases) 341 | def test_aliases(x, y, expected_result, client): 342 | response = client.get(f"/compute?x={x}&y={y}") 343 | assert response.json == expected_result 344 | 345 | 346 | @pytest.mark.usefixtures("app_with_int_path_param_route") 347 | class TestPathIntParameter: 348 | def test_correct_param_passes(self, client): 349 | id_ = 12 350 | expected_response = {"id": id_} 351 | response = client.get(f"/path_param/{id_}/") 352 | assert response.json == expected_response 353 | 354 | def test_string_parameter(self, client): 355 | expected_response = { 356 | "validation_error": { 357 | "path_params": [ 358 | { 359 | "loc": ["obj_id"], 360 | "msg": "value is not a valid integer", 361 | "type": "type_error.integer", 362 | } 363 | ] 364 | } 365 | } 366 | response = client.get("/path_param/not_an_int/") 367 | 368 | assert response.json == expected_response 369 | assert response.status_code == 400 370 | 371 | 372 | @pytest.mark.usefixtures("app_with_untyped_path_param_route") 373 | class TestPathUnannotatedParameter: 374 | def test_int_str_param_passes(self, client): 375 | id_ = 12 376 | expected_response = {"id": str(id_)} 377 | response = client.get(f"/path_param/{id_}/") 378 | 379 | assert response.json == expected_response 380 | 381 | def test_str_param_passes(self, client): 382 | id_ = "twelve" 383 | expected_response = {"id": id_} 384 | response = client.get(f"/path_param/{id_}/") 385 | 386 | assert response.json == expected_response 387 | 388 | 389 | @pytest.mark.usefixtures("app_with_optional_body") 390 | class TestGetJsonParams: 391 | def test_empty_body_fails(self, client): 392 | response = client.post( 393 | "/no_params", headers={"Content-Type": "application/json"} 394 | ) 395 | 396 | assert response.status_code == 400 397 | assert ( 398 | "failed to decode json object: expecting value: line 1 column 1 (char 0)" 399 | in response.text.lower() 400 | ) 401 | 402 | def test_silent(self, client): 403 | response = client.post("/silent", headers={"Content-Type": "application/json"}) 404 | 405 | assert response.json == { 406 | "validation_error": { 407 | "body_params": [ 408 | { 409 | "loc": ["param"], 410 | "msg": "field required", 411 | "type": "value_error.missing", 412 | } 413 | ] 414 | } 415 | } 416 | assert response.status_code == 400 417 | 418 | 419 | @pytest.mark.usefixtures("app_raise_on_validation_error") 420 | class TestCustomResponse: 421 | def test_silent(self, client): 422 | response = client.post("/silent", headers={"Content-Type": "application/json"}) 423 | 424 | assert response.json["title"] == "validation error" 425 | assert response.json["body"] == [ 426 | { 427 | "loc": ["param"], 428 | "msg": "field required", 429 | "type": "value_error.missing", 430 | } 431 | ] 432 | assert response.status_code == 422 433 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/onlinehub0808/flask_pydantic/96c6e1679ed198d80a45cd8de33762e411b7b9ea/tests/unit/__init__.py -------------------------------------------------------------------------------- /tests/unit/test_core.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List, NamedTuple, Optional, Type, Union 2 | 3 | import pytest 4 | from flask import jsonify 5 | from flask_pydantic import validate, ValidationError 6 | from flask_pydantic.core import convert_query_params, is_iterable_of_models 7 | from flask_pydantic.exceptions import ( 8 | InvalidIterableOfModelsException, 9 | JsonBodyParsingError, 10 | ) 11 | from pydantic import BaseModel 12 | from werkzeug.datastructures import ImmutableMultiDict 13 | 14 | 15 | class ValidateParams(NamedTuple): 16 | body_model: Optional[Type[BaseModel]] = None 17 | query_model: Optional[Type[BaseModel]] = None 18 | form_model: Optional[Type[BaseModel]] = None 19 | response_model: Type[BaseModel] = None 20 | on_success_status: int = 200 21 | request_query: ImmutableMultiDict = ImmutableMultiDict({}) 22 | request_body: Union[dict, List[dict]] = {} 23 | request_form: ImmutableMultiDict = ImmutableMultiDict({}) 24 | expected_response_body: Optional[dict] = None 25 | expected_status_code: int = 200 26 | exclude_none: bool = False 27 | response_many: bool = False 28 | request_body_many: bool = False 29 | 30 | 31 | class ResponseModel(BaseModel): 32 | q1: int 33 | q2: str 34 | b1: float 35 | b2: Optional[str] 36 | 37 | 38 | class QueryModel(BaseModel): 39 | q1: int 40 | q2: str = "default" 41 | 42 | 43 | class RequestBodyModel(BaseModel): 44 | b1: float 45 | b2: Optional[str] = None 46 | 47 | 48 | class FormModel(BaseModel): 49 | f1: int 50 | f2: str = None 51 | 52 | 53 | class RequestBodyModelRoot(BaseModel): 54 | __root__: Union[str, RequestBodyModel] 55 | 56 | 57 | validate_test_cases = [ 58 | pytest.param( 59 | ValidateParams( 60 | request_body={"b1": 1.4}, 61 | request_query=ImmutableMultiDict({"q1": 1}), 62 | request_form=ImmutableMultiDict({"f1": 1}), 63 | form_model=FormModel, 64 | expected_response_body={"q1": 1, "q2": "default", "b1": 1.4, "b2": None}, 65 | response_model=ResponseModel, 66 | query_model=QueryModel, 67 | body_model=RequestBodyModel, 68 | ), 69 | id="simple valid example with default values", 70 | ), 71 | pytest.param( 72 | ValidateParams( 73 | request_body={"b1": 1.4}, 74 | request_query=ImmutableMultiDict({"q1": 1}), 75 | request_form=ImmutableMultiDict({"f1": 1}), 76 | form_model=FormModel, 77 | expected_response_body={"q1": 1, "q2": "default", "b1": 1.4}, 78 | response_model=ResponseModel, 79 | query_model=QueryModel, 80 | body_model=RequestBodyModel, 81 | exclude_none=True, 82 | ), 83 | id="simple valid example with default values, exclude none", 84 | ), 85 | pytest.param( 86 | ValidateParams( 87 | query_model=QueryModel, 88 | expected_response_body={ 89 | "validation_error": { 90 | "query_params": [ 91 | { 92 | "loc": ["q1"], 93 | "msg": "field required", 94 | "type": "value_error.missing", 95 | } 96 | ] 97 | } 98 | }, 99 | expected_status_code=400, 100 | ), 101 | id="invalid query param", 102 | ), 103 | pytest.param( 104 | ValidateParams( 105 | body_model=RequestBodyModel, 106 | expected_response_body={ 107 | "validation_error": { 108 | "body_params": [ 109 | { 110 | "loc": ["root"], 111 | "msg": "is not an array of objects", 112 | "type": "type_error.array", 113 | } 114 | ] 115 | } 116 | }, 117 | request_body={"b1": 3.14, "b2": "str"}, 118 | expected_status_code=400, 119 | request_body_many=True, 120 | ), 121 | id="`request_body_many=True` but in request body is a single object", 122 | ), 123 | pytest.param( 124 | ValidateParams( 125 | expected_response_body={ 126 | "validation_error": { 127 | "body_params": [ 128 | { 129 | "loc": ["b1"], 130 | "msg": "field required", 131 | "type": "value_error.missing", 132 | } 133 | ] 134 | } 135 | }, 136 | body_model=RequestBodyModel, 137 | expected_status_code=400, 138 | ), 139 | id="invalid body param", 140 | ), 141 | pytest.param( 142 | ValidateParams( 143 | expected_response_body={ 144 | "validation_error": { 145 | "body_params": [ 146 | { 147 | "loc": ["b1"], 148 | "msg": "field required", 149 | "type": "value_error.missing", 150 | } 151 | ] 152 | } 153 | }, 154 | body_model=RequestBodyModel, 155 | expected_status_code=400, 156 | request_body=[{}], 157 | request_body_many=True, 158 | ), 159 | id="invalid body param in many-object request body", 160 | ), 161 | pytest.param( 162 | ValidateParams( 163 | form_model=FormModel, 164 | expected_response_body={ 165 | "validation_error": { 166 | "form_params": [ 167 | { 168 | "loc": ["f1"], 169 | "msg": "field required", 170 | "type": "value_error.missing", 171 | } 172 | ] 173 | } 174 | }, 175 | expected_status_code=400, 176 | ), 177 | id="invalid form param", 178 | ), 179 | ] 180 | 181 | 182 | class TestValidate: 183 | @pytest.mark.parametrize("parameters", validate_test_cases) 184 | def test_validate(self, mocker, request_ctx, parameters: ValidateParams): 185 | mock_request = mocker.patch.object(request_ctx, "request") 186 | mock_request.args = parameters.request_query 187 | mock_request.get_json = lambda: parameters.request_body 188 | mock_request.form = parameters.request_form 189 | 190 | def f(): 191 | body = {} 192 | query = {} 193 | if mock_request.form_params: 194 | body = mock_request.form_params.dict() 195 | if mock_request.body_params: 196 | body = mock_request.body_params.dict() 197 | if mock_request.query_params: 198 | query = mock_request.query_params.dict() 199 | return parameters.response_model(**body, **query) 200 | 201 | response = validate( 202 | query=parameters.query_model, 203 | body=parameters.body_model, 204 | on_success_status=parameters.on_success_status, 205 | exclude_none=parameters.exclude_none, 206 | response_many=parameters.response_many, 207 | request_body_many=parameters.request_body_many, 208 | form=parameters.form_model, 209 | )(f)() 210 | 211 | assert response.status_code == parameters.expected_status_code 212 | assert response.json == parameters.expected_response_body 213 | if 200 <= response.status_code < 300: 214 | assert ( 215 | mock_request.body_params.dict(exclude_none=True, exclude_defaults=True) 216 | == parameters.request_body 217 | ) 218 | assert ( 219 | mock_request.query_params.dict(exclude_none=True, exclude_defaults=True) 220 | == parameters.request_query.to_dict() 221 | ) 222 | 223 | @pytest.mark.parametrize("parameters", validate_test_cases) 224 | def test_validate_kwargs(self, mocker, request_ctx, parameters: ValidateParams): 225 | mock_request = mocker.patch.object(request_ctx, "request") 226 | mock_request.args = parameters.request_query 227 | mock_request.get_json = lambda: parameters.request_body 228 | mock_request.form = parameters.request_form 229 | 230 | def f( 231 | body: parameters.body_model, 232 | query: parameters.query_model, 233 | form: parameters.form_model, 234 | ): 235 | return parameters.response_model( 236 | **body.dict(), **query.dict(), **form.dict() 237 | ) 238 | 239 | response = validate( 240 | on_success_status=parameters.on_success_status, 241 | exclude_none=parameters.exclude_none, 242 | response_many=parameters.response_many, 243 | request_body_many=parameters.request_body_many, 244 | )(f)() 245 | 246 | assert response.json == parameters.expected_response_body 247 | assert response.status_code == parameters.expected_status_code 248 | if 200 <= response.status_code < 300: 249 | assert ( 250 | mock_request.body_params.dict(exclude_none=True, exclude_defaults=True) 251 | == parameters.request_body 252 | ) 253 | assert ( 254 | mock_request.query_params.dict(exclude_none=True, exclude_defaults=True) 255 | == parameters.request_query.to_dict() 256 | ) 257 | 258 | @pytest.mark.usefixtures("request_ctx") 259 | def test_response_with_status(self): 260 | expected_status_code = 201 261 | expected_response_body = dict(q1=1, q2="2", b1=3.14, b2="b2") 262 | 263 | def f(): 264 | return ResponseModel(q1=1, q2="2", b1=3.14, b2="b2"), expected_status_code 265 | 266 | response = validate()(f)() 267 | assert response.status_code == expected_status_code 268 | assert response.json == expected_response_body 269 | 270 | @pytest.mark.usefixtures("request_ctx") 271 | def test_response_already_response(self): 272 | expected_response_body = {"a": 1, "b": 2} 273 | 274 | def f(): 275 | return jsonify(expected_response_body) 276 | 277 | response = validate()(f)() 278 | assert response.json == expected_response_body 279 | 280 | @pytest.mark.usefixtures("request_ctx") 281 | def test_response_many_response_objs(self): 282 | response_content = [ 283 | ResponseModel(q1=1, q2="2", b1=3.14, b2="b2"), 284 | ResponseModel(q1=2, q2="3", b1=3.14), 285 | ResponseModel(q1=3, q2="4", b1=6.9, b2="b4"), 286 | ] 287 | expected_response_body = [ 288 | {"q1": 1, "q2": "2", "b1": 3.14, "b2": "b2"}, 289 | {"q1": 2, "q2": "3", "b1": 3.14}, 290 | {"q1": 3, "q2": "4", "b1": 6.9, "b2": "b4"}, 291 | ] 292 | 293 | def f(): 294 | return response_content 295 | 296 | response = validate(exclude_none=True, response_many=True)(f)() 297 | assert response.json == expected_response_body 298 | 299 | @pytest.mark.usefixtures("request_ctx") 300 | def test_invalid_many_raises(self): 301 | def f(): 302 | return ResponseModel(q1=1, q2="2", b1=3.14, b2="b2") 303 | 304 | with pytest.raises(InvalidIterableOfModelsException): 305 | validate(response_many=True)(f)() 306 | 307 | def test_valid_array_object_request_body(self, mocker, request_ctx): 308 | mock_request = mocker.patch.object(request_ctx, "request") 309 | mock_request.args = ImmutableMultiDict({"q1": 1}) 310 | mock_request.get_json = lambda: [ 311 | {"b1": 1.0, "b2": "str1"}, 312 | {"b1": 2.0, "b2": "str2"}, 313 | ] 314 | expected_response_body = [ 315 | {"q1": 1, "q2": "default", "b1": 1.0, "b2": "str1"}, 316 | {"q1": 1, "q2": "default", "b1": 2.0, "b2": "str2"}, 317 | ] 318 | 319 | def f(): 320 | query_params = mock_request.query_params 321 | body_params = mock_request.body_params 322 | return [ 323 | ResponseModel( 324 | q1=query_params.q1, 325 | q2=query_params.q2, 326 | b1=obj.b1, 327 | b2=obj.b2, 328 | ) 329 | for obj in body_params 330 | ] 331 | 332 | response = validate( 333 | query=QueryModel, 334 | body=RequestBodyModel, 335 | request_body_many=True, 336 | response_many=True, 337 | )(f)() 338 | 339 | assert response.status_code == 200 340 | assert response.json == expected_response_body 341 | 342 | def test_unsupported_media_type(self, request_ctx, mocker): 343 | mock_request = mocker.patch.object(request_ctx, "request") 344 | content_type = "text/plain" 345 | mock_request.headers = {"Content-Type": content_type} 346 | mock_request.get_json = lambda: None 347 | body_model = RequestBodyModel 348 | response = validate(body_model)(lambda x: x)() 349 | assert response.status_code == 415 350 | assert response.json == { 351 | "detail": f"Unsupported media type '{content_type}' in request. " 352 | "'application/json' is required." 353 | } 354 | 355 | def test_invalid_body_model_root(self, request_ctx, mocker): 356 | mock_request = mocker.patch.object(request_ctx, "request") 357 | content_type = "application/json" 358 | mock_request.headers = {"Content-Type": content_type} 359 | mock_request.get_json = lambda: None 360 | body_model = RequestBodyModelRoot 361 | response = validate(body_model)(lambda x: x)() 362 | assert response.status_code == 400 363 | assert response.json == { 364 | "validation_error": { 365 | "body_params": [ 366 | { 367 | "loc": ["__root__"], 368 | "msg": "none is not an allowed value", 369 | "type": "type_error.none.not_allowed", 370 | } 371 | ] 372 | } 373 | } 374 | 375 | def test_damaged_request_body_json_with_charset(self, request_ctx, mocker): 376 | mock_request = mocker.patch.object(request_ctx, "request") 377 | content_type = "application/json;charset=utf-8" 378 | mock_request.headers = {"Content-Type": content_type} 379 | mock_request.get_json = lambda: None 380 | body_model = RequestBodyModel 381 | with pytest.raises(JsonBodyParsingError): 382 | validate(body_model)(lambda x: x)() 383 | 384 | def test_damaged_request_body(self, request_ctx, mocker): 385 | mock_request = mocker.patch.object(request_ctx, "request") 386 | content_type = "application/json" 387 | mock_request.headers = {"Content-Type": content_type} 388 | mock_request.get_json = lambda: None 389 | body_model = RequestBodyModel 390 | with pytest.raises(JsonBodyParsingError): 391 | validate(body_model)(lambda x: x)() 392 | 393 | @pytest.mark.parametrize("parameters", validate_test_cases) 394 | def test_validate_func_having_return_type_annotation( 395 | self, mocker, request_ctx, parameters: ValidateParams 396 | ): 397 | mock_request = mocker.patch.object(request_ctx, "request") 398 | mock_request.args = parameters.request_query 399 | mock_request.get_json = lambda: parameters.request_body 400 | mock_request.form = parameters.request_form 401 | 402 | def f() -> Any: 403 | body = {} 404 | query = {} 405 | if mock_request.form_params: 406 | body = mock_request.form_params.dict() 407 | if mock_request.body_params: 408 | body = mock_request.body_params.dict() 409 | if mock_request.query_params: 410 | query = mock_request.query_params.dict() 411 | return parameters.response_model(**body, **query) 412 | 413 | response = validate( 414 | query=parameters.query_model, 415 | body=parameters.body_model, 416 | form=parameters.form_model, 417 | on_success_status=parameters.on_success_status, 418 | exclude_none=parameters.exclude_none, 419 | response_many=parameters.response_many, 420 | request_body_many=parameters.request_body_many, 421 | )(f)() 422 | 423 | assert response.status_code == parameters.expected_status_code 424 | assert response.json == parameters.expected_response_body 425 | if 200 <= response.status_code < 300: 426 | assert ( 427 | mock_request.body_params.dict(exclude_none=True, exclude_defaults=True) 428 | == parameters.request_body 429 | ) 430 | assert ( 431 | mock_request.query_params.dict(exclude_none=True, exclude_defaults=True) 432 | == parameters.request_query.to_dict() 433 | ) 434 | 435 | def test_fail_validation_custom_status_code(self, app, request_ctx, mocker): 436 | app.config["FLASK_PYDANTIC_VALIDATION_ERROR_STATUS_CODE"] = 422 437 | mock_request = mocker.patch.object(request_ctx, "request") 438 | content_type = "application/json" 439 | mock_request.headers = {"Content-Type": content_type} 440 | mock_request.get_json = lambda: None 441 | body_model = RequestBodyModelRoot 442 | response = validate(body_model)(lambda x: x)() 443 | assert response.status_code == 422 444 | assert response.json == { 445 | "validation_error": { 446 | "body_params": [ 447 | { 448 | "loc": ["__root__"], 449 | "msg": "none is not an allowed value", 450 | "type": "type_error.none.not_allowed", 451 | } 452 | ] 453 | } 454 | } 455 | 456 | def test_body_fail_validation_raise_exception(self, app, request_ctx, mocker): 457 | app.config["FLASK_PYDANTIC_VALIDATION_ERROR_RAISE"] = True 458 | mock_request = mocker.patch.object(request_ctx, "request") 459 | content_type = "application/json" 460 | mock_request.headers = {"Content-Type": content_type} 461 | mock_request.get_json = lambda: None 462 | body_model = RequestBodyModelRoot 463 | with pytest.raises(ValidationError) as excinfo: 464 | validate(body_model)(lambda x: x)() 465 | assert excinfo.value.body_params == [ 466 | { 467 | "loc": ("__root__",), 468 | "msg": "none is not an allowed value", 469 | "type": "type_error.none.not_allowed", 470 | } 471 | ] 472 | 473 | def test_query_fail_validation_raise_exception(self, app, request_ctx, mocker): 474 | app.config["FLASK_PYDANTIC_VALIDATION_ERROR_RAISE"] = True 475 | mock_request = mocker.patch.object(request_ctx, "request") 476 | content_type = "application/json" 477 | mock_request.headers = {"Content-Type": content_type} 478 | mock_request.get_json = lambda: None 479 | query_model = QueryModel 480 | with pytest.raises(ValidationError) as excinfo: 481 | validate(query=query_model)(lambda x: x)() 482 | assert excinfo.value.query_params == [ 483 | { 484 | "loc": ("q1",), 485 | "msg": "field required", 486 | "type": "value_error.missing", 487 | } 488 | ] 489 | 490 | def test_form_fail_validation_raise_exception(self, app, request_ctx, mocker): 491 | app.config["FLASK_PYDANTIC_VALIDATION_ERROR_RAISE"] = True 492 | mock_request = mocker.patch.object(request_ctx, "request") 493 | content_type = "application/json" 494 | mock_request.headers = {"Content-Type": content_type} 495 | mock_request.get_json = lambda: None 496 | form_model = FormModel 497 | with pytest.raises(ValidationError) as excinfo: 498 | validate(form=form_model)(lambda x: x)() 499 | assert excinfo.value.form_params == [ 500 | { 501 | "loc": ("f1",), 502 | "msg": "field required", 503 | "type": "value_error.missing", 504 | } 505 | ] 506 | 507 | 508 | class TestIsIterableOfModels: 509 | def test_simple_true_case(self): 510 | models = [ 511 | QueryModel(q1=1, q2="w"), 512 | QueryModel(q1=2, q2="wsdf"), 513 | RequestBodyModel(b1=3.1), 514 | RequestBodyModel(b1=0.1), 515 | ] 516 | assert is_iterable_of_models(models) 517 | 518 | def test_false_for_non_iterable(self): 519 | assert not is_iterable_of_models(1) 520 | 521 | def test_false_for_single_model(self): 522 | assert not is_iterable_of_models(RequestBodyModel(b1=12)) 523 | 524 | 525 | convert_query_params_test_cases = [ 526 | pytest.param( 527 | ImmutableMultiDict({"a": 1, "b": "b"}), {"a": 1, "b": "b"}, id="primitive types" 528 | ), 529 | pytest.param( 530 | ImmutableMultiDict({"a": 1, "b": "b", "c": ["one"]}), 531 | {"a": 1, "b": "b", "c": ["one"]}, 532 | id="one element in array", 533 | ), 534 | pytest.param( 535 | ImmutableMultiDict({"a": 1, "b": "b", "c": ["one"], "d": [1]}), 536 | {"a": 1, "b": "b", "c": ["one"], "d": [1]}, 537 | id="one element in arrays", 538 | ), 539 | pytest.param( 540 | ImmutableMultiDict({"a": 1, "b": "b", "c": ["one"], "d": [1, 2, 3]}), 541 | {"a": 1, "b": "b", "c": ["one"], "d": [1, 2, 3]}, 542 | id="one element in array, multiple in the other", 543 | ), 544 | pytest.param( 545 | ImmutableMultiDict({"a": 1, "b": "b", "c": ["one", "two", "three"]}), 546 | {"a": 1, "b": "b", "c": ["one", "two", "three"]}, 547 | id="multiple elements in array", 548 | ), 549 | pytest.param( 550 | ImmutableMultiDict( 551 | {"a": 1, "b": "b", "c": ["one", "two", "three"], "d": [1, 2, 3]} 552 | ), 553 | {"a": 1, "b": "b", "c": ["one", "two", "three"], "d": [1, 2, 3]}, 554 | id="multiple in both arrays", 555 | ), 556 | ] 557 | 558 | 559 | @pytest.mark.parametrize( 560 | "query_params,expected_result", convert_query_params_test_cases 561 | ) 562 | def test_convert_query_params(query_params: ImmutableMultiDict, expected_result: dict): 563 | class Model(BaseModel): 564 | a: int 565 | b: str 566 | c: Optional[List[str]] 567 | d: Optional[List[int]] 568 | 569 | assert convert_query_params(query_params, Model) == expected_result 570 | --------------------------------------------------------------------------------