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 #25 from lendingblock/master
Browse files Browse the repository at this point in the history
Release 0.1.5
  • Loading branch information
lsbardel authored Jun 29, 2018
2 parents c348916 + d863c9d commit 6938c81
Show file tree
Hide file tree
Showing 11 changed files with 111 additions and 32 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ dist
# ides
.vscode/.ropeproject
.idea
*.swp

# Mac
.DS_Store
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__ = '0.1.4'
__version__ = '0.1.5'
22 changes: 17 additions & 5 deletions openapi/data/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@
DEFAULT = 'default'
REQUIRED = 'required'
VALIDATOR = 'OPENAPI_VALIDATOR'
DUMP = 'dump',
DUMP = 'dump'
FORMAT = 'format'
OPS = 'ops'


class ValidationError(ValueError):
Expand All @@ -26,7 +27,8 @@ def __init__(self, field, message):


def data_field(
required=False, validator=None, default=None, dump=None, format=None):
required=False, validator=None, default=None, dump=None, format=None,
ops=()):
"""Extend a dataclass field with
:param validator: optional callable which accept (field, value, data)
Expand All @@ -37,6 +39,7 @@ def data_field(
:param dump: optional callable which receive the field value and convert to
the desired value to serve in requests
:param format: optional string which represents the JSON schema format
:param ops: optional tuple of strings specifying available operations
"""
if isinstance(validator, Validator) and not dump:
dump = validator.dump
Expand All @@ -46,7 +49,8 @@ def data_field(
REQUIRED: required,
DEFAULT: default,
DUMP: dump,
FORMAT: format
FORMAT: format,
OPS: ops
}))
return f

Expand Down Expand Up @@ -79,7 +83,8 @@ def decimal_field(required=False, min_value=None,
max_value=None, precision=None):
return data_field(
required=required,
validator=DecimalValidator(min_value, max_value, precision)
validator=DecimalValidator(min_value, max_value, precision),
ops=('lt', 'gt')
)


Expand All @@ -93,7 +98,8 @@ def enum_field(EnumClass, **kwargs):


def date_time_field(required=False):
return data_field(required=required, validator=DateTimeValidator())
return data_field(required=required, validator=DateTimeValidator(),
ops=('lt', 'gt'))


# VALIDATORS
Expand Down Expand Up @@ -237,3 +243,9 @@ def __call__(self, field, value, data=None):

def dump(self, value):
return str(value).lower() == 'true'


def field_ops(field):
yield field.name
for op in field.metadata.get(OPS, ()):
yield f'{field.name}:{op}'
40 changes: 24 additions & 16 deletions openapi/data/validate.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from typing import Dict
from dataclasses import dataclass

from .fields import VALIDATOR, REQUIRED, DEFAULT, ValidationError
from .fields import VALIDATOR, REQUIRED, DEFAULT, ValidationError, field_ops


@dataclass
Expand All @@ -13,30 +13,38 @@ class ValidatedData:
def validate(schema, data, strict=True):
"""Validate a dictionary of data with a given dataclass
"""
data = data.copy()
errors = {}
cleaned = {}
for field in schema.__dataclass_fields__.values():
try:
required = field.metadata.get(REQUIRED)
if field.name not in data and DEFAULT not in field.metadata:
if required and strict:
raise ValidationError(field.name, 'required')
continue

validator = field.metadata.get(VALIDATOR)
default = field.metadata.get(DEFAULT)
value = data.get(field.name, default)
if DEFAULT in field.metadata:
data.setdefault(field.name, field.metadata[DEFAULT])

if field.name not in data and required and strict:
raise ValidationError(field.name, 'required')

for name in field_ops(field):
if name not in data:
continue
value = data[name]

if value == 'NULL':
cleaned[name] = None
continue

if validator:
value = validator(field, value)
if validator:
value = validator(field, value)

if value and not isinstance(value, field.type):
try:
value = field.type(value)
except (TypeError, ValueError):
raise ValidationError(field.name, 'not a valid value')
if not isinstance(value, field.type):
try:
value = field.type(value)
except (TypeError, ValueError):
raise ValidationError(name, 'not a valid value')

