commit 931d0ddb1cb84df4c371cfd732f3d5cda77074a2 Author: Syrus Akbary Date: Thu Sep 24 02:11:50 2015 -0700 First working version of Graphene 😃 diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..37744dec --- /dev/null +++ b/.gitignore @@ -0,0 +1,61 @@ +# Created by https://www.gitignore.io + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover + +# Translations +*.mo +*.pot + +# Django stuff: +*.log + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..fff4be87 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,15 @@ +language: python +sudo: false +python: +- 2.7 +install: +- pip install pytest pytest-cov coveralls flake8 +- pip install -e .[django] +- pip install git+https://github.com/dittos/graphqllib.git # Last version of graphqllib +- pip install graphql-relay +- python setup.py develop +script: +- py.test --cov=graphene +# - flake8 +after_success: +- coveralls diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..9127d7c5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015 Syrus Akbary + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 00000000..0e9d91b8 --- /dev/null +++ b/README.md @@ -0,0 +1,21 @@ +# Graphene: GraphQL Object Mapper + +This is a library to use GraphQL in Python in a easy way. +It will map the models/fields to internal GraphQL-py objects without effort. + +[![Build Status](https://travis-ci.org/syrusakbary/graphene.svg?branch=master)](https://travis-ci.org/syrusakbary/graphene) +[![Coverage Status](https://coveralls.io/repos/syrusakbary/graphene/badge.svg?branch=master&service=github)](https://coveralls.io/github/syrusakbary/graphene?branch=master) + +## Contributing + +After cloning this repo, ensure dependencies are installed by running: + +```sh +python setup.py install +``` + +After developing, the full test suite can be evaluated by running: + +```sh +python setup.py test # Use --pytest-args="-v -s" for verbose mode +``` diff --git a/graphene/__init__.py b/graphene/__init__.py new file mode 100644 index 00000000..42442952 --- /dev/null +++ b/graphene/__init__.py @@ -0,0 +1,28 @@ +from graphql.core.type import ( + GraphQLEnumType as Enum, + GraphQLArgument as Argument, + # GraphQLSchema as Schema, + GraphQLString as String, + GraphQLInt as Int, + GraphQLID as ID +) + +from graphene.core.fields import ( + Field, + StringField, + IntField, + BooleanField, + IDField, + ListField, + NonNullField, +) + +from graphene.core.types import ( + ObjectType, + Interface, + Schema +) + +from graphene.decorators import ( + resolve_only_args +) diff --git a/graphene/core/__init__.py b/graphene/core/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/graphene/core/fields.py b/graphene/core/fields.py new file mode 100644 index 00000000..876416b1 --- /dev/null +++ b/graphene/core/fields.py @@ -0,0 +1,136 @@ +import inspect +from graphql.core.type import ( + GraphQLField, + GraphQLList, + GraphQLNonNull, + GraphQLInt, + GraphQLString, + GraphQLBoolean, + GraphQLID, + GraphQLArgument, +) +from graphene.core.types import ObjectType, Interface +from graphene.utils import cached_property + +class Field(object): + def __init__(self, field_type, resolve=None, null=True, args=None, description='', **extra_args): + self.field_type = field_type + self.resolve_fn = resolve + self.null = null + self.args = args or {} + self.extra_args = extra_args + self._type = None + self.description = description or self.__doc__ + self.object_type = None + + def contribute_to_class(self, cls, name): + self.field_name = name + self.object_type = cls + if isinstance(self.field_type, Field) and not self.field_type.object_type: + self.field_type.contribute_to_class(cls, name) + cls._meta.add_field(self) + + def resolver(self, instance, args, info): + if self.object_type.can_resolve(self.field_name, instance, args, info): + return self.resolve(instance, args, info) + else: + return None + + def resolve(self, instance, args, info): + if self.resolve_fn: + resolve_fn = self.resolve_fn + else: + resolve_fn = lambda root, args, info: root.resolve(self.field_name, args, info) + return resolve_fn(instance, args, info) + + @cached_property + def type(self): + field_type = self.field_type + _is_class = inspect.isclass(field_type) + if _is_class and issubclass(field_type, ObjectType): + field_type = field_type._meta.type + elif isinstance(field_type, Field): + field_type = field_type.type + elif field_type == 'self': + field_type = self.object_type._meta.type + field_type = self.type_wrapper(field_type) + + return field_type + + def type_wrapper(self, field_type): + if not self.null: + field_type = GraphQLNonNull(field_type) + return field_type + + @cached_property + def field(self): + if not self.field_type: + raise Exception('Must specify a field GraphQL type for the field %s'%self.field_name) + + if not self.object_type: + raise Exception('Field could not be constructed in a non graphene.Type or graphene.Interface') + + extra_args = self.extra_args.copy() + for arg_name, arg_value in extra_args.items(): + if isinstance(arg_value, GraphQLArgument): + self.args[arg_name] = arg_value + del extra_args[arg_name] + + if extra_args != {}: + raise TypeError("Field %s.%s initiated with invalid args: %s" % ( + self.object_type, + self.field_name, + ','.join(meta_attrs.keys()) + )) + + return GraphQLField( + self.type, + description=self.description, + args=self.args, + resolver=self.resolver, + ) + + def __str__(self): + """ Return "object_type.field_name". """ + return '%s.%s' % (self.object_type, self.field_name) + + def __repr__(self): + """ + Displays the module, class and name of the field. + """ + path = '%s.%s' % (self.__class__.__module__, self.__class__.__name__) + name = getattr(self, 'field_name', None) + if name is not None: + return '<%s: %s>' % (path, name) + return '<%s>' % path + + +class TypeField(Field): + def __init__(self, *args, **kwargs): + super(TypeField, self).__init__(self.field_type, *args, **kwargs) + + +class StringField(TypeField): + field_type = GraphQLString + + +class IntField(TypeField): + field_type = GraphQLInt + + +class BooleanField(TypeField): + field_type = GraphQLBoolean + + +class IDField(TypeField): + field_type = GraphQLID + + +class ListField(Field): + def type_wrapper(self, field_type): + return GraphQLList(field_type) + + +class NonNullField(Field): + def type_wrapper(self, field_type): + return GraphQLNonNull(field_type) diff --git a/graphene/core/options.py b/graphene/core/options.py new file mode 100644 index 00000000..f55cd494 --- /dev/null +++ b/graphene/core/options.py @@ -0,0 +1,69 @@ +from graphene.utils import cached_property + +DEFAULT_NAMES = ('description', 'name', 'interface', 'type_name', 'interfaces', 'proxy') + +class Options(object): + def __init__(self, meta=None): + self.meta = meta + self.local_fields = [] + self.interface = False + self.proxy = False + self.interfaces = [] + self.parents = [] + + def contribute_to_class(self, cls, name): + cls._meta = self + self.parent = cls + # First, construct the default values for these options. + self.object_name = cls.__name__ + self.type_name = self.object_name + + self.description = cls.__doc__ + # Store the original user-defined values for each option, + # for use when serializing the model definition + self.original_attrs = {} + + # Next, apply any overridden values from 'class Meta'. + if self.meta: + meta_attrs = self.meta.__dict__.copy() + for name in self.meta.__dict__: + # Ignore any private attributes that Django doesn't care about. + # NOTE: We can't modify a dictionary's contents while looping + # over it, so we loop over the *original* dictionary instead. + if name.startswith('_'): + del meta_attrs[name] + for attr_name in DEFAULT_NAMES: + if attr_name in meta_attrs: + setattr(self, attr_name, meta_attrs.pop(attr_name)) + self.original_attrs[attr_name] = getattr(self, attr_name) + elif hasattr(self.meta, attr_name): + setattr(self, attr_name, getattr(self.meta, attr_name)) + self.original_attrs[attr_name] = getattr(self, attr_name) + + # Any leftover attributes must be invalid. + if meta_attrs != {}: + raise TypeError("'class Meta' got invalid attribute(s): %s" % ','.join(meta_attrs.keys())) + + if self.interfaces != [] and self.interface: + raise Exception("A interface cannot inherit from interfaces") + + del self.meta + + def add_field(self, field): + self.local_fields.append(field) + setattr(self.parent, field.field_name, field) + + @cached_property + def fields(self): + fields = [] + for parent in self.parents: + fields.extend(parent._meta.fields) + return self.local_fields + fields + + @cached_property + def fields_map(self): + return {f.field_name:f for f in self.fields} + + @cached_property + def type(self): + return self.parent.get_graphql_type() diff --git a/graphene/core/types.py b/graphene/core/types.py new file mode 100644 index 00000000..250559e5 --- /dev/null +++ b/graphene/core/types.py @@ -0,0 +1,136 @@ +import inspect +import six + +from graphql.core.type import ( + GraphQLObjectType, + GraphQLInterfaceType, + GraphQLSchema +) +from graphql.core import graphql + +from graphene.core.options import Options + +class ObjectTypeMeta(type): + def __new__(cls, name, bases, attrs): + super_new = super(ObjectTypeMeta, cls).__new__ + parents = [b for b in bases if isinstance(b, ObjectTypeMeta)] + if not parents: + # If this isn't a subclass of Model, don't do anything special. + return super_new(cls, name, bases, attrs) + + module = attrs.pop('__module__') + doc = attrs.pop('__doc__', None) + new_class = super_new(cls, name, bases, {'__module__': module, '__doc__': doc}) + attr_meta = attrs.pop('Meta', None) + if not attr_meta: + meta = getattr(new_class, 'Meta', None) + else: + meta = attr_meta + base_meta = getattr(new_class, '_meta', None) + + new_class.add_to_class('_meta', Options(meta)) + if base_meta and base_meta.proxy: + new_class._meta.interface = base_meta.interface + # Add all attributes to the class. + for obj_name, obj in attrs.items(): + new_class.add_to_class(obj_name, obj) + + new_fields = new_class._meta.local_fields + field_names = {f.field_name for f in new_fields} + + for base in parents: + original_base = base + if not hasattr(base, '_meta'): + # Things without _meta aren't functional models, so they're + # uninteresting parents. + continue + + parent_fields = base._meta.local_fields + # Check for clashes between locally declared fields and those + # on the base classes (we cannot handle shadowed fields at the + # moment). + for field in parent_fields: + if field.field_name in field_names: + raise FieldError( + 'Local field %r in class %r clashes ' + 'with field of similar name from ' + 'base class %r' % (field.field_name, name, base.__name__) + ) + new_class._meta.parents.append(base) + if base._meta.interface: + new_class._meta.interfaces.append(base) + # new_class._meta.parents.extend(base._meta.parents) + + return new_class + + def add_to_class(cls, name, value): + # We should call the contribute_to_class method only if it's bound + if not inspect.isclass(value) and hasattr(value, 'contribute_to_class'): + value.contribute_to_class(cls, name) + else: + setattr(cls, name, value) + + +class ObjectType(six.with_metaclass(ObjectTypeMeta)): + def __init__(self, instance=None): + self.instance = instance + + def get_field(self, field): + return getattr(self.instance, field, None) + + def resolve(self, field_name, args, info): + if field_name not in self._meta.fields_map.keys(): + raise Exception('Field %s not found in model'%field_name) + custom_resolve_fn = 'resolve_%s'%field_name + if hasattr(self, custom_resolve_fn): + resolve_fn = getattr(self, custom_resolve_fn) + return resolve_fn(args, info) + return self.get_field(field_name) + + @classmethod + def can_resolve(cls, field_name, instance, args, info): + # Useful for manage permissions in fields + return True + + @classmethod + def resolve_type(cls, instance, *_): + return instance._meta.type + + @classmethod + def get_graphql_type(cls): + fields = cls._meta.fields_map + if cls._meta.interface: + return GraphQLInterfaceType( + cls._meta.type_name, + description=cls._meta.description, + resolve_type=cls.resolve_type, + fields=lambda: {name:field.field for name, field in fields.items()} + ) + return GraphQLObjectType( + cls._meta.type_name, + description=cls._meta.description, + interfaces=[i._meta.type for i in cls._meta.interfaces], + fields=lambda: {name:field.field for name, field in fields.items()} + ) + + +class Interface(ObjectType): + class Meta: + interface = True + proxy = True + + +class Schema(object): + def __init__(self, query, mutation=None): + self.query = query + self.query_type = query._meta.type + self._schema = GraphQLSchema(query=self.query_type, mutation=mutation) + + def execute(self, request='', root=None, vars=None, operation_name=None): + return graphql( + self._schema, + request=request, + root=root or self.query(), + vars=vars, + operation_name=operation_name + ) diff --git a/graphene/decorators.py b/graphene/decorators.py new file mode 100644 index 00000000..a3e6b335 --- /dev/null +++ b/graphene/decorators.py @@ -0,0 +1,8 @@ +from functools import wraps + + +def resolve_only_args(func): + @wraps(func) + def inner(self, args, info): + return func(self, **args) + return inner \ No newline at end of file diff --git a/graphene/relay/__init__.py b/graphene/relay/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/graphene/utils.py b/graphene/utils.py new file mode 100644 index 00000000..709b74a3 --- /dev/null +++ b/graphene/utils.py @@ -0,0 +1,16 @@ +class cached_property(object): + """ + A property that is only computed once per instance and then replaces itself + with an ordinary attribute. Deleting the attribute resets the property. + Source: https://github.com/bottlepy/bottle/commit/fa7733e075da0d790d809aa3d2f53071897e6f76 + """ # noqa + + def __init__(self, func): + self.__doc__ = getattr(func, '__doc__') + self.func = func + + def __get__(self, obj, cls): + if obj is None: + return self + value = obj.__dict__[self.func.__name__] = self.func(obj) + return value diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 00000000..15bcaf1b --- /dev/null +++ b/setup.cfg @@ -0,0 +1,3 @@ +[flake8] +exclude = tests/*,setup.py +max-line-length = 160 diff --git a/setup.py b/setup.py new file mode 100644 index 00000000..7925712f --- /dev/null +++ b/setup.py @@ -0,0 +1,62 @@ +import sys + +from setuptools import setup, find_packages +from setuptools.command.test import test as TestCommand + + +class PyTest(TestCommand): + user_options = [('pytest-args=', 'a', "Arguments to pass to py.test")] + + def initialize_options(self): + TestCommand.initialize_options(self) + self.pytest_args = [] + + def finalize_options(self): + TestCommand.finalize_options(self) + self.test_args = [] + self.test_suite = True + + def run_tests(self): + #import here, cause outside the eggs aren't loaded + import pytest + errno = pytest.main(self.pytest_args) + sys.exit(errno) + +setup( + name='graphene', + version='0.1', + + description='Graphene: GraphQL Object Mapper', + + url='https://github.com/syrusakbary/graphene', + + author='Syrus Akbary', + author_email='me@syrusakbary.com', + + license='MIT', + + classifiers=[ + 'Development Status :: 3 - Alpha', + 'Intended Audience :: Developers', + 'Topic :: Software Development :: Libraries', + 'Programming Language :: Python :: 2', + ], + + keywords='api graphql protocol rest relay graphene', + + packages=find_packages(exclude=['tests']), + + install_requires=[ + 'graphqllib', + 'graphql-relay' + ], + tests_require=['pytest>=2.7.2'], + extras_require={ + 'django': [ + 'Django>=1.8.0,<1.9', + 'singledispatch>=3.4.0.3', + ], + }, + + cmdclass={'test': PyTest}, +) diff --git a/tests/core/test_fields.py b/tests/core/test_fields.py new file mode 100644 index 00000000..18cb75e1 --- /dev/null +++ b/tests/core/test_fields.py @@ -0,0 +1,61 @@ +from py.test import raises +from collections import namedtuple +from pytest import raises +from graphene.core.fields import ( + Field, + StringField, +) + +from graphene.core.options import Options + +from graphql.core.type import ( + GraphQLField, + GraphQLNonNull, + GraphQLInt, + GraphQLString, + GraphQLBoolean, + GraphQLID, +) + +class ObjectType(object): + _meta = Options() + def resolve(self, *args, **kwargs): + return None + def can_resolve(self, *args): + return True + +ot = ObjectType() + +ObjectType._meta.contribute_to_class(ObjectType, '_meta') + +def test_field_no_contributed_raises_error(): + f = Field(GraphQLString) + with raises(Exception) as excinfo: + f.field + + +def test_field_type(): + f = Field(GraphQLString) + f.contribute_to_class(ot, 'field_name') + assert isinstance(f.field, GraphQLField) + assert f.type == GraphQLString + + +def test_stringfield_type(): + f = StringField() + f.contribute_to_class(ot, 'field_name') + assert f.type == GraphQLString + + +def test_stringfield_type_null(): + f = StringField(null=False) + f.contribute_to_class(ot, 'field_name') + assert isinstance(f.field, GraphQLField) + assert isinstance(f.type, GraphQLNonNull) + + +def test_field_resolve(): + f = StringField(null=False) + f.contribute_to_class(ot, 'field_name') + field_type = f.field + field_type.resolver(ot,2,3) diff --git a/tests/core/test_options.py b/tests/core/test_options.py new file mode 100644 index 00000000..cb27eba1 --- /dev/null +++ b/tests/core/test_options.py @@ -0,0 +1,67 @@ +from py.test import raises +from collections import namedtuple +from pytest import raises +from graphene.core.fields import ( + Field, + StringField, +) + +from graphene.core.options import Options + +class Meta: + interface = True + type_name = 'Character' + +class InvalidMeta: + other_value = True + +def test_field_added_in_meta(): + opt = Options(Meta) + + class ObjectType(object): + pass + + opt.contribute_to_class(ObjectType, '_meta') + f = StringField() + f.field_name = 'string_field' + opt.add_field(f) + assert f in opt.fields + +def test_options_contribute(): + opt = Options(Meta) + + class ObjectType(object): + pass + + opt.contribute_to_class(ObjectType, '_meta') + assert ObjectType._meta == opt + +def test_options_typename(): + opt = Options(Meta) + + class ObjectType(object): + pass + + opt.contribute_to_class(ObjectType, '_meta') + assert opt.type_name == 'Character' + +def test_options_description(): + opt = Options(Meta) + + class ObjectType(object): + '''False description''' + pass + + opt.contribute_to_class(ObjectType, '_meta') + assert opt.description == 'False description' + +def test_field_no_contributed_raises_error(): + opt = Options(InvalidMeta) + + class ObjectType(object): + pass + + with raises(Exception) as excinfo: + opt.contribute_to_class(ObjectType, '_meta') + + assert 'invalid attribute' in str(excinfo.value) diff --git a/tests/core/test_types.py b/tests/core/test_types.py new file mode 100644 index 00000000..e8d3ec69 --- /dev/null +++ b/tests/core/test_types.py @@ -0,0 +1,41 @@ +from py.test import raises +from collections import namedtuple +from pytest import raises +from graphene.core.fields import ( + Field, + StringField, +) +from graphql.core.type import ( + GraphQLObjectType, + GraphQLInterfaceType +) + +from graphene.core.types import ( + Interface, + ObjectType +) + +class Character(Interface): + '''Character description''' + name = StringField() + +class Human(Character): + '''Human description''' + friends = StringField() + +def test_interface(): + object_type = Character._meta.type + assert Character._meta.interface == True + assert Character._meta.type_name == 'Character' + assert isinstance(object_type, GraphQLInterfaceType) + assert object_type.description == 'Character description' + assert object_type.get_fields() == {'name': Character.name.field} + +def test_object_type(): + object_type = Human._meta.type + assert Human._meta.interface == False + assert Human._meta.type_name == 'Human' + assert isinstance(object_type, GraphQLObjectType) + assert object_type.description == 'Human description' + assert object_type.get_fields() == {'name': Character.name.field, 'friends': Human.friends.field} + assert object_type.get_interfaces() == [Character._meta.type] diff --git a/tests/starwars/__init__.py b/tests/starwars/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/starwars/data.py b/tests/starwars/data.py new file mode 100644 index 00000000..8b30cac6 --- /dev/null +++ b/tests/starwars/data.py @@ -0,0 +1,95 @@ +from collections import namedtuple + +Human = namedtuple('Human', 'id name friends appearsIn homePlanet') + +luke = Human( + id='1000', + name='Luke Skywalker', + friends=[ '1002', '1003', '2000', '2001' ], + appearsIn=[ 4, 5, 6 ], + homePlanet='Tatooine', +) + +vader = Human( + id='1001', + name='Darth Vader', + friends=[ '1004' ], + appearsIn=[ 4, 5, 6 ], + homePlanet='Tatooine', +) + +han = Human( + id='1002', + name='Han Solo', + friends=[ '1000', '1003', '2001' ], + appearsIn=[ 4, 5, 6 ], + homePlanet=None, +) + +leia = Human( + id='1003', + name='Leia Organa', + friends=[ '1000', '1002', '2000', '2001' ], + appearsIn=[ 4, 5, 6 ], + homePlanet='Alderaan', +) + +tarkin = Human( + id='1004', + name='Wilhuff Tarkin', + friends=[ '1001' ], + appearsIn=[ 4 ], + homePlanet=None, +) + +humanData = { + '1000': luke, + '1001': vader, + '1002': han, + '1003': leia, + '1004': tarkin, +} + +Droid = namedtuple('Droid', 'id name friends appearsIn primaryFunction') + +threepio = Droid( + id='2000', + name='C-3PO', + friends=[ '1000', '1002', '1003', '2001' ], + appearsIn=[ 4, 5, 6 ], + primaryFunction='Protocol', +) + +artoo = Droid( + id='2001', + name='R2-D2', + friends=[ '1000', '1002', '1003' ], + appearsIn=[ 4, 5, 6 ], + primaryFunction='Astromech', +) + +droidData = { + '2000': threepio, + '2001': artoo, +} + +def getCharacter(id): + return humanData.get(id) or droidData.get(id) + + +def getFriends(character): + return map(getCharacter, character.friends) + + +def getHero(episode): + if episode == 5: + return luke + return artoo + + +def getHuman(id): + return humanData.get(id) + + +def getDroid(id): + return droidData.get(id) diff --git a/tests/starwars/schema.py b/tests/starwars/schema.py new file mode 100644 index 00000000..a2edd318 --- /dev/null +++ b/tests/starwars/schema.py @@ -0,0 +1,69 @@ +import graphene +from graphene import resolve_only_args + +from .data import getHero, getHuman, getCharacter, getDroid, Human as _Human, Droid as _Droid + +from graphql.core.type import ( + GraphQLObjectType, + GraphQLField, + GraphQLString, + GraphQLNonNull, + GraphQLArgument +) + +Episode = graphene.Enum('Episode', dict( + NEWHOPE = 4, + EMPIRE = 5, + JEDI = 6 +)) + +def wrap_character(character): + if isinstance(character, _Human): + return Human(character) + elif isinstance(character, _Droid): + return Droid(character) + +class Character(graphene.Interface): + id = graphene.IDField() + name = graphene.StringField() + friends = graphene.ListField(graphene.Field('self')) + appearsIn = graphene.ListField(Episode) + + def resolve_friends(self, args, *_): + return [wrap_character(getCharacter(f)) for f in self.instance.friends] + +class Human(Character): + homePlanet = graphene.StringField() + + +class Droid(Character): + primaryFunction = graphene.StringField() + + +class Query(graphene.ObjectType): + hero = graphene.Field(Character, + episode = graphene.Argument(Episode) + ) + human = graphene.Field(Human, + id = graphene.Argument(graphene.String) + ) + droid = graphene.Field(Droid, + id = graphene.Argument(graphene.String) + ) + + @resolve_only_args + def resolve_hero(self, episode): + return wrap_character(getHero(episode)) + + @resolve_only_args + def resolve_human(self, id): + return wrap_character(getHuman(id)) + if human: + return Human(human) + + @resolve_only_args + def resolve_droid(self, id): + return wrap_character(getDroid(id)) + + +Schema = graphene.Schema(query=Query) diff --git a/tests/starwars/test_query.py b/tests/starwars/test_query.py new file mode 100644 index 00000000..500178e4 --- /dev/null +++ b/tests/starwars/test_query.py @@ -0,0 +1,345 @@ +from .schema import Schema, Query +from graphql.core import graphql + + +def test_hero_name_query(): + query = ''' + query HeroNameQuery { + hero { + name + } + } + ''' + expected = { + 'hero': { + 'name': 'R2-D2' + } + } + result = Schema.execute(query) + assert not result.errors + assert result.data == expected + + +def test_hero_name_and_friends_query(): + query = ''' + query HeroNameAndFriendsQuery { + hero { + id + name + friends { + name + } + } + } + ''' + expected = { + 'hero': { + 'id': '2001', + 'name': 'R2-D2', + 'friends': [ + {'name': 'Luke Skywalker'}, + {'name': 'Han Solo'}, + {'name': 'Leia Organa'}, + ] + } + } + result = Schema.execute(query) + assert not result.errors + assert result.data == expected + + +def test_nested_query(): + query = ''' + query NestedQuery { + hero { + name + friends { + name + appearsIn + friends { + name + } + } + } + } + ''' + expected = { + 'hero': { + 'name': 'R2-D2', + 'friends': [ + { + 'name': 'Luke Skywalker', + 'appearsIn': [ 'NEWHOPE', 'EMPIRE', 'JEDI' ], + 'friends': [ + { + 'name': 'Han Solo', + }, + { + 'name': 'Leia Organa', + }, + { + 'name': 'C-3PO', + }, + { + 'name': 'R2-D2', + }, + ] + }, + { + 'name': 'Han Solo', + 'appearsIn': [ 'NEWHOPE', 'EMPIRE', 'JEDI' ], + 'friends': [ + { + 'name': 'Luke Skywalker', + }, + { + 'name': 'Leia Organa', + }, + { + 'name': 'R2-D2', + }, + ] + }, + { + 'name': 'Leia Organa', + 'appearsIn': [ 'NEWHOPE', 'EMPIRE', 'JEDI' ], + 'friends': [ + { + 'name': 'Luke Skywalker', + }, + { + 'name': 'Han Solo', + }, + { + 'name': 'C-3PO', + }, + { + 'name': 'R2-D2', + }, + ] + }, + ] + } + } + result = Schema.execute(query) + assert not result.errors + assert result.data == expected + + +def test_fetch_luke_query(): + query = ''' + query FetchLukeQuery { + human(id: "1000") { + name + } + } + ''' + expected = { + 'human': { + 'name': 'Luke Skywalker', + } + } + result = Schema.execute(query) + assert not result.errors + assert result.data == expected + + +def test_fetch_some_id_query(): + query = ''' + query FetchSomeIDQuery($someId: String!) { + human(id: $someId) { + name + } + } + ''' + params = { + 'someId': '1000', + } + expected = { + 'human': { + 'name': 'Luke Skywalker', + } + } + result = Schema.execute(query, None, params) + assert not result.errors + assert result.data == expected + + +def test_fetch_some_id_query2(): + query = ''' + query FetchSomeIDQuery($someId: String!) { + human(id: $someId) { + name + } + } + ''' + params = { + 'someId': '1002', + } + expected = { + 'human': { + 'name': 'Han Solo', + } + } + result = Schema.execute(query, None, params) + assert not result.errors + assert result.data == expected + + +def test_invalid_id_query(): + query = ''' + query humanQuery($id: String!) { + human(id: $id) { + name + } + } + ''' + params = { + 'id': 'not a valid id', + } + expected = { + 'human': None + } + result = Schema.execute(query, None, params) + assert not result.errors + assert result.data == expected + + +def test_fetch_luke_aliased(): + query = ''' + query FetchLukeAliased { + luke: human(id: "1000") { + name + } + } + ''' + expected = { + 'luke': { + 'name': 'Luke Skywalker', + } + } + result = Schema.execute(query) + assert not result.errors + assert result.data == expected + + +def test_fetch_luke_and_leia_aliased(): + query = ''' + query FetchLukeAndLeiaAliased { + luke: human(id: "1000") { + name + } + leia: human(id: "1003") { + name + } + } + ''' + expected = { + 'luke': { + 'name': 'Luke Skywalker', + }, + 'leia': { + 'name': 'Leia Organa', + } + } + result = Schema.execute(query) + assert not result.errors + assert result.data == expected + + +def test_duplicate_fields(): + query = ''' + query DuplicateFields { + luke: human(id: "1000") { + name + homePlanet + } + leia: human(id: "1003") { + name + homePlanet + } + } + ''' + expected = { + 'luke': { + 'name': 'Luke Skywalker', + 'homePlanet': 'Tatooine', + }, + 'leia': { + 'name': 'Leia Organa', + 'homePlanet': 'Alderaan', + } + } + result = Schema.execute(query) + assert not result.errors + assert result.data == expected + + +def test_use_fragment(): + query = ''' + query UseFragment { + luke: human(id: "1000") { + ...HumanFragment + } + leia: human(id: "1003") { + ...HumanFragment + } + } + fragment HumanFragment on Human { + name + homePlanet + } + ''' + expected = { + 'luke': { + 'name': 'Luke Skywalker', + 'homePlanet': 'Tatooine', + }, + 'leia': { + 'name': 'Leia Organa', + 'homePlanet': 'Alderaan', + } + } + result = Schema.execute(query) + assert not result.errors + assert result.data == expected + + +def test_check_type_of_r2(): + query = ''' + query CheckTypeOfR2 { + hero { + __typename + name + } + } + ''' + expected = { + 'hero': { + '__typename': 'Droid', + 'name': 'R2-D2', + } + } + result = Schema.execute(query) + assert not result.errors + assert result.data == expected + + +def test_check_type_of_luke(): + query = ''' + query CheckTypeOfLuke { + hero(episode: EMPIRE) { + __typename + name + } + } + ''' + expected = { + 'hero': { + '__typename': 'Human', + 'name': 'Luke Skywalker', + } + } + result = Schema.execute(query) + assert not result.errors + assert result.data == expected diff --git a/tox.ini b/tox.ini new file mode 100644 index 00000000..09e27276 --- /dev/null +++ b/tox.ini @@ -0,0 +1,12 @@ +[tox] +envlist = py27 + +[testenv] +deps= + pytest>=2.7.2 + django>=1.8.0,<1.9 + flake8 + singledispatch +commands= + py.test + flake8