Skip to content

Commit

Permalink
Merge pull request #40 from smt5541/json_schema
Browse files Browse the repository at this point in the history
Add test suite, JSON validation and minor bug fixes
  • Loading branch information
Ge0rg3 authored Feb 26, 2024
2 parents 5b41b6b + fe31fa6 commit 47c6617
Show file tree
Hide file tree
Showing 34 changed files with 4,193 additions and 34 deletions.
35 changes: 35 additions & 0 deletions .github/workflows/python-test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# This workflow will install Python dependencies, run tests and lint with a single version of Python
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python

name: Flask-Parameter-Validation Unit Tests

on:
push:
branches: [ "master", "github-ci" ]
pull_request:
branches: [ "master" ]

permissions:
contents: read

jobs:
build:

runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v3
- name: Set up Python 3
uses: actions/setup-python@v3
with:
python-version: "3.x"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pytest
cd flask_parameter_validation/test
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
- name: Test with Pytest
run: |
cd flask_parameter_validation
pytest
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ The validation on files are different to the others, but file input can still be
* datetime.datetime
* datetime.date
* datetime.time
* dict

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

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

### JSON Schema Validation
An example of the [JSON Schema](https://json-schema.org) validation is provided below:
```python
json_schema = {
"type": "object",
"required": ["user_id", "first_name", "last_name", "tags"],
"properties": {
"user_id": {"type": "integer"},
"first_name": {"type": "string"},
"last_name": {"type": "string"},
"tags": {
"type": "array",
"items": {"type": "string"}
}
}
}

@api.get("/json_schema_example")
@ValidateParameters()
def json_schema(data: dict = Json(json_schema=json_schema)):
return jsonify({"data": data})
```

## Contributions
Many thanks to all those who have made contributions to the project:
* [d3-steichman](https://github.com/d3-steichman): API documentation, custom error handling, datetime validation and bug fixes
Expand Down
14 changes: 11 additions & 3 deletions flask_parameter_validation/parameter_types/file.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
- Would originally be in Flask's request.file
- Value will be a FileStorage object
"""
import io

from werkzeug.datastructures import FileStorage

from .parameter import Parameter


Expand All @@ -21,7 +25,7 @@ def __init__(
self.min_length = min_length
self.max_length = max_length

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

# Min content length validation
if self.min_length is not None:
if value.content_length < self.min_length:
origin = value.stream.tell()
if value.stream.seek(0, io.SEEK_END) < self.min_length:
raise ValueError(
f"must have a content-length at least {self.min_length}."
)
value.stream.seek(origin)

# Max content length validation
if self.max_length is not None:
if value.content_length > self.max_length:
origin = value.stream.tell()
if value.stream.seek(0, io.SEEK_END) > self.max_length:
raise ValueError(
f"must have a content-length at most {self.max_length}."
)
value.stream.seek(origin)
return True
63 changes: 43 additions & 20 deletions flask_parameter_validation/parameter_types/parameter.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
"""
import re
from datetime import date, datetime, time

import dateutil.parser as parser
import jsonschema
from jsonschema.exceptions import ValidationError as JSONSchemaValidationError


class Parameter:
Expand All @@ -27,6 +28,7 @@ def __init__(
datetime_format=None, # str: datetime format string (https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes),
comment=None, # str: comment for autogenerated documentation
alias=None, # str: alias for parameter name
json_schema=None, # dict: JSON Schema to check received dicts or lists against
):
self.default = default
self.min_list_length = min_list_length
Expand All @@ -42,9 +44,29 @@ def __init__(
self.datetime_format = datetime_format
self.comment = comment
self.alias = alias
self.json_schema = json_schema

def func_helper(self, v):
func_result = self.func(v)
if type(func_result) is bool:
if not func_result:
raise ValueError(
"value does not match the validator function."
)
elif type(func_result) is tuple:
if len(func_result) == 2 and type(func_result[0]) is bool and type(func_result[1]) is str:
if not func_result[0]:
raise ValueError(
func_result[1]
)
else:
raise ValueError(
f"validator function returned incorrect type: {str(type(func_result))}, should return bool or (bool, str)"
)

# Validator
def validate(self, value):
original_value_type_list = type(value) is list
if type(value) is list:
values = value
# Min list len
Expand All @@ -59,14 +81,28 @@ def validate(self, value):
raise ValueError(
f"must have have a maximum of {self.max_list_length} items."
)
if self.func is not None:
self.func_helper(value)
if self.json_schema is not None:
try:
jsonschema.validate(value, self.json_schema)
except JSONSchemaValidationError as e:
raise ValueError(f"failed JSON Schema validation: {e.args[0]}")
elif type(value) is dict:
if self.json_schema is not None:
try:
jsonschema.validate(value, self.json_schema)
except JSONSchemaValidationError as e:
raise ValueError(f"failed JSON Schema validation: {e.args[0]}")
values = [value]
else:
values = [value]

# Iterate through values given (or just one, if not list)
for value in values:
# Min length
if self.min_str_length is not None:
if hasattr(value, "len") and len(value) < self.min_str_length:
if len(value) < self.min_str_length:
raise ValueError(
f"must have at least {self.min_str_length} characters."
)
Expand Down Expand Up @@ -110,24 +146,11 @@ def validate(self, value):
f"pattern does not match: {self.pattern}."
)

# Callable
if self.func is not None:
func_result = self.func(value)
if type(func_result) is bool:
if not func_result:
raise ValueError(
"value does not match the validator function."
)
elif type(func_result) is tuple:
if len(func_result) == 2 and type(func_result[0]) is bool and type(func_result[1]) is str:
if not func_result[0]:
raise ValueError(
func_result[1]
)
else:
raise ValueError(
f"validator function returned incorrect type: {str(type(func_result))}, should return bool or (bool, str)"
)
# Callable (non-list)
if self.func is not None and not original_value_type_list:
self.func_helper(value)



return True

Expand Down
19 changes: 14 additions & 5 deletions flask_parameter_validation/parameter_types/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
Query Parameters
- i.e. sent in GET requests, /?username=myname
"""
import json

from .parameter import Parameter


Expand All @@ -28,9 +30,16 @@ def convert(self, value, allowed_types):
pass
# bool conversion
if bool in allowed_types:
if value.lower() == "true":
value = True
elif value.lower() == "false":
value = False

try:
if value.lower() == "true":
value = True
elif value.lower() == "false":
value = False
except AttributeError:
pass
if dict in allowed_types:
try:
value = json.loads(value)
except ValueError:
raise ValueError(f"invalid JSON")
return super().convert(value, allowed_types)
27 changes: 27 additions & 0 deletions flask_parameter_validation/parameter_types/route.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,30 @@ class Route(Parameter):

def __init__(self, default=None, **kwargs):
super().__init__(default, **kwargs)

def convert(self, value, allowed_types):
"""Convert query parameters to corresponding types."""
if type(value) is str:
# int conversion
if int in allowed_types:
try:
value = int(value)
except ValueError:
pass
# float conversion
if float in allowed_types:
try:
value = float(value)
except ValueError:
pass
# bool conversion
if bool in allowed_types:
try:
if value.lower() == "true":
value = True
elif value.lower() == "false":
value = False
except AttributeError:
pass

return super().convert(value, allowed_types)
14 changes: 8 additions & 6 deletions flask_parameter_validation/parameter_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,9 @@
import inspect
import re
from inspect import signature

from flask import request
from werkzeug.datastructures import ImmutableMultiDict
from werkzeug.exceptions import BadRequest

from .exceptions import (InvalidParameterTypeError, MissingInputError,
ValidationError)
from .parameter_types import File, Form, Json, Query, Route
Expand Down Expand Up @@ -46,7 +44,7 @@ def __call__(self, f):
async def nested_func(**kwargs):
# Step 1 - Get expected input details as dict
expected_inputs = signature(f).parameters

# Step 2 - Validate JSON inputs
json_input = None
if request.headers.get("Content-Type") is not None:
Expand All @@ -61,7 +59,8 @@ async def nested_func(**kwargs):
# Step 3 - Extract list of parameters expected to be lists (otherwise all values are converted to lists)
expected_list_params = []
for name, param in expected_inputs.items():
if str(param.annotation).startswith("typing.List") or str(param.annotation).startswith("typing.Optional[typing.List"):
if str(param.annotation).startswith("typing.List") or str(param.annotation).startswith(
"typing.Optional[typing.List"):
expected_list_params.append(param.default.alias or name)

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

# Validate parameter-specific requirements are met
try:
expected_delivery_type.validate(user_input)
if type(user_input) is list:
expected_delivery_type.validate(user_input)
else:
expected_delivery_type.validate(user_inputs[0])
except ValueError as e:
raise ValidationError(str(e), expected_name, expected_input_type)

Expand Down
Empty file.
19 changes: 19 additions & 0 deletions flask_parameter_validation/test/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import pytest
from .testing_application import create_app


@pytest.fixture(scope="session")
def app():
app = create_app()
app.config.update({"TESTING": True})
yield app


@pytest.fixture()
def client(app):
return app.test_client()


@pytest.fixture()
def runner(app):
return app.test_cli_runner()
3 changes: 3 additions & 0 deletions flask_parameter_validation/test/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Flask==3.0.2
../../
requests
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions flask_parameter_validation/test/resources/test.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"name": "Test JSON Document",
"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."
}
Loading

0 comments on commit 47c6617

Please sign in to comment.