Skip to content

Commit 47c6617

Browse files
authored
Merge pull request #40 from smt5541/json_schema
Add test suite, JSON validation and minor bug fixes
2 parents 5b41b6b + fe31fa6 commit 47c6617

34 files changed

+4193
-34
lines changed

.github/workflows/python-test.yml

+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# This workflow will install Python dependencies, run tests and lint with a single version of Python
2+
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python
3+
4+
name: Flask-Parameter-Validation Unit Tests
5+
6+
on:
7+
push:
8+
branches: [ "master", "github-ci" ]
9+
pull_request:
10+
branches: [ "master" ]
11+
12+
permissions:
13+
contents: read
14+
15+
jobs:
16+
build:
17+
18+
runs-on: ubuntu-latest
19+
20+
steps:
21+
- uses: actions/checkout@v3
22+
- name: Set up Python 3
23+
uses: actions/setup-python@v3
24+
with:
25+
python-version: "3.x"
26+
- name: Install dependencies
27+
run: |
28+
python -m pip install --upgrade pip
29+
pip install pytest
30+
cd flask_parameter_validation/test
31+
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
32+
- name: Test with Pytest
33+
run: |
34+
cd flask_parameter_validation
35+
pytest

README.md

+25
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ The validation on files are different to the others, but file input can still be
6666
* datetime.datetime
6767
* datetime.date
6868
* datetime.time
69+
* dict
6970

