colander icon indicating copy to clipboard operation
colander copied to clipboard

Add support for json schema

Open offlinehacker opened this issue 11 years ago • 10 comments

It would be great if colander models would be able to generate json schema. This way it would be possible to use any kind of form library that supports json schema to render forms, making it more standard and compatible.

offlinehacker avatar Jun 10 '13 16:06 offlinehacker

I added basic JSON schema support (alpha) - https://github.com/plamut/colander/commit/5385f02603c6086fb32662a57ecaa60d5be8080d However, this is work is far from finished, there are several things that deserve a closer look:

  • JSON schema specification can be found in this draft. There wasn't enough time at the sprint to study it in detail, so I used this WIki example as a reference as well. JSON schema as currently generated by colander might not be valid (e.g. there might be some properties/attributes which are not allowed by the draft).
  • There are no tests yet. It would also be beneficial to test JSON schema compatibility with existing form libraries which consume JSON schema as an input.
  • For some of the colander SchemaType objects it wasn't clear how to serialize them to JSON to retain all information. For example, a Tuple node is converted to JSON 'array' with a custom descriptive attribute length, specifying the number of items in the tuple. As mentioned before this might not be in accordance with the aforementioned draft.

I'm attaching a script I was using during the development, it might come handy. It basically defines a colander schema, dumps it to JSON and prints out the result.

from datetime import time
import colander
import json

class Friend(colander.TupleSchema):
    rank = colander.SchemaNode(colander.Int(),
                              validator=colander.Range(0, 9999))
    name = colander.SchemaNode(colander.String())

class Phone(colander.MappingSchema):
    location = colander.SchemaNode(colander.String(),
                                  validator=colander.OneOf(['home', 'work']))
    number = colander.SchemaNode(colander.String())

class Friends(colander.SequenceSchema):
    friend = Friend()

class Phones(colander.SequenceSchema):
    phone = Phone()

class NumberList(colander.SequenceSchema):
    number = colander.SchemaNode(colander.Int())

class OrderedList(colander.List):
    item = colander.SchemaNode(colander.String())



class Person(colander.MappingSchema):
    name = colander.SchemaNode(colander.String())
    nickname = colander.SchemaNode(colander.String(), missing=colander.null)
    age = colander.SchemaNode(colander.Int(),
                             validator=colander.Range(0, 200))
    friends = Friends()
    phones = Phones()
    fav_numbers = NumberList()
    is_happy = colander.SchemaNode(colander.Bool())
    ordered_item_list = colander.SchemaNode(OrderedList())
    my_unique_items = colander.SchemaNode(colander.Set())
    date_of_birth = colander.SchemaNode(colander.Date())
    time_of_arrival = colander.SchemaNode(colander.DateTime())
    wakes_up_at = colander.SchemaNode(colander.Time())
    my_global_object = colander.SchemaNode(colander.GlobalObject(time))


schema = Person()
json_schema = schema._to_json_dict()
json_string = schema.to_json()

assert(json_string == json.dumps(json_schema))
print json_string

import pprint
pp = pprint.PrettyPrinter(indent=4)
print "************ final JSON schema **************"
pp.pprint(json_schema)

plamut avatar Aug 17 '13 16:08 plamut

Nice, thanks, i will look it up and test it as soon as possible for sure, and make contributions when time will allow. ColanderAlchemy+colander+json_schema makes a new world of possibilities.

I'm already sorry for not attending the sprint, but i had more important things to do. On Aug 17, 2013 6:47 PM, "Peter Lamut" [email protected] wrote:

I added basic JSON schema support (alpha) - plamut@5385f02https://github.com/plamut/colander/commit/5385f02603c6086fb32662a57ecaa60d5be8080dHowever, this is work is far from finished, there are several things that deserve a closer look:

JSON schema specification can be found in this drafthttp://tools.ietf.org/html/draft-zyp-json-schema-04. There wasn't enough time at the sprint to study it in detail, so I used this WIki example http://en.wikipedia.org/wiki/JSON#JSON_Schema as a reference as well. JSON schema as currently generated by colander might not be valid (e.g. there might be some properties/attributes which are not allowed by the draft).

