Skip to content

Commit

Permalink
start on schema validation
Browse files Browse the repository at this point in the history
for #3
  • Loading branch information
snarfed committed Sep 22, 2024
1 parent 677cd0c commit 166a06a
Show file tree
Hide file tree
Showing 10 changed files with 253 additions and 254 deletions.
1 change: 0 additions & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,6 @@
# Example configuration for intersphinx: refer to the Python standard library.
intersphinx_mapping = {
'flask': ('https://flask.palletsprojects.com/en/latest', None),
'jsonschema': ('https://python-jsonschema.readthedocs.io/en/stable', None),
'python': ('https://docs.python.org/3/', None),
'requests': ('https://docs.python-requests.org/en/stable/', None),
'simple_websocket': ('https://simple-websocket.readthedocs.io/en/stable', None),
Expand Down
135 changes: 70 additions & 65 deletions lexrpc/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,9 @@
import json
import logging
import re
from types import NoneType
import urllib.parse

import jsonschema
from jsonschema import validators

logger = logging.getLogger(__name__)

METHOD_TYPES = frozenset((
Expand All @@ -30,6 +28,16 @@
'number',
'string',
))
# https://atproto.com/specs/lexicon#overview-of-types
FIELD_TYPES = {
'null': NoneType,
'boolean': bool,
'integer': int,
'string': str,
'bytes': bytes,
'array': list,
'object': dict,
}

# https://atproto.com/specs/nsid
NSID_SEGMENT = '[a-zA-Z0-9-]+'
Expand All @@ -53,25 +61,6 @@ def __init__(self, message, name=None, **kwargs):
self.message = message


# TODO: drop jsonschema, implement from scratch? maybe skip until some methods
# are implemented? probably not?

# in progress code to extend jsonschema validator to support ref etc
#
# def is_ref(checker, instance):
# return True

# class CustomValidator(validators._LATEST_VERSION):
# TYPE_CHECKER = validators._LATEST_VERSION.TYPE_CHECKER.redefine('ref', is_ref)

# # CustomValidator.META_SCHEMA['type'] += ['record', 'ref', 'token']
# import json; print(json.dumps(CustomValidator.META_SCHEMA, indent=2))

# CustomValidator._META_SCHEMAS\
# ['https://json-schema.org/draft/2020-12/meta/validation']\
# ['$defs']['simpleTypes'].append('ref')


def load_lexicons(traversable):
if traversable.is_file():
lexicons = [json.loads(traversable.read_text())]
Expand All @@ -91,6 +80,11 @@ def fail(msg, exc=NotImplementedError):
raise exc(msg)


class ValidationError(ValueError):
"""Raised when an object or XRPC input or output doesn't match its schema."""
pass


class Base():
"""Base class for both XRPC client and server."""

Expand All @@ -111,7 +105,7 @@ def __init__(self, lexicons=None, validate=True, truncate=False):
than their ``maxGraphemes`` or ``maxLength`` in their lexicon
Raises:
jsonschema.SchemaError: if any schema is invalid
ValidationError: if any schema is invalid
"""
self._validate = validate
self._truncate = truncate
Expand All @@ -122,37 +116,24 @@ def __init__(self, lexicons=None, validate=True, truncate=False):

for i, lexicon in enumerate(copy.deepcopy(lexicons)):
nsid = lexicon.get('id')
assert nsid, f'Lexicon {i} missing id field'
if not nsid:
raise ValidationError(f'Lexicon {i} missing id field')
# logger.debug(f'Loading lexicon {nsid}')

for name, defn in lexicon.get('defs', {}).items():
id = nsid if name == 'main' else f'{nsid}#{name}'
self.defs[id] = defn

type = defn['type']
assert type in LEXICON_TYPES | PARAMETER_TYPES, \
f'Bad type for lexicon {id}: {type}'

if type in METHOD_TYPES:
# preprocess parameters properties into full JSON Schema
params = defn.get('parameters', {})
defn['parameters'] = {
'schema': {
'type': 'object',
'required': params.get('required', []),
'properties': params.get('properties', {}),
},
}
if type not in LEXICON_TYPES | PARAMETER_TYPES:
raise ValidationError(f'Bad type for lexicon {id}: {type}')

for field in ('input', 'output', 'message',
'parameters', 'record'):
if validate:
# validate schemas
for field in ('input', 'output', 'message',
'parameters', 'record'):
# logger.debug(f'Validating {id} {field} schema')
schema = defn.get(field, {}).get('schema')
# if schema:
# TODO: adapt jsonschema to support Lexicon, or drop
# validators.validator_for(schema).check_schema(schema)
# logger.debug(f'Validating {id} {field} schema')
# TODO
pass

self.defs[id] = defn

Expand All @@ -174,7 +155,9 @@ def _get_def(self, id):
def _maybe_validate(self, nsid, type, obj):
"""If configured to do so, validates a JSON object against a given schema.
Does nothing if this object was initialized with validate=False.
Returns ``None`` if the object validates, otherwise raises an exception.
Does nothing if this object was initialized with ``validate=False``.
Args:
nsid (str): method NSID
Expand All @@ -188,9 +171,9 @@ def _maybe_validate(self, nsid, type, obj):
Raises:
NotImplementedError: if no lexicon exists for the given NSID, or the
lexicon does not define a schema for the given type
jsonschema.ValidationError: if the object is invalid
ValidationError: if the object is invalid
"""
assert type in ('input', 'output', 'parameters', 'record'), type
assert type in ('input', 'output', 'message', 'parameters', 'record'), type

base = self._get_def(nsid).get(type, {})
encoding = base.get('encoding')
Expand All @@ -203,12 +186,11 @@ def _maybe_validate(self, nsid, type, obj):
schema = base.get('schema')

if not schema:
# TODO: handle # fragment ids
if '#' in nsid:
return obj
if not obj:
return obj
fail(f'{nsid} has no schema for {type}')

return
# ...or should we fail if obj is non-null? maybe not, since then
# we'd fail if a query with no params gets requested with any query
# params at all, eg utm_* tracking params

if self._truncate:
for name, config in schema.get('properties', {}).items():
Expand All @@ -222,16 +204,41 @@ def _maybe_validate(self, nsid, type, obj):
}

if not self._validate:
return obj
return

# logger.debug(f'Validating {nsid} {type}')
try:
# TODO: adapt jsonschema to support Lexicon, or drop
# jsonschema.validate(obj, schema)
pass
except jsonschema.ValidationError as e:
e.message = f'Error validating {nsid} {type}: {e.message}'
raise

for name, prop in schema.get('properties', {}).items():
if name not in obj:
if name in schema.get('required', []):
raise ValidationError(
f'{nsid} {type} missing required property {name}')
continue

val = obj[name]
if val is None:
if name not in schema.get('nullable', []):
raise ValidationError(
f'{nsid} {type} property {name} is not nullable')
continue

# TODO: datetime
# TODO: token
# TODO: ref
# TODO: union
# TODO: unknown
# TODO: cid-link
if not isinstance(val, FIELD_TYPES[prop['type']]):
raise ValidationError(
f'{nsid} {type}: unexpected value for {prop["type"]} property '
f'{name}: {val!r}')

if type == 'array':
for item in val:
if not isinstance(item, FIELD_TYPES[prop['items']['type']]):
raise ValidationError(
f'{nsid} {type}: unexpected item for {prop["type"]} '
f'array property {name}: {item!r}')

return obj

Expand Down Expand Up @@ -270,9 +277,7 @@ def decode_params(self, method_nsid, params):
NotImplementedError: if no method lexicon is registered for the given NSID
"""
lexicon = self._get_def(method_nsid)
params_schema = lexicon.get('parameters', {})\
.get('schema', {})\
.get('properties', {})
params_schema = lexicon.get('parameters', {}).get('properties', {})

decoded = {}
for name, val in params:
Expand Down
6 changes: 3 additions & 3 deletions lexrpc/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ def __init__(self, address=DEFAULT_PDS, access_token=None,
kwargs: passed through to :class:`Base`
Raises:
jsonschema.SchemaError: if any schema is invalid
ValidationError: if any schema is invalid
"""
super().__init__(**kwargs)

Expand Down Expand Up @@ -136,8 +136,8 @@ def call(self, nsid, input=None, headers={}, decode=True, **params):
Raises:
NotImplementedError: if the given NSID is not found in any of the
loaded lexicons
jsonschema.ValidationError: if the parameters, input, or returned
output don't validate against the method's schemas
ValidationError: if the parameters, input, or returned output don't
validate against the method's schemas
requests.RequestException: if the connection or HTTP request to the
remote server failed
"""
Expand Down
3 changes: 1 addition & 2 deletions lexrpc/flask_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,10 @@
from flask.views import View
from flask_sock import Sock
from iterators import TimeoutIterator
from jsonschema import ValidationError
from simple_websocket import ConnectionClosed

from . import base
from .base import NSID_RE
from .base import NSID_RE, ValidationError
from .server import Redirect

logger = logging.getLogger(__name__)
Expand Down
6 changes: 3 additions & 3 deletions lexrpc/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ def __init__(self, **kwargs):
kwargs: passed through to :class:`Base`
Raises:
jsonschema.SchemaError: if any schema is invalid
ValidationError: if any schema is invalid
"""
super().__init__(**kwargs)
self._methods = {}
Expand Down Expand Up @@ -87,8 +87,8 @@ def call(self, nsid, input=None, **params):
Raises:
NotImplementedError: if the given NSID is not implemented or found in
any of the loaded lexicons
jsonschema.ValidationError: if the parameters, input, or returned
output don't validate against the method's schemas
ValidationError: if the parameters, input, or returned output don't
validate against the method's schemas
"""
def loggable(val):
return f'{len(val)} bytes' if isinstance(val, bytes) else val
Expand Down
Loading

0 comments on commit 166a06a

Please sign in to comment.