Skip to content
This repository has been archived by the owner on Feb 25, 2022. It is now read-only.

Support user defined formatters for validators #608

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions apistar/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ def __new__(cls, name, bases, attrs):


class Type(Mapping, metaclass=TypeMetaclass):

formatter = None

def __init__(self, *args, **kwargs):
definitions = None
allow_coerce = False
Expand Down Expand Up @@ -116,8 +119,8 @@ def __getitem__(self, key):
if value is None:
return None
validator = self.validator.properties[key]
if hasattr(validator, 'format') and validator.format in validators.FORMATS:
formatter = validators.FORMATS[validator.format]
if validator.formatter is not None:
formatter = validator.formatter
return formatter.to_string(value)
return value

Expand Down
12 changes: 8 additions & 4 deletions apistar/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ class Validator:
errors = {}
_creation_counter = 0

def __init__(self, title='', description='', default=NO_DEFAULT, allow_null=False, definitions=None, def_name=None):
def __init__(self, title='', description='', default=NO_DEFAULT, allow_null=False,
definitions=None, def_name=None, formatter=None):
definitions = {} if (definitions is None) else dict_type(definitions)

assert isinstance(title, str)
Expand All @@ -46,6 +47,7 @@ def __init__(self, title='', description='', default=NO_DEFAULT, allow_null=Fals
self.allow_null = allow_null
self.definitions = definitions
self.def_name = def_name
self.formatter = formatter

# We need this global counter to determine what order fields have
# been declared in when used with `Type`.
Expand Down Expand Up @@ -127,13 +129,15 @@ def __init__(self, max_length=None, min_length=None, pattern=None,
self.pattern = pattern
self.enum = enum
self.format = format
if isinstance(self.format, str) and self.formatter is None and self.format in FORMATS:
self.formatter = FORMATS[self.format]

def validate(self, value, definitions=None, allow_coerce=False):
if value is None and self.allow_null:
return None
elif value is None:
self.error('null')
elif self.format in FORMATS and FORMATS[self.format].is_native_type(value):
elif self.formatter is not None and self.formatter.is_native_type(value):
return value
elif not isinstance(value, str):
self.error('type')
Expand All @@ -159,8 +163,8 @@ def validate(self, value, definitions=None, allow_coerce=False):
if not re.search(self.pattern, value):
self.error('pattern')

if self.format in FORMATS:
return FORMATS[self.format].validate(value)
if self.formatter is not None:
return self.formatter.validate(value)

return value

Expand Down
36 changes: 36 additions & 0 deletions docs/api-guide/type-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,3 +270,39 @@ You can also access the serialized string representation if needed.
* `title` - A title to use in API schemas and documentation.
* `description` - A description to use in API schemas and documentation.
* `allow_null` - Indicates if `None` should be considered a valid value. Defaults to `False`.

## Custom Formats

Custom formatters can be provided for validators to enable them to return any native type

```python
from apistar.formats import BaseFormat

class Foo:
def __init__(self, bar):
self.bar = bar

class FooFormatter(BaseFormat):
def is_native_type(self, value):
return isinstance(value, Foo)

def to_string(self, value):
return value.bar

def validate(self, value):
if not isinstance(value, str) or not value.startswith('bar_'):
raise exceptions.ValidationError('Must start with bar_.')
return Foo(value)

class Example(types.Type):
foo = validators.String(formatter=FooFormatter())

>>> data = {'foo': 'bar_foo'}
>>> obj = Example(data)

>>> obj.foo
<__main__.Foo object at 0x7f143ec8ec88>

>>> obj['foo']
"bar_foo"
```
33 changes: 33 additions & 0 deletions tests/test_formats.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import pytest

from apistar import exceptions, types, validators
from apistar.formats import BaseFormat

UTC = datetime.timezone.utc

Expand Down Expand Up @@ -120,3 +121,35 @@ class Example(types.Type):
})
assert example.when is None
assert example['when'] is None


def test_custom_formatter():
class Foo:
def __init__(self, bar):
self.bar = bar

class FooFormatter(BaseFormat):
def is_native_type(self, value):
return isinstance(value, Foo)

def to_string(self, value):
return value.bar

def validate(self, value):
if not isinstance(value, str) or not value.startswith('bar_'):
raise exceptions.ValidationError('Must start with bar_.')
return Foo(value)

class Example(types.Type):
foo = validators.String(formatter=FooFormatter())

with pytest.raises(exceptions.ValidationError) as exc:
example = Example({
'foo': 'foo'
})
assert exc.value.detail == {'foo': 'Must start with bar_.'}

example = Example({'foo': 'bar_foo'})
assert isinstance(example.foo, Foo)
assert example.foo.bar == 'bar_foo'
assert example['foo'] == 'bar_foo'