There are no tests yet. It would also be beneficial to test JSON schema compatibility with existing form libraries which consume JSON schema as an input.

For some of the colander SchemaType objects it wasn't clear how to serialize them to JSON to retain all information. For example, a Tuple node is converted to JSON 'array' with a custom descriptive attribute * length*, specifying the number of items in the tuple. As mentioned before this might not be in accordance with the aforementioned draft.

I'm attaching a script I was using during the development, it might come handy. It basically defines a colander schema, dumps it to JSON and prints out the result.

from datetime import timeimport colanderimport json class Friend(colander.TupleSchema): rank = colander.SchemaNode(colander.Int(), validator=colander.Range(0, 9999)) name = colander.SchemaNode(colander.String()) class Phone(colander.MappingSchema): location = colander.SchemaNode(colander.String(), validator=colander.OneOf(['home', 'work'])) number = colander.SchemaNode(colander.String()) class Friends(colander.SequenceSchema): friend = Friend() class Phones(colander.SequenceSchema): phone = Phone() class NumberList(colander.SequenceSchema): number = colander.SchemaNode(colander.Int()) class OrderedList(colander.List): item = colander.SchemaNode(colander.String())

class Person(colander.MappingSchema): name = colander.SchemaNode(colander.String()) nickname = colander.SchemaNode(colander.String(), missing=colander.null) age = colander.SchemaNode(colander.Int(), validator=colander.Range(0, 200)) friends = Friends() phones = Phones() fav_numbers = NumberList() is_happy = colander.SchemaNode(colander.Bool()) ordered_item_list = colander.SchemaNode(OrderedList()) my_unique_items = colander.SchemaNode(colander.Set()) date_of_birth = colander.SchemaNode(colander.Date()) time_of_arrival = colander.SchemaNode(colander.DateTime()) wakes_up_at = colander.SchemaNode(colander.Time()) my_global_object = colander.SchemaNode(colander.GlobalObject(time))

schema = Person()json_schema = schema._to_json_dict()json_string = schema.to_json() assert(json_string == json.dumps(json_schema))print json_string import pprintpp = pprint.PrettyPrinter(indent=4)print "************ final JSON schema **************"pp.pprint(json_schema)

— Reply to this email directly or view it on GitHubhttps://github.com/Pylons/colander/issues/112#issuecomment-22815218 .

offlinehacker avatar Aug 17 '13 17:08 offlinehacker

Just a quick answer from what i can see from diff and from my knowledge of colander.

Colander does not output json from serializer(even if you do json.dumps), it outputs some tree structure with every leaf node value type equal to string. There's a bug request open for that and patches provided, but no decisions or expanation made. That's why to_json could be renamed to to_json_schema.

Secondly, the function that really outputs json data string with correct types should be made or serializer should be patched or added another flag to output json. I preffer to patch serializer to output json and add to_json function which would output json data. That probably also requires deform to be patched. On Aug 17, 2013 7:16 PM, "Jaka Hudoklin" [email protected] wrote:

Nice, thanks, i will look it up and test it as soon as possible for sure, and make contributions when time will allow. ColanderAlchemy+colander+json_schema makes a new world of possibilities.

I'm already sorry for not attending the sprint, but i had more important things to do. On Aug 17, 2013 6:47 PM, "Peter Lamut" [email protected] wrote:

I added basic JSON schema support (alpha) - plamut@5385f02https://github.com/plamut/colander/commit/5385f02603c6086fb32662a57ecaa60d5be8080dHowever, this is work is far from finished, there are several things that deserve a closer look:

JSON schema specification can be found in this drafthttp://tools.ietf.org/html/draft-zyp-json-schema-04. There wasn't enough time at the sprint to study it in detail, so I used this WIki example http://en.wikipedia.org/wiki/JSON#JSON_Schema as a reference as well. JSON schema as currently generated by colander might not be valid (e.g. there might be some properties/attributes which are not allowed by the draft).

There are no tests yet. It would also be beneficial to test JSON schema compatibility with existing form libraries which consume JSON schema as an input.

