diff --git a/.gitignore b/.gitignore index b6e4761..a02ea7a 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,6 @@ dmypy.json # Pyre type checker .pyre/ + +# VS Code settings +.vscode diff --git a/README.md b/README.md index 063dbe0..646850b 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # Flask-Pydantic + [![Actions Status](https://github.com/bauerji/flask_pydantic/workflows/Python%20package/badge.svg?branch=master)](https://github.com/bauerji/flask_pydantic/actions) [![PyPI](https://img.shields.io/pypi/v/Flask-Pydantic?color=g)](https://pypi.org/project/Flask-Pydantic/) [![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) @@ -8,15 +9,23 @@ Flask extension for integration of the awesome [pydantic package](https://github.com/samuelcolvin/pydantic) with [Flask](https://palletsprojects.com/p/flask/). ## Installation -``python3 -m pip install Flask-Pydantic`` + +`python3 -m pip install Flask-Pydantic` ## Basics -`validate` decorator validates query and body request parameters and makes them accessible via flask's `request` variable + +`validate` decorator validates query and body request parameters and makes them accessible two ways: + +1. [Using `validate` arguments, via flask's `request` variable](#basic-example) | **parameter type** | **`request` attribute name** | -|:--------------:|:------------------------:| -| query | `query_params` | -| body | `body_params` | +| :----------------: | :--------------------------: | +| query | `query_params` | +| body | `body_params` | + +2. [Using the decorated function argument parameters type hints](#using-the-decorated-function-kwargs) + +--- - Success response status code can be modified via `on_success_status` parameter of `validate` decorator. - `response_many` parameter set to `True` enables serialization of multiple models (route function should therefore return iterable of models). @@ -26,10 +35,13 @@ Flask extension for integration of the awesome [pydantic package](https://github For more details see in-code docstring or example app. ## Usage + ### Basic example -Simply use `validate` decorator on route function. + +Simply use `validate` decorator on route function. :exclamation: Be aware that `@app.route` decorator must precede `@validate` (i. e. `@validate` must be closer to the function declaration). + ```python from typing import Optional @@ -69,48 +81,48 @@ def post(): nickname=request.body_params.nickname, ) ``` + - `age` query parameter is a required `int` - - if none is provided the response contains: - ```json - { - "validation_error": { - "query_params": [ - { - "loc": [ - "age" - ], - "msg": "field required", - "type": "value_error.missing" - } - ] + - if none is provided the response contains: + ```json + { + "validation_error": { + "query_params": [ + { + "loc": ["age"], + "msg": "field required", + "type": "value_error.missing" } - } - ``` - - for incompatible type (e. g. string `/?age=not_a_number`) - ```json - { - "validation_error": { - "query_params": [ - { - "loc": [ - "age" - ], - "msg": "value is not a valid integer", - "type": "type_error.integer" - } - ] + ] + } + } + ``` + - for incompatible type (e. g. string `/?age=not_a_number`) + ```json + { + "validation_error": { + "query_params": [ + { + "loc": ["age"], + "msg": "value is not a valid integer", + "type": "type_error.integer" } - } - ``` + ] + } + } + ``` - likewise for body parameters - example call with valid parameters: -``curl -XPOST http://localhost:5000/?age=20 --data '{"name": "John Doe"}' -H 'Content-Type: application/json'`` + `curl -XPOST http://localhost:5000/?age=20 --data '{"name": "John Doe"}' -H 'Content-Type: application/json'` --> ``{"id": 2, "age": 20, "name": "John Doe", "nickname": null}`` +-> `{"id": 2, "age": 20, "name": "John Doe", "nickname": null}` ### Modify response status code + The default success status code is `200`. It can be modified in two ways + - in return statement + ```python # necessary imports, app and models definition ... @@ -125,7 +137,9 @@ def post(): nickname=request.body_params.nickname, ), 201 ``` + - in `validate` decorator + ```python @app.route("/", methods=["POST"]) @validate(body=BodyModel, query=QueryModel, on_success_status=201) @@ -135,41 +149,68 @@ def post(): Status code in case of validation error can be modified using `FLASK_PYDANTIC_VALIDATION_ERROR_STATUS_CODE` flask configuration variable. +### Using the decorated function `kwargs` + +Instead of passing `body` and `query` to `validate`, it is possible to directly +defined them by using type hinting in the decorated function. + +```python +# necessary imports, app and models definition +... + +@app.route("/", methods=["POST"]) +@validate() +def post(body: BodyModel, query: QueryModel): + return ResponseModel( + id=id_, + age=query.age, + name=body.name, + nickname=body.nickname, + ) +``` + +This way, the parsed data will be directly available in `body` and `query`. +Furthermore, your IDE will be able to correctly type them. + ### Example app + For more complete examples see [example application](https://github.com/bauerji/flask_pydantic/tree/master/example_app). ### Configuration + The behaviour can be configured using flask's application config -``FLASK_PYDANTIC_VALIDATION_ERROR_STATUS_CODE`` - response status code after validation error (defaults to `400`) +`FLASK_PYDANTIC_VALIDATION_ERROR_STATUS_CODE` - response status code after validation error (defaults to `400`) ## Contributing + Feature requests and pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. - clone repository - ```bash - git clone https://github.com/bauerji/flask_pydantic.git - cd flask_pydantic - ``` - - create virtual environment and activate it - ```bash - python3 -m venv venv - source venv/bin/activate - ``` - - install development requirements - ```bash - python3 -m pip install -r requirements/test.pip - ``` - - checkout new branch and make your desired changes (don't forget to update tests) - ```bash - git checkout -b - ``` - - run tests - ```bash - python3 -m pytest - ``` - - if tests fails on Black tests, make sure You have your code compliant with style of [Black formatter](https://github.com/psf/black) - - push your changes and create a pull request to master branch + ```bash + git clone https://github.com/bauerji/flask_pydantic.git + cd flask_pydantic + ``` +- create virtual environment and activate it + ```bash + python3 -m venv venv + source venv/bin/activate + ``` +- install development requirements + ```bash + python3 -m pip install -r requirements/test.pip + ``` +- checkout new branch and make your desired changes (don't forget to update tests) + ```bash + git checkout -b + ``` +- run tests + ```bash + python3 -m pytest + ``` +- if tests fails on Black tests, make sure You have your code compliant with style of [Black formatter](https://github.com/psf/black) +- push your changes and create a pull request to master branch ## TODOs: + - header request parameters - cookie request parameters diff --git a/example_app/app.py b/example_app/app.py index c2ea537..954b1d9 100644 --- a/example_app/app.py +++ b/example_app/app.py @@ -53,6 +53,24 @@ def post(): ) +@app.route("/kwargs", methods=["POST"]) +@validate() +def post(body: BodyModel, query: QueryModel): + """ + Basic example with both query and body parameters, response object serialization. + This time using the decorated function kwargs `body` and `query` type hinting + """ + # save model to DB + id_ = 3 + + return ResponseModel( + id=id_, + age=query.age, + name=body.name, + nickname=body.nickname, + ) + + @app.route("/many", methods=["GET"]) @validate(response_many=True) def get_many(): diff --git a/flask_pydantic/core.py b/flask_pydantic/core.py index 07293b5..e108c18 100644 --- a/flask_pydantic/core.py +++ b/flask_pydantic/core.py @@ -85,6 +85,8 @@ def validate( - request.query_params - request.body_params + Or directly as `kwargs`, if you define them in the decorated function. + `exclude_none` whether to remove None fields from response `response_many` whether content of response consists of many objects (e. g. List[BaseModel]). Resulting response will be an array of serialized @@ -92,32 +94,38 @@ def validate( `request_body_many` whether response body contains array of given model (request.body_params then contains list of models i. e. List[BaseModel]) - example: + example:: + + from flask import request + from flask_pydantic import validate + from pydantic import BaseModel - from flask import request - from flask_pydantic import validate - from pydantic import BaseModel + class Query(BaseModel): + query: str - class Query(BaseModel): - query: str + class Body(BaseModel): + color: str - class Body(BaseModel): - color: str + class MyModel(BaseModel): + id: int + color: str + description: str - class MyModel(BaseModel): - id: int - color: str - description: str + ... - ... + @app.route("/") + @validate(query=Query, body=Body) + def test_route(): + query = request.query_params.query + color = request.body_params.query - @app.route("/") - @validate(query=Query, body=Body) - def test_route(): - query = request.query_params.query - color = request.body_params.query + return MyModel(...) - return MyModel(...) + @app.route("/kwargs") + @validate() + def test_route_kwargs(query:Query, body:Body): + + return MyModel(...) -> that will render JSON response with serialized MyModel instance """ @@ -126,22 +134,26 @@ def decorate(func: Callable[[InputParams], Any]) -> Callable[[InputParams], Any] @wraps(func) def wrapper(*args, **kwargs): q, b, err = None, None, {} - if query: - query_params = convert_query_params(request.args, query) + query_in_kwargs = func.__annotations__.get("query") + query_model = query_in_kwargs or query + if query_model: + query_params = convert_query_params(request.args, query_model) try: - q = query(**query_params) + q = query_model(**query_params) except ValidationError as ve: err["query_params"] = ve.errors() - if body: + body_in_kwargs = func.__annotations__.get("body") + body_model = body_in_kwargs or body + if body_model: body_params = request.get_json() if request_body_many: try: - b = validate_many_models(body, body_params) + b = validate_many_models(body_model, body_params) except ManyModelValidationError as e: err["body_params"] = e.errors() else: try: - b = body(**body_params) + b = body_model(**body_params) except TypeError: content_type = request.headers.get("Content-Type", "").lower() if content_type != "application/json": @@ -152,6 +164,11 @@ def wrapper(*args, **kwargs): err["body_params"] = ve.errors() request.query_params = q request.body_params = b + if query_in_kwargs: + kwargs["query"] = q + if body_in_kwargs: + kwargs["body"] = b + if err: status_code = current_app.config.get( "FLASK_PYDANTIC_VALIDATION_ERROR_STATUS_CODE", 400 diff --git a/tests/conftest.py b/tests/conftest.py index 1ce3997..0a7cbf3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -85,4 +85,14 @@ def post(): ] return response_model(results=results[: query_params.limit], count=len(results)) + @app.route("/search/kwargs", methods=["POST"]) + @validate() + def post_kwargs(query: query_model, body: body_model): + results = [ + post_model(**p) + for p in posts + if pass_search(p, body.search_term, body.exclude, query.min_views) + ] + return response_model(results=results[: query.limit], count=len(results)) + return app diff --git a/tests/func/test_app.py b/tests/func/test_app.py index 3ab52ce..78b5bc2 100644 --- a/tests/func/test_app.py +++ b/tests/func/test_app.py @@ -87,6 +87,12 @@ def test_post(self, client, query, body, expected_status, expected_response): assert response.json == expected_response assert response.status_code == expected_status + @pytest.mark.parametrize("query,body,expected_status,expected_response", test_cases) + def test_post_kwargs(self, client, query, body, expected_status, expected_response): + response = client.post(f"/search/kwargs{query}", json=body) + assert response.json == expected_response + assert response.status_code == expected_status + def test_error_status_code(self, app, mocker, client): mocker.patch.dict( app.config, {"FLASK_PYDANTIC_VALIDATION_ERROR_STATUS_CODE": 422} diff --git a/tests/unit/test_core.py b/tests/unit/test_core.py index ed77e2e..7252847 100644 --- a/tests/unit/test_core.py +++ b/tests/unit/test_core.py @@ -179,6 +179,33 @@ def f(): exclude_none=True, exclude_defaults=True ) == parameters.request_query.to_dict(flat=True) + @pytest.mark.parametrize("parameters", validate_test_cases) + def test_validate_kwargs(self, mocker, request_ctx, parameters: ValidateParams): + mock_request = mocker.patch.object(request_ctx, "request") + mock_request.args = parameters.request_query + mock_request.get_json = lambda: parameters.request_body + + def f(body: parameters.body_model, query: parameters.query_model): + return parameters.response_model(**body.dict(), **query.dict()) + + response = validate( + on_success_status=parameters.on_success_status, + exclude_none=parameters.exclude_none, + response_many=parameters.response_many, + request_body_many=parameters.request_body_many, + )(f)() + + assert response.status_code == parameters.expected_status_code + assert response.json == parameters.expected_response_body + if 200 <= response.status_code < 300: + assert ( + mock_request.body_params.dict(exclude_none=True, exclude_defaults=True) + == parameters.request_body + ) + assert mock_request.query_params.dict( + exclude_none=True, exclude_defaults=True + ) == parameters.request_query.to_dict(flat=True) + @pytest.mark.usefixtures("request_ctx") def test_response_with_status(self): expected_status_code = 201