Skip to content
This repository has been archived by the owner on Mar 24, 2024. It is now read-only.

Commit

Permalink
Merge pull request #207 from quantmind/required
Browse files Browse the repository at this point in the history
docs
  • Loading branch information
lsbardel committed Mar 1, 2020
2 parents 967c0ab + 463ae59 commit 6348c96
Show file tree
Hide file tree
Showing 27 changed files with 375 additions and 169 deletions.
2 changes: 1 addition & 1 deletion .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"RedirectOutput"
],
"args": [
"tests/test_db.py"
"tests/data/test_validator.py"
]
}
]
Expand Down
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,10 @@ mypy: ## run mypy
@mypy openapi

postgresql: ## run postgresql for testing
docker run --rm -d --network=host --name=openapi-db postgres:12
docker run -e POSTGRES_PASSWORD=postgres --rm --network=host --name=openapi-db -d postgres:12

postgresql-nd: ## run postgresql for testing - non daemon
docker run --rm --network=host --name=openapi-db postgres:12
docker run -e POSTGRES_PASSWORD=postgres --rm --network=host --name=openapi-db postgres:12

py36: ## build python 3.6 image for testing
docker build -f dev/Dockerfile --build-arg PY_VERSION=python:3.6.10 -t openapi36 .
Expand Down
3 changes: 3 additions & 0 deletions dev/requirements-test.txt
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,6 @@ sqlalchemy-stubs
# additional features
raven-aiohttp
python-dotenv

#
openapi-spec-validator
17 changes: 17 additions & 0 deletions docs/reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,23 @@ JSON field

.. autofunction:: json_field

Data Validation
===============

Dataclass from db table
-----------------------
.. module:: openapi.data.db

.. autofunction:: dataclass_from_table


Dump data
---------
.. module:: openapi.data.dump

.. autofunction:: dump


Spec
====

Expand Down
2 changes: 1 addition & 1 deletion openapi/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Minimal OpenAPI asynchronous server application"""

__version__ = "1.6.1"
__version__ = "1.7.0"
11 changes: 9 additions & 2 deletions openapi/data/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,15 @@ def dataclass_from_table(
include: t.Optional[t.Sequence[str]] = None,
required: bool = False,
ops: t.Optional[t.Dict[str, t.Sequence[str]]] = None,
):
"""Create a dataclass from an sqlalchemy table
) -> type:
"""Create a dataclass from an :class:`sqlalchemy.schema.Table`
:param name: dataclass name
:param table: sqlalchemy table
:param exclude: fields to exclude from the dataclass
:param include: fields to include in the dataclass
:param required: set all non nullable columns as required fields in the dataclass
:param ops: additional operation for fields
"""
columns = []
include = set(include or table.columns.keys()) - set(exclude or ())
Expand Down
9 changes: 8 additions & 1 deletion openapi/data/dump.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,14 @@ def dump_dict(schema: Any, data: Dict[str, Any]) -> List[Dict]:
return {name: dump(schema, d) for name, d in data.items()}


def dump(schema: Any, data: Any):
def dump(schema: Any, data: Any) -> Any:
"""Dump data with a given schema.
:param schema: a valid schema is either a dataclass or a `List` of schemas
or a `Dict` of schemas
:param data: data to dump, if dataclasses are part of the schema,
the `dump` metadata function will be used if available (see :func:`.data_field`)
"""
type_info = TypingInfo.get(schema)
if type_info.container is list:
return dump_list(type_info.element, cast(List, data))
Expand Down
15 changes: 12 additions & 3 deletions openapi/data/fields.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import decimal
from dataclasses import Field, dataclass, field
from dataclasses import Field, dataclass, field, fields
from datetime import date, datetime, time
from numbers import Number
from typing import Any, Callable, Dict, Iterator, Optional, Tuple
Expand All @@ -23,11 +23,15 @@


class ValidationError(ValueError):
def __init__(self, field, message):
def __init__(self, field: str, message: str) -> None:
self.field = field
self.message = message


def field_dict(dc: type) -> Dict[str, Field]:
return {f.name: f for f in fields(dc)}