For some of the colander SchemaType objects it wasn't clear how to serialize them to JSON to retain all information. For example, a Tuple node is converted to JSON 'array' with a custom descriptive attribute * length*, specifying the number of items in the tuple. As mentioned before this might not be in accordance with the aforementioned draft.

I'm attaching a script I was using during the development, it might come handy. It basically defines a colander schema, dumps it to JSON and prints out the result.

from datetime import timeimport colanderimport json class Friend(colander.TupleSchema): rank = colander.SchemaNode(colander.Int(), validator=colander.Range(0, 9999)) name = colander.SchemaNode(colander.String()) class Phone(colander.MappingSchema): location = colander.SchemaNode(colander.String(), validator=colander.OneOf(['home', 'work'])) number = colander.SchemaNode(colander.String()) class Friends(colander.SequenceSchema): friend = Friend() class Phones(colander.SequenceSchema): phone = Phone() class NumberList(colander.SequenceSchema): number = colander.SchemaNode(colander.Int()) class OrderedList(colander.List): item = colander.SchemaNode(colander.String())

class Person(colander.MappingSchema): name = colander.SchemaNode(colander.String()) nickname = colander.SchemaNode(colander.String(), missing=colander.null) age = colander.SchemaNode(colander.Int(), validator=colander.Range(0, 200)) friends = Friends() phones = Phones() fav_numbers = NumberList() is_happy = colander.SchemaNode(colander.Bool()) ordered_item_list = colander.SchemaNode(OrderedList()) my_unique_items = colander.SchemaNode(colander.Set()) date_of_birth = colander.SchemaNode(colander.Date()) time_of_arrival = colander.SchemaNode(colander.DateTime()) wakes_up_at = colander.SchemaNode(colander.Time()) my_global_object = colander.SchemaNode(colander.GlobalObject(time))

schema = Person()json_schema = schema._to_json_dict()json_string = schema.to_json() assert(json_string == json.dumps(json_schema))print json_string import pprintpp = pprint.PrettyPrinter(indent=4)print "************ final JSON schema **************"pp.pprint(json_schema)

— Reply to this email directly or view it on GitHubhttps://github.com/Pylons/colander/issues/112#issuecomment-22815218 .

offlinehacker avatar Aug 17 '13 17:08 offlinehacker

Hey, sorry for the late reply, I'm overwhelmed with work. I agree with the proposition, to_json can indeed be renamed to to_json_schema, it's more explicit on what it actually do.

I haven't touched serializer at the sprint, but yes, I thank that's the right place to add JSON support, since serializer already deals with data. And yes, the libraries that currently depend on colander's serialization would probably need to be patched as well (but can't really say, I don't know them).

One problem I see with serializing is how to define a mapping between colander and JSON types, the latter being rather limited. For instance a Tuple in colander could be serialized to JSON array, but would probably need additional metadata (such as number of elements, which is fixed for a Tuple) if we want to have a reverse transformation (JSON --> colander) unambiguously defined. Of course libraries dependent on the colander would most likely have to be aware of this metadata.

plamut avatar Aug 21 '13 08:08 plamut

I like this idea, but I don't understand why generating JSON schemas (for JS form generation) should to be part of colander. Why not create a separate package, just as HTML form generation is done in a separate package.

I do think that serializing data to JSON is a reasonable enhancement for colander, but that's a different issue.

latteier avatar Apr 16 '14 18:04 latteier

Does this do the trick? https://github.com/sbrauer/Audrey/blob/master/audrey/colanderutil.py

Sorry if not relevant. Did not take the time to investigate much.

marconius avatar Jul 29 '14 20:07 marconius

BTW, is there something in the opposite direction (from json schema to colander), similar to dict2colander, but with JSON schema as input?

rnd0101 avatar May 15 '15 17:05 rnd0101

Looks like some work has been done on this already: https://pypi.python.org/simple/colander-jsonschema/

# -*- coding: utf-8 -*-

import colander
import colander.interfaces


__version__ = '0.2'


class ConversionError(Exception):
    pass