cleaned[field.name] = value
cleaned[name] = value

except ValidationError as exc:
errors[exc.field] = exc.message
Expand Down
2 changes: 1 addition & 1 deletion openapi/db/path.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ async def get_one(
):
"""Get a single model
"""
table = table or self.db_table
table = table if table is not None else self.db_table
filters = self.get_filters(query, query_schema=query_schema)
query = self.get_query(table.select(), filters)
sql, args = compile_query(query)
Expand Down
6 changes: 3 additions & 3 deletions openapi/spec/path.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@ def insert_data(self, data, strict=True):
return data

def get_filters(self, query=None, query_schema='query_schema'):
if query is None:
query = dict(self.request.query)
combined = dict(self.request.query)
combined.update(query or {})
try:
params = self.cleaned(query_schema, query)
params = self.cleaned(query_schema, combined)
except web.HTTPNotImplemented:
params = {}
if self.path_schema:
Expand Down
5 changes: 3 additions & 2 deletions openapi/spec/spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from .exceptions import InvalidTypeException
from .path import ApiPath
from .utils import load_yaml_from_docstring
from ..data.fields import FORMAT, REQUIRED
from ..data.fields import FORMAT, REQUIRED, field_ops
from ..utils import compact

OPENAPI = '3.0.1'
Expand Down Expand Up @@ -76,7 +76,8 @@ def _schema2json(self, schema):
json_property = self._field2json(field)
if not json_property:
continue
properties[field.name] = json_property
for name in field_ops(field):
properties[name] = json_property

return {
'type': 'object',
Expand Down
5 changes: 5 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,8 @@ async def cli(loop, db_engine):
await client.start_server()
yield client
await client.close()


@pytest.fixture
def clean_db(db_engine):
db_engine.execute('truncate table tasks')
7 changes: 4 additions & 3 deletions tests/example/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ def meta(meta=None):

sa.Table(
'tasks', meta,
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('title', sa.String(), nullable=False),
sa.Column('done', sa.DateTime)
sa.Column('id', sa.Integer, primary_key=True),
sa.Column('title', sa.String, nullable=False),
sa.Column('done', sa.DateTime),
sa.Column('severity', sa.Integer)
)

return meta
5 changes: 4 additions & 1 deletion tests/example/models.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
from datetime import datetime

from dataclasses import dataclass

from openapi.data.fields import (
data_field, date_time_field
data_field, date_time_field, decimal_field
)


@dataclass
class TaskAdd:
title: str = data_field(required=True)
severity: int = decimal_field()


@dataclass
Expand All @@ -20,6 +22,7 @@ class Task(TaskAdd):
@dataclass
class TaskQuery:
done: bool = data_field()
severity: int = decimal_field()


@dataclass
Expand Down
48 changes: 48 additions & 0 deletions tests/test_filters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
from dataclasses import asdict

from openapi.spec import OpenApi, OpenApiSpec
from openapi.testing import jsonBody


async def test_spec(test_app):
open_api = OpenApi()

spec = OpenApiSpec(asdict(open_api))
spec.build(test_app)
assert spec.schemas['TaskQuery']['properties'].keys() == {
'done',
'severity',
'severity:lt',
'severity:gt'
}


async def test_filters(cli, clean_db):
test1 = {
'title': 'test1',
'severity': 1
}
test2 = {
'title': 'test2',
'severity': 3
}
test3 = {
'title': 'test3'
}

await cli.post('/tasks', json=test1)
await cli.post('/tasks', json=test2)
await cli.post('/tasks', json=test3)

async def assert_query(params, expected):
response = await cli.get('/tasks', params=params)
body = await jsonBody(response, 200)
for d in body:
d.pop('id')
assert body == expected

await assert_query({'severity:gt': 2}, [test2])
await assert_query({'severity:lt': 2}, [test1])
await assert_query({'severity': 2}, [])
await assert_query({'severity': 1}, [test1])
await assert_query({'severity': 'NULL'}, [test3])

0 comments on commit 6938c81

Please sign in to comment.