def data_field(
required: bool = False,
validator: Callable[[Field, Any, Dict], Any] = None,
Expand Down Expand Up @@ -447,4 +451,9 @@ def __call__(self, field, value, data=None):
raise ValidationError(field.name, "%s not valid" % value)

def dump(self, value):
return json.loads(value if isinstance(value, str) else json.dumps(value))
if isinstance(value, str):
try:
value = json.loads(value)
except json.JSONDecodeError:
pass
return json.loads(json.dumps(value))
137 changes: 84 additions & 53 deletions openapi/data/validate.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
from dataclasses import MISSING, Field, dataclass, fields
from typing import Any, Dict, List, Tuple, Union, cast
from dataclasses import MISSING, Field, fields
from typing import Any, Dict, List, NamedTuple, Tuple, Union

from multidict import MultiDict

from ..utils import TypingInfo, is_subclass
from ..utils import TypingInfo
from .fields import POST_PROCESS, REQUIRED, VALIDATOR, ValidationError, field_ops

NOT_VALID_TYPE = "not valid type"

@dataclass
class ValidatedData:
data: Any
errors: Dict

class ValidatedData(NamedTuple):
data: Any = None
errors: Union[Dict, List, str, None] = None


class ValidationErrors(ValueError):
Expand All @@ -19,57 +20,98 @@ def __init__(self, errors) -> None:


def validated_schema(schema, data, *, strict: bool = True):
d = validate(schema, data, strict=strict)
if d.errors:
raise ValidationErrors(d.errors)
return schema(**d.data)
data = validate(schema, data, strict=strict, raise_on_errors=True)
return schema(**data)


def validate(
schema: Any,
data: Union[Dict[str, Any], MultiDict],
data: Any,
*,
strict: bool = True,
multiple: bool = False,
) -> ValidatedData:
raise_on_errors: bool = False,
) -> Any:
"""Validate a dictionary of data with a given dataclass
"""
type_info = TypingInfo.get(schema)
if type_info.container is list:
return validate_list(type_info.element, data, strict=strict, multiple=multiple)
elif type_info.container is dict:
return validate_dict(type_info.element, data, strict=strict, multiple=multiple)
elif type_info.is_dataclass:
return validate_dataclass(
type_info.element, data, strict=strict, multiple=multiple
)
try:
if type_info.container is list:
vdata = validate_list(
type_info.element, data, strict=strict, multiple=multiple,
)
elif type_info.container is dict:
vdata = validate_dict(
type_info.element, data, strict=strict, multiple=multiple,
)
elif type_info.is_dataclass:
vdata = validate_dataclass(
type_info.element, data, strict=strict, multiple=multiple,
)
elif type_info.is_union:
vdata = validate_union(type_info.element, data)
else:
vdata = validate_simple(type_info.element, data)
except ValidationErrors as e:
if not raise_on_errors:
return ValidatedData(errors=e.errors)
raise
else:
return ValidatedData(data=data, errors={})
return vdata if raise_on_errors else ValidatedData(data=vdata, errors={})


def validate_simple(schema: type, data: Any) -> Any:
if isinstance(data, schema):
return data
raise ValidationErrors(NOT_VALID_TYPE)


def validate_union(schema: Tuple[TypingInfo, ...], data: Any) -> Any:
for type_info in schema:
try:
return validate(type_info, data, raise_on_errors=True)
except ValidationErrors:
continue
raise ValidationErrors(NOT_VALID_TYPE)


def validate_list(
schema: type, data: list, *, strict: bool = True, multiple: bool = False,
) -> ValidatedData:
validated = ValidatedData(data=[], errors={})
validated = []
if isinstance(data, list):
for d in data:
v = validate(schema, d, strict=strict, multiple=multiple)
validated.data.append(v.data)
validated.errors.update(v.errors)
v = validate(
schema, d, strict=strict, multiple=multiple, raise_on_errors=True
)
validated.append(v)
return validated
else:
validated.errors["message"] = "expected a sequence"
return validated
raise ValidationErrors("expected a sequence")


def validate_dict(
schema: type, data: Dict[str, Any], *, strict: bool = True, multiple: bool = False,
schema: type,
data: Dict[str, Any],
*,
strict: bool = True,
multiple: bool = False,
raise_on_errors: bool = True,
) -> ValidatedData:
validated = ValidatedData(data={}, errors={})
for name, d in data.items():
v = validate(schema, d, strict=strict, multiple=multiple)
validated.data[name] = v.data
validated.errors.update(v.errors)
return validated
if isinstance(data, dict):
validated = ValidatedData(data={}, errors={})
for name, d in data.items():
try:
validated.data[name] = validate(
schema, d, strict=strict, multiple=multiple, raise_on_errors=True
)
except ValidationErrors as exc:
validated.errors[name] = exc.errors
if validated.errors:
raise ValidationErrors(errors=validated.errors)
return validated.data
else:
raise ValidationErrors("expected an object")