class NoSuchConverter(ConversionError):
    pass


def convert_length_validator_factory(max_key, min_key):
    """
    :type max_key: str
    :type min_key: str
    """
    def validator_converter(schema_node, validator):
        """
        :type schema_node: colander.SchemaNode
        :type validator: colander.interfaces.Validator
        :rtype: dict
        """
        converted = None
        if isinstance(validator, colander.Length):
            converted = {}
            if validator.max is not None:
                converted[max_key] = validator.max
            if validator.min is not None:
                converted[min_key] = validator.min
        return converted
    return validator_converter


def convert_oneof_validator_factory(null_values=(None,)):
    """
    :type null_values: iter
    """
    def validator_converter(schema_node, validator):
        """
        :type schema_node: colander.SchemaNode
        :type validator: colander.interfaces.Validator
        :rtype: dict
        """
        converted = None
        if isinstance(validator, colander.OneOf):
            converted = {}
            converted['enum'] = list(validator.choices)
            if not schema_node.required:
                converted['enum'].extend(list(null_values))
        return converted

    return validator_converter


def convert_range_validator(schema_node, validator):
    """
    :type schema_node: colander.SchemaNode
    :type validator: colander.interfaces.Validator
    :rtype: dict
    """
    converted = None
    if isinstance(validator, colander.Range):
        converted = {}
        if validator.max is not None:
            converted['maximum'] = validator.max
        if validator.min is not None:
            converted['minimum'] = validator.min
    return converted


def convert_regex_validator(schema_node, validator):
    """
    :type schema_node: colander.SchemaNode
    :type validator: colander.interfaces.Validator
    :rtype: dict
    """
    converted = None
    if isinstance(validator, colander.Regex):
        converted = {}
        if hasattr(colander, 'url') and validator is colander.url:
            converted['format'] = 'uri'
        elif isinstance(validator, colander.Email):
            converted['format'] = 'email'
        else:
            converted['pattern'] = validator.match_object.pattern
    return converted


class ValidatorConversionDispatcher(object):

    def __init__(self, *converters):
        self.converters = converters

    def __call__(self, schema_node, validator=None):
        """
        :type schema_node: colander.SchemaNode
        :type validator: colander.interfaces.Validator
        :rtype: dict
        """
        if validator is None:
            validator = schema_node.validator
        converted = {}
        if validator is not None:
            for converter in (self.convert_all_validator,) + self.converters:
                ret = converter(schema_node, validator)
                if ret is not None:
                    converted = ret
                    break
        return converted

    def convert_all_validator(self, schema_node, validator):
        """
        :type schema_node: colander.SchemaNode
        :type validator: colander.interfaces.Validator
        :rtype: dict
        """
        converted = None
        if isinstance(validator, colander.All):
            converted = {}
            for v in validator.validators:
                ret = self(schema_node, v)
                converted.update(ret)
        return converted


class TypeConverter(object):

    type = ''
    convert_validator = lambda self, schema_node: {}

    def __init__(self, dispatcher):
        """
        :type dispatcher: TypeConversionDispatcher
        """
        self.dispatcher = dispatcher

    def convert_type(self, schema_node, converted):
        """
        :type schema_node: colander.SchemaNode
        :type converted: dict
        :rtype: dict
        """
        converted['type'] = self.type
        if not schema_node.required:
            converted['type'] = [converted['type'], 'null']
        if schema_node.title:
            converted['title'] = schema_node.title
        if schema_node.description:
            converted['description'] = schema_node.description
        if schema_node.default is not colander.null:
            converted['default'] = schema_node.default
        return converted

    def __call__(self, schema_node, converted=None):
        """
        :type schema_node: colander.SchemaNode
        :type converted: dict
        :rtype: dict
        """
        if converted is None:
            converted = {}
        converted = self.convert_type(schema_node, converted)
        converted.update(self.convert_validator(schema_node))
        return converted