7071
### Validation
7172
All parameters can have default values, and automatic validation.
@@ -84,6 +85,7 @@ All parameters can have default values, and automatic validation.
8485
* datetime_format: str, datetime format string ([datetime format codes](https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes))
8586
* comment: str, A string to display as the argument description in generated documentation (if used)
8687
* alias: str, An expected parameter name instead of the function name. See `access_type` example for clarification.
88+
* json_schema: dict, An expected [JSON Schema](https://json-schema.org) which the dict input must conform to
8789

8890
`File` has the following options:
8991
* content_types: array of strings, an array of allowed content types.
@@ -221,6 +223,29 @@ This method returns an object with the following structure:
221223
]
222224
```
223225

226+
### JSON Schema Validation
227+
An example of the [JSON Schema](https://json-schema.org) validation is provided below:
228+
```python
229+
json_schema = {
230+
"type": "object",
231+
"required": ["user_id", "first_name", "last_name", "tags"],
232+
"properties": {
233+
"user_id": {"type": "integer"},
234+
"first_name": {"type": "string"},
235+
"last_name": {"type": "string"},
236+
"tags": {
237+
"type": "array",
238+
"items": {"type": "string"}
239+
}
240+
}
241+
}
242+
243+
@api.get("/json_schema_example")
244+
@ValidateParameters()
245+
def json_schema(data: dict = Json(json_schema=json_schema)):
246+
return jsonify({"data": data})
247+
```
248+
224249
## Contributions
225250
Many thanks to all those who have made contributions to the project:
226251
* [d3-steichman](https://github.com/d3-steichman): API documentation, custom error handling, datetime validation and bug fixes

flask_parameter_validation/parameter_types/file.py

+11-3
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33
- Would originally be in Flask's request.file
44
- Value will be a FileStorage object
55
"""
6+
import io
7+
8+
from werkzeug.datastructures import FileStorage
9+
610
from .parameter import Parameter
711

812

@@ -21,7 +25,7 @@ def __init__(
2125
self.min_length = min_length
2226
self.max_length = max_length
2327

24-
def validate(self, value):
28+
def validate(self, value: FileStorage):
2529
# Content type validation
2630
if self.content_types is not None:
2731
# We check mimetype, as it strips charset etc.
@@ -31,15 +35,19 @@ def validate(self, value):
3135

3236
# Min content length validation
3337
if self.min_length is not None:
34-
if value.content_length < self.min_length:
38+
origin = value.stream.tell()
39+
if value.stream.seek(0, io.SEEK_END) < self.min_length:
3540
raise ValueError(
3641
f"must have a content-length at least {self.min_length}."
3742
)
43+
value.stream.seek(origin)
3844

3945
# Max content length validation
4046
if self.max_length is not None:
41-
if value.content_length > self.max_length:
47+
origin = value.stream.tell()
48+
if value.stream.seek(0, io.SEEK_END) > self.max_length:
4249
raise ValueError(
4350
f"must have a content-length at most {self.max_length}."
4451
)
52+
value.stream.seek(origin)
4553
return True

flask_parameter_validation/parameter_types/parameter.py

+43-20
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
"""
55
import re
66
from datetime import date, datetime, time
7-
87
import dateutil.parser as parser
8+
import jsonschema
9+
from jsonschema.exceptions import ValidationError as JSONSchemaValidationError
910

1011

1112
class Parameter:
@@ -27,6 +28,7 @@ def __init__(
2728
datetime_format=None, # str: datetime format string (https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes),
2829
comment=None, # str: comment for autogenerated documentation
2930
alias=None, # str: alias for parameter name
31+
json_schema=None, # dict: JSON Schema to check received dicts or lists against
3032
):
3133
self.default = default
3234
self.min_list_length = min_list_length
@@ -42,9 +44,29 @@ def __init__(
4244
self.datetime_format = datetime_format
4345
self.comment = comment
4446
self.alias = alias
47+
self.json_schema = json_schema
48+
49+
def func_helper(self, v):
50+
func_result = self.func(v)
51+
if type(func_result) is bool:
52+
if not func_result:
53+
raise ValueError(
54+
"value does not match the validator function."
55+
)
56+
elif type(func_result) is tuple:
57+
if len(func_result) == 2 and type(func_result[0]) is bool and type(func_result[1]) is str:
58+
if not func_result[0]:
59+
raise ValueError(
60+
func_result[1]
61+
)
62+
else:
63+
raise ValueError(
64+
f"validator function returned incorrect type: {str(type(func_result))}, should return bool or (bool, str)"
65+
)
4566

4667
# Validator
4768
def validate(self, value):
69+
original_value_type_list = type(value) is list
4870
if type(value) is list:
4971
values = value
5072
# Min list len
@@ -59,14 +81,28 @@ def validate(self, value):
5981
raise ValueError(
6082
f"must have have a maximum of {self.max_list_length} items."
6183
)
84+
if self.func is not None:
85+
self.func_helper(value)
86+
if self.json_schema is not None:
87+
try:
88+
jsonschema.validate(value, self.json_schema)
89+
except JSONSchemaValidationError as e:
90+
raise ValueError(f"failed JSON Schema validation: {e.args[0]}")
91+
elif type(value) is dict:
92+
if self.json_schema is not None:
93+
try:
94+
jsonschema.validate(value, self.json_schema)
95+
except JSONSchemaValidationError as e:
96+
raise ValueError(f"failed JSON Schema validation: {e.args[0]}")
97+
values = [value]
6298
else:
6399
values = [value]
64100

65101
# Iterate through values given (or just one, if not list)
66102
for value in values:
67103
# Min length
68104
if self.min_str_length is not None:
69-
if hasattr(value, "len") and len(value) < self.min_str_length:
105+
if len(value) < self.min_str_length:
70106
raise ValueError(
71107
f"must have at least {self.min_str_length} characters."
72108
)
@@ -110,24 +146,11 @@ def validate(self, value):
110146
f"pattern does not match: {self.pattern}."
111147
)
112148

113-
# Callable
114-
if self.func is not None:
115-
func_result = self.func(value)
116-
if type(func_result) is bool:
117-
if not func_result:
118-
raise ValueError(
119-
"value does not match the validator function."
120-
)
121-
elif type(func_result) is tuple:
122-
if len(func_result) == 2 and type(func_result[0]) is bool and type(func_result[1]) is str:
123-
if not func_result[0]:
124-
raise ValueError(
125-
func_result[1]
126-
)
127-
else:
128-
raise ValueError(
129-
f"validator function returned incorrect type: {str(type(func_result))}, should return bool or (bool, str)"
130-
)
149+
# Callable (non-list)
150+
if self.func is not None and not original_value_type_list:
151+
self.func_helper(value)
152+
153+
131154

132155
return True
133156

flask_parameter_validation/parameter_types/query.py

+14-5
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
Query Parameters
33
- i.e. sent in GET requests, /?username=myname
44
"""
5+
import json
6+
57
from .parameter import Parameter
68

79

@@ -28,9 +30,16 @@ def convert(self, value, allowed_types):
2830
pass
2931
# bool conversion
3032
if bool in allowed_types:
31-
if value.lower() == "true":
32-
value = True
33-
elif value.lower() == "false":
34-
value = False
35-
33+
try:
34+
if value.lower() == "true":
35+
value = True
36+
elif value.lower() == "false":
37+
value = False
38+
except AttributeError:
39+
pass
40+
if dict in allowed_types:
41+
try:
42+
value = json.loads(value)
43+
except ValueError:
44+
raise ValueError(f"invalid JSON")
3645
return super().convert(value, allowed_types)

flask_parameter_validation/parameter_types/route.py

+27
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,30 @@ class Route(Parameter):
1010

1111
def __init__(self, default=None, **kwargs):
1212
super().__init__(default, **kwargs)
13+
14+
def convert(self, value, allowed_types):
15+
"""Convert query parameters to corresponding types."""
16+
if type(value) is str:
17+
# int conversion
18+
if int in allowed_types:
19+
try:
20+
value = int(value)
21+
except ValueError:
22+
pass
23+
# float conversion
24+
if float in allowed_types:
25+
try:
26+
value = float(value)
27+
except ValueError:
28+
pass
29+
# bool conversion
30+
if bool in allowed_types:
31+
try:
32+
if value.lower() == "true":
33+
value = True
34+
elif value.lower() == "false":
35+
value = False
36+
except AttributeError:
37+
pass
38+
39+
return super().convert(value, allowed_types)

flask_parameter_validation/parameter_validation.py

+8-6
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,9 @@
33
import inspect
44
import re
55
from inspect import signature
6-
76
from flask import request
87
from werkzeug.datastructures import ImmutableMultiDict
98
from werkzeug.exceptions import BadRequest
10-
119
from .exceptions import (InvalidParameterTypeError, MissingInputError,
1210
ValidationError)
1311
from .parameter_types import File, Form, Json, Query, Route
@@ -46,7 +44,7 @@ def __call__(self, f):
4644
async def nested_func(**kwargs):
4745
# Step 1 - Get expected input details as dict
4846
expected_inputs = signature(f).parameters
49-
47+
5048
# Step 2 - Validate JSON inputs
5149
json_input = None
5250
if request.headers.get("Content-Type") is not None:
@@ -61,7 +59,8 @@ async def nested_func(**kwargs):
6159
# Step 3 - Extract list of parameters expected to be lists (otherwise all values are converted to lists)
6260
expected_list_params = []
6361
for name, param in expected_inputs.items():
64-
if str(param.annotation).startswith("typing.List") or str(param.annotation).startswith("typing.Optional[typing.List"):
62+
if str(param.annotation).startswith("typing.List") or str(param.annotation).startswith(
63+
"typing.Optional[typing.List"):
6564
expected_list_params.append(param.default.alias or name)
6665

6766
# Step 4 - Convert request inputs to dicts
@@ -152,7 +151,7 @@ def validate(self, expected_input, all_request_inputs):
152151
else:
153152
# Optionals are Unions with a NoneType, so we should check if None is part of Union __args__ (if exist)
154153
if (
155-
hasattr(expected_input_type, "__args__") and type(None) in expected_input_type.__args__
154+
hasattr(expected_input_type, "__args__") and type(None) in expected_input_type.__args__
156155
):
157156
return user_input
158157
else:
@@ -231,7 +230,10 @@ def validate(self, expected_input, all_request_inputs):
231230

232231
# Validate parameter-specific requirements are met
233232
try:
234-
expected_delivery_type.validate(user_input)
233+
if type(user_input) is list:
234+
expected_delivery_type.validate(user_input)
235+
else:
236+
expected_delivery_type.validate(user_inputs[0])
235237
except ValueError as e:
236238
raise ValidationError(str(e), expected_name, expected_input_type)
237239

flask_parameter_validation/test/__init__.py

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import pytest
2+
from .testing_application import create_app
3+
4+
5+
@pytest.fixture(scope="session")
6+
def app():
7+
app = create_app()
8+
app.config.update({"TESTING": True})
9+
yield app
10+
11+
12+
@pytest.fixture()
13+
def client(app):
14+
return app.test_client()
15+
16+
17+
@pytest.fixture()
18+
def runner(app):
19+
return app.test_cli_runner()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Flask==3.0.2
2+
../../
3+
requests
Loading
Loading
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"name": "Test JSON Document",
3+
"description": "This document will be uploaded to an API route that expects image/jpeg or image/png Content-Type, with the expectation that the API will return an error."
4+
}

0 commit comments

Comments
 (0)