Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add kwargs parsing #13

Merged
merged 4 commits into from Sep 8, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Expand Up @@ -127,3 +127,6 @@ dmypy.json

# Pyre type checker
.pyre/

# VS Code settings
.vscode
165 changes: 103 additions & 62 deletions 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)
Expand All @@ -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).
Expand All @@ -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

Expand Down Expand Up @@ -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
...
Expand All @@ -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)
Expand All @@ -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 <your_branch_name>
```
- 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 <your_branch_name>
```
- 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
18 changes: 18 additions & 0 deletions example_app/app.py
Expand Up @@ -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():
Expand Down
67 changes: 42 additions & 25 deletions flask_pydantic/core.py
Expand Up @@ -85,39 +85,47 @@ 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
models.
`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
"""
Expand All @@ -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":
Expand All @@ -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
Expand Down
10 changes: 10 additions & 0 deletions tests/conftest.py
Expand Up @@ -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