class BaseStringTypeConverter(TypeConverter):

    type = 'string'
    format = None

    def convert_type(self, schema_node, converted):
        """
        :type schema_node: colander.SchemaNode
        :type converted: dict
        :rtype: dict
        """
        converted = super(BaseStringTypeConverter,
                          self).convert_type(schema_node, converted)
        if schema_node.required:
            converted['minLength'] = 1
        if self.format is not None:
            converted['format'] = self.format
        return converted


class BooleanTypeConverter(TypeConverter):
    type = 'boolean'


class DateTypeConverter(BaseStringTypeConverter):
    format = 'date'


class DateTimeTypeConverter(BaseStringTypeConverter):
    format = 'date-time'


class NumberTypeConverter(TypeConverter):
    type = 'number'
    convert_validator = ValidatorConversionDispatcher(
        convert_range_validator,
        convert_oneof_validator_factory(),
    )


class IntegerTypeConverter(NumberTypeConverter):
    type = 'integer'


class StringTypeConverter(BaseStringTypeConverter):
    convert_validator = ValidatorConversionDispatcher(
        convert_length_validator_factory('maxLength', 'minLength'),
        convert_regex_validator,
        convert_oneof_validator_factory(('', None)),
    )


class TimeTypeConverter(BaseStringTypeConverter):
    format = 'time'


class ObjectTypeConverter(TypeConverter):

    type = 'object'

    def convert_type(self, schema_node, converted):
        """
        :type schema_node: colander.SchemaNode
        :type converted: dict
        :rtype: dict
        """
        converted = super(ObjectTypeConverter,
                          self).convert_type(schema_node, converted)
        properties = {}
        required = []
        for sub_node in schema_node.children:
            properties[sub_node.name] = self.dispatcher(sub_node)
            if sub_node.required:
                required.append(sub_node.name)
        converted['properties'] = properties
        if len(required) > 0:
            converted['required'] = required
        return converted


class ArrayTypeConverter(TypeConverter):

    type = 'array'
    convert_validator = ValidatorConversionDispatcher(
        convert_length_validator_factory('maxItems', 'minItems'),
    )

    def convert_type(self, schema_node, converted):
        """
        :type schema_node: colander.SchemaNode
        :type converted: dict
        :rtype: dict
        """
        converted = super(ArrayTypeConverter,
                          self).convert_type(schema_node, converted)
        converted['items'] = self.dispatcher(schema_node.children[0])
        return converted


class TypeConversionDispatcher(object):

    converters = {
        colander.Boolean: BooleanTypeConverter,
        colander.Date: DateTypeConverter,
        colander.DateTime: DateTimeTypeConverter,
        colander.Float: NumberTypeConverter,
        colander.Integer: IntegerTypeConverter,
        colander.Mapping: ObjectTypeConverter,
        colander.Sequence: ArrayTypeConverter,
        colander.String: StringTypeConverter,
        colander.Time: TimeTypeConverter,
    }

    def __init__(self, converters=None):
        """
        :type converters: dict
        """
        if converters is not None:
            self.converters.update(converters)

    def __call__(self, schema_node):
        """
        :type schema_node: colander.SchemaNode
        :rtype: dict
        """
        schema_type = schema_node.typ
        schema_type = type(schema_type)
        converter_class = self.converters.get(schema_type)
        if converter_class is None:
            raise NoSuchConverter
        converter = converter_class(self)
        converted = converter(schema_node)
        return converted


def finalize_conversion(converted):
    """
    :type converted: dict
    :rtype: dict
    """
    converted['$schema'] = 'http://json-schema.org/draft-04/schema#'
    return converted


def convert(schema_node, converters=None):
    """
    :type schema_node: colander.SchemaNode
    :type converters: dict
    :rtype: dict
    """
    dispatcher = TypeConversionDispatcher(converters)
    converted = dispatcher(schema_node)
    converted = finalize_conversion(converted)
    return converted

… but hasn't been updated since 2014. This is a blocker, I will use Marshmallow instead.

SamuelMarks avatar Mar 01 '17 13:03 SamuelMarks

+1

sighalt avatar Jan 24 '18 13:01 sighalt

Was there any update on reading or writing JSON schema using Colander?

jenstroeger avatar Aug 22 '18 06:08 jenstroeger