def validate_dataclass(
Expand Down Expand Up @@ -97,7 +139,7 @@ def validate_dataclass(
continue

if multiple:
values = cast(MultiDict, data).getall(name)
values = data.getall(name)
if len(values) > 1:
collected = []
for v in values:
Expand All @@ -114,13 +156,17 @@ def validate_dataclass(

except ValidationError as exc:
errors[exc.field] = exc.message
except ValidationErrors as exc:
errors[name] = exc.errors

if not errors:
validate = getattr(schema, "validate", None)
if validate:
validate(cleaned, errors)

return ValidatedData(data=cleaned, errors=errors)
if errors:
raise ValidationErrors(errors=errors)
return cleaned


def collect_value(field: Field, name: str, value: Any) -> Any:
Expand All @@ -131,22 +177,7 @@ def collect_value(field: Field, name: str, value: Any) -> Any:
if validator:
value = validator(field, value)

if is_subclass(field.type, List) or is_subclass(field.type, Tuple):
# hack - we need to formalize this and allow for nested validators
if not isinstance(value, (list, tuple)):
raise ValidationError(name, "not a valid value")
value = list(value)
elif is_subclass(field.type, Dict):
if not isinstance(value, dict):
raise ValidationError(name, "not a valid value")
else:
types = getattr(field.type, "__args__", None) or (field.type,)
types = tuple((getattr(t, "__origin__", None) or t) for t in types)
if not isinstance(value, types):
try:
value = field.type(value)
except (TypeError, ValueError):
raise ValidationError(name, "not a valid value")
value = validate(field.type, value, raise_on_errors=True)

post_process = field.metadata.get(POST_PROCESS)
return post_process(value) if post_process else value
Expand Down
12 changes: 7 additions & 5 deletions openapi/spec/spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@ def get_schema_info(self, schema: Any) -> Dict[str, str]:
"type": "object",
"additionalProperties": self.get_schema_info(type_info.element),
}
elif type_info.is_union:
return {"oneOf": [self.get_schema_info(e) for e in type_info.element]}
elif type_info.is_dataclass:
name = self.add_schema_to_parse(type_info.element)
return {"$ref": f"{SCHEMA_BASE_REF}{name}"}
Expand Down Expand Up @@ -222,11 +224,9 @@ def build(
self.add_schema_to_parse(ValidationErrors)
self.add_schema_to_parse(ErrorMessage)
self.add_schema_to_parse(FieldError)
security = self.doc["info"].get("security")
sk = {}
security = self.doc["info"].pop("security", None) or {}
if security:
sk = security
self.doc["info"]["security"] = list(sk)
self.doc["info"]["security"] = list(security)
# Build paths
self._build_paths(app, public, private)
s = self.parsed_schemas()
Expand All @@ -240,7 +240,9 @@ def build(
schemas=OrderedDict(((k, s[k]) for k in sorted(s))),
parameters=OrderedDict(((k, p[k]) for k in sorted(p))),
responses=OrderedDict(((k, r[k]) for k in sorted(r))),
securitySchemes=OrderedDict((((k, sk[k]) for k in sorted(sk)))),
securitySchemes=OrderedDict(
(((k, security[k]) for k in sorted(security)))
),
),
servers=self.servers,
)
Expand Down
10 changes: 9 additions & 1 deletion openapi/testing.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,29 @@
"""Testing utilities
"""
import asyncio
from typing import Any

from aiohttp.client import ClientResponse

from .db.dbmodel import CrudDB
from .json import dumps, loads
from .utils import asynccontextmanager


async def jsonBody(response, status=200):
async def json_body(response: ClientResponse, status: int = 200) -> Any:
assert response.content_type == "application/json"
data = await response.json(loads=loads)
if response.status != status: # pragma: no cover
print(dumps({"status": response.status, "data": data}, indent=4))

assert response.status == status
return data


# backward compatibility
jsonBody = json_body


def equal_dict(d1, d2):
"""Check if two dictionaries are the same"""
d1, d2 = map(dumps, (d1, d2))
Expand Down

0 comments on commit 6348c96

Please sign in to comment.