From 663a3a9b0272df4c45f2b2b7ce2bf04e65b828dd Mon Sep 17 00:00:00 2001 From: Thomas Leonard Date: Fri, 23 Oct 2020 16:54:23 +0200 Subject: [PATCH 01/15] Fix type name and decorator bug --- graphene_federation/entity.py | 4 +- graphene_federation/extend.py | 4 +- graphene_federation/service.py | 122 ++++++++++++++++++++------------- 3 files changed, 77 insertions(+), 53 deletions(-) diff --git a/graphene_federation/entity.py b/graphene_federation/entity.py index eea8e44..d536297 100644 --- a/graphene_federation/entity.py +++ b/graphene_federation/entity.py @@ -40,7 +40,7 @@ def resolve_entities(parent, info, representations): try: resolver = getattr( - model, "_%s__resolve_reference" % representation["__typename"]) + model, "_%s__resolve_reference" % model.__name__) except AttributeError: pass else: @@ -54,7 +54,7 @@ def resolve_entities(parent, info, representations): def key(fields: str): def decorator(Type): - register_entity(Type.__name__, Type) + register_entity(Type._meta.name, Type) existing = getattr(Type, "_sdl", "") diff --git a/graphene_federation/extend.py b/graphene_federation/extend.py index c60ddf1..b13a567 100644 --- a/graphene_federation/extend.py +++ b/graphene_federation/extend.py @@ -10,8 +10,8 @@ def extend(fields: str): def decorator(Type): if hasattr(Type, '_sdl'): raise RuntimeError("Can't extend type which is already extended or has @key") - register_entity(Type.__name__, Type) - register_extend_type(Type.__name__, Type) + register_entity(Type._meta.name, Type) + register_extend_type(Type._meta.name, Type) setattr(Type, '_sdl', '@key(fields: "%s")' % fields) return Type return decorator diff --git a/graphene_federation/service.py b/graphene_federation/service.py index f0dbe96..0b0137f 100644 --- a/graphene_federation/service.py +++ b/graphene_federation/service.py @@ -1,83 +1,107 @@ import re -from graphene import ObjectType, String, Field -from graphene.utils.str_converters import to_camel_case - -from graphene_federation.extend import extended_types -from graphene_federation.provides import provides_parent_types -from .entity import custom_entities +from typing import Any, Dict +from packaging import version -def _mark_field( - entity_name, entity, schema: str, mark_attr_name: str, - decorator_resolver: callable, auto_camelcase: bool -): - for field_name in dir(entity): - field = getattr(entity, field_name, None) - if field is not None and getattr(field, mark_attr_name, None): - # todo write tests on regexp - schema_field_name = to_camel_case(field_name) if auto_camelcase else field_name - pattern = re.compile( - r"(type\s%s\s[^\{]*\{[^\}]*\s%s[\s]*:[\s]*[^\s]+)(\s)" % ( - entity_name, schema_field_name)) - schema = pattern.sub( - rf'\g<1> {decorator_resolver(getattr(field, mark_attr_name))} ', schema) +import graphene +from graphene import ObjectType, String, Field, Schema, __version__ as graphene_version - return schema +if version.parse(graphene_version) < version.parse('3.0.0'): + from graphql.utils.schema_printer import _print_fields as print_fields +else: + from graphql.utilities.print_schema import print_fields as print_fields +from graphene_federation.extend import extended_types +from graphene_federation.provides import provides_parent_types -def _mark_external(entity_name, entity, schema, auto_camelcase): - return _mark_field( - entity_name, entity, schema, '_external', lambda _: '@external', auto_camelcase) +from .entity import custom_entities -def _mark_requires(entity_name, entity, schema, auto_camelcase): - return _mark_field( - entity_name, entity, schema, '_requires', lambda fields: f'@requires(fields: "{fields}")', - auto_camelcase +class MonoFieldType: + """ + In order to be able to reuse the `print_fields` method to get a singular field + string definition, we need to define an object that has a `.fields` attribute. + """ + def __init__(self, name, field): + self.fields = { + name: field + } + +DECORATORS = { + "_external": lambda _: "@external", + "_requires": lambda fields: f'@requires(fields: "{fields}")', + "_provides": lambda fields: f'@provides(fields: "{fields}")', +} + +def add_entity_fields_decorators(entity, schema: Schema, string_schema: str) -> str: + """ + For a given entity, go through all its field and see if any directive decorator need to be added. + The methods (from graphene-federation) marking fields that require some special treatment for federation add + corresponding attributes to the field itself. + Those attributes are listed in the `DECORATORS` variable as key and their respective value is the resolver that + returns what needs to be amended to the field declaration. + + This method simply go through the field that need to be modified and replace them with their annotated version in the + schema string representation. + """ + entity_name = entity._meta.name + entity_type = schema._type_map[entity_name] + str_fields = [] + for name, field in entity_type.fields.items(): + str_field = print_fields(MonoFieldType(name, field)) + f = getattr(entity, name, None) + if f is not None: + for decorator, decorator_resolver in DECORATORS.items(): + decorator_value = getattr(f, decorator, None) + if decorator_value: + str_field += f" {decorator_resolver(decorator_value)}" + str_fields.append(str_field) + str_fields_annotated = "\n".join(str_fields) + # Replace the original field declaration by the annotated one + str_fields_original = print_fields(entity_type) + pattern = re.compile( + r"(type\s%s\s[^\{]*)\{\s*%s\s*\}" % ( + entity_name, re.escape(str_fields_original) + ) ) - - -def _mark_provides(entity_name, entity, schema, auto_camelcase): - return _mark_field( - entity_name, entity, schema, '_provides', lambda fields: f'@provides(fields: "{fields}")', - auto_camelcase + string_schema = pattern.sub( + r"\g<1> {\n%s\n}" % str_fields_annotated, + string_schema ) -def get_sdl(schema, custom_entities): +def get_sdl(schema: Schema, custom_entities: Dict[str, Any]) -> str: + """ + Add all needed decorators to the string representation of the schema. + """ string_schema = str(schema) - string_schema = string_schema.replace("\n", " ") regex = r"schema \{(\w|\!|\s|\:)*\}" pattern = re.compile(regex) string_schema = pattern.sub(" ", string_schema) + # Add entity sdl for entity_name, entity in custom_entities.items(): type_def_re = r"(type %s [^\{]*)" % entity_name repl_str = r"\1 %s " % entity._sdl pattern = re.compile(type_def_re) string_schema = pattern.sub(repl_str, string_schema) - for entity in provides_parent_types: - string_schema = _mark_provides( - entity.__name__, entity, string_schema, schema.auto_camelcase) + # Add fields directives (@external, @provides, @requires) + for entity in provides_parent_types | set(extended_types.values()): + add_entity_fields_decorators(entity, schema, string_schema) + # Prepend `extend` keyword to the type definition of extended types for entity_name, entity in extended_types.items(): - string_schema = _mark_external(entity_name, entity, string_schema, schema.auto_camelcase) - string_schema = _mark_requires(entity_name, entity, string_schema, schema.auto_camelcase) - - type_def_re = r"type %s ([^\{]*)" % entity_name - type_def = r"type %s " % entity_name - repl_str = r"extend %s \1" % type_def - pattern = re.compile(type_def_re) - - string_schema = pattern.sub(repl_str, string_schema) + type_def = re.compile(r"type %s ([^\{]*)" % entity_name) + repl_str = r"extend type %s \1" % entity_name + string_schema = type_def.sub(repl_str, string_schema) return string_schema -def get_service_query(schema): +def get_service_query(schema: Schema): sdl_str = get_sdl(schema, custom_entities) class _Service(ObjectType): From 7d88073e83e80bd9beac78fdfc188bad367d8c96 Mon Sep 17 00:00:00 2001 From: Thomas Leonard Date: Fri, 23 Oct 2020 16:54:32 +0200 Subject: [PATCH 02/15] Add some test (wip) --- Dockerfile | 14 ++ Makefile | 22 ++- docker-compose.yml | 9 + graphene_federation/tests/__init__.py | 0 .../tests/test_schema_annotation.py | 172 ++++++++++++++++++ setup.py | 36 ++-- 6 files changed, 240 insertions(+), 13 deletions(-) create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 graphene_federation/tests/__init__.py create mode 100644 graphene_federation/tests/test_schema_annotation.py diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..57781ee --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.8-slim + +# Disable Python buffering in order to see the logs immediatly +ENV PYTHONUNBUFFERED=1 + +# Set the default working directory +WORKDIR /workdir + +COPY . /workdir + +# Install dependencies +RUN pip install -e ".[dev]" + +CMD tail -f /dev/null diff --git a/Makefile b/Makefile index 0660860..69b16d3 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,23 @@ -build: +# ------------------------- +# Integration testing +# ------------------------- + +.PHONY: integration-build ## Build environment for integration tests +integration-build: cd integration_tests && docker-compose build -test: +.PHONY: integration-test ## Run integration tests +integration-test: cd integration_tests && docker-compose down && docker-compose run --rm tests + +# ------------------------- +# Development and unit testing +# ------------------------- + +.PHONY: dev-setup ## Install development dependencies +dev-setup: + docker-compose up -d + +.PHONY: tests ## Run unit tests +tests: + docker-compose run graphene_federation py.test graphene_federation --cov=graphene_federation -vv diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..673d47b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,9 @@ +version: '3.5' + +services: + + graphene_federation: + build: + context: . + volumes: + - ./:/workdir diff --git a/graphene_federation/tests/__init__.py b/graphene_federation/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/graphene_federation/tests/test_schema_annotation.py b/graphene_federation/tests/test_schema_annotation.py new file mode 100644 index 0000000..af9ebaf --- /dev/null +++ b/graphene_federation/tests/test_schema_annotation.py @@ -0,0 +1,172 @@ +from graphql import graphql + +from graphene import ObjectType, ID, String, NonNull, Field + +from ..entity import key +from ..extend import extend, external +from ..main import build_schema + +# ------------------------ +# User service +# ------------------------ +# users = [ +# {"user_id": "1", "name": "Jane", "email": "jane@mail.com"}, +# {"user_id": "2", "name": "Jack", "email": "jack@mail.com"}, +# {"user_id": "3", "name": "Mary", "email": "mary@mail.com"}, +# ] + +# @key("user_id") +# @key("email") +# class User(ObjectType): +# user_id = ID(required=True) +# email = String(required=True) +# name = String() + +# def __resolve_reference(self, info, *args, **kwargs): +# if self.id: +# user = next(filter(lambda x: x["id"] == self.id, users)) +# elif self.email: +# user = next(filter(lambda x: x["email"] == self.email, users)) +# return User(**user) + +# class UserQuery(ObjectType): +# user = Field(User, user_id=ID(required=True)) + +# def resolve_user(self, info, user_id, *args, **kwargs): +# return User(**next(filter(lambda x: x["user_id"] == user_id, users))) + +# user_schema = build_schema(query=UserQuery) + +# ------------------------ +# Chat service +# ------------------------ +chat_messages = [ + {"id": "1", "user_id": "1", "text": "Hi"}, + {"id": "2", "user_id": "1", "text": "How is the weather?"}, + {"id": "3", "user_id": "2", "text": "Who are you"}, + {"id": "4", "user_id": "3", "text": "Don't be rude Jack"}, + {"id": "5", "user_id": "3", "text": "Hi Jane"}, + {"id": "6", "user_id": "2", "text": "Sorry but weather sucks so I am upset"}, +] + +@extend("user_id") +class ChatUser(ObjectType): + user_id = external(ID(required=True)) + +class ChatMessage(ObjectType): + id = ID(required=True) + text = String() + user_id = ID() + user = NonNull(ChatUser) + + def resolve_user(self, info, *args, **kwargs): + return ChatUser(user_id=self.user_id) + +class ChatQuery(ObjectType): + message = Field(ChatMessage, id=ID(required=True)) + + def resolve_user(self, info, id, *args, **kwargs): + return ChatMessage(**next(filter(lambda x: x["id"] == id, chat_messages))) + +chat_schema = build_schema(query=ChatQuery) + +# ------------------------ +# Tests +# ------------------------ + +def xtest_user_schema(): + """ + Check that the user schema has been annotated correctly + and that a request to retrieve a user works. + """ + assert str(user_schema) == """schema { + query: Query +} + +type Query { + user(userId: ID!): User + _entities(representations: [_Any]): [_Entity] + _service: _Service +} + +type User { + userId: ID! + email: String! + name: String +} + +scalar _Any + +union _Entity = User + +type _Service { + sdl: String +} +""" + query = """ + query { + user(userId: "2") { + name + } + } + """ + result = graphql(user_schema, query) + assert not result.errors + assert result.data == {"user": {"name": "Jack"}} + +def test_chat_schema(): + """ + Check that the chat schema has been annotated correctly + and that a request to retrieve a chat message works. + """ + assert str(chat_schema) == """schema { + query: Query +} + +type ChatMessage { + id: ID! + text: String + userId: ID + user: ChatUser! +} + +type ChatUser { + userId: ID! +} + +type Query { + message(id: ID!): ChatMessage + _entities(representations: [_Any]): [_Entity] + _service: _Service +} + +scalar _Any + +union _Entity = ChatUser + +type _Service { + sdl: String +} +""" + query = """ + query { + message(id: "4") { + name + userId + } + } + """ + result = graphql(chat_schema, query) + assert not result.errors + assert result.data == {"message": {"text": "Don't be rude Jack", "userId": "3"}} + + + +# Test type with external field also used in a connection +# Test type with 2 similar looking names +# test camel case situation +# test basic case for extend/external/provides + +# Add unit testing +# Add linting (black, flake) +# Add typing diff --git a/setup.py b/setup.py index 16c9ee0..d8a615a 100644 --- a/setup.py +++ b/setup.py @@ -1,5 +1,5 @@ import os -from setuptools import setup +from setuptools import find_packages, setup def read(*rnames): @@ -7,26 +7,40 @@ def read(*rnames): version = '0.1.0' +tests_require = [ + "pytest==6.1.1", + "pytest-cov", +] + +dev_require = [ + "black==20.8b1", + "flake8==3.8.4", +] + tests_require + setup( - name = 'graphene-federation', - packages = ['graphene_federation'], - version = version, + name='graphene-federation', + packages=find_packages(exclude=["tests"]), + version=version, license='MIT', description = 'Federation implementation for graphene', long_description=(read('README.md')), long_description_content_type='text/markdown', - author = 'Igor Kasianov', - author_email = 'super.hang.glider@gmail.com', - url = 'https://github.com/preply/graphene-federation', - download_url = f'https://github.com/preply/graphene-federation/archive/{version}.tar.gz', - keywords = ['graphene', 'gql', 'federation'], + author='Igor Kasianov', + author_email='super.hang.glider@gmail.com', + url='https://github.com/preply/graphene-federation', + download_url=f'https://github.com/preply/graphene-federation/archive/{version}.tar.gz', + keywords=['graphene', 'gql', 'federation'], install_requires=[ - "graphene>=2.1.0,<3" - ], + "graphene>=2.1.0,<3" + ], classifiers=[ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Topic :: Software Development :: Libraries", "Programming Language :: Python :: 3.6", ], + extras_require={ + "test": tests_require, + "dev": dev_require, + }, ) From 4e4a51aaf9ab6963483c57e13340a7e75cb92bcb Mon Sep 17 00:00:00 2001 From: Thomas Leonard Date: Mon, 26 Oct 2020 14:53:35 +0100 Subject: [PATCH 03/15] Get rid of global variables --- graphene_federation/entity.py | 67 ++++++++++++------- graphene_federation/extend.py | 36 +++++++--- graphene_federation/main.py | 4 +- graphene_federation/provides.py | 21 +++++- graphene_federation/service.py | 36 ++++++---- .../tests/test_schema_annotation.py | 60 ++++++++--------- 6 files changed, 143 insertions(+), 81 deletions(-) diff --git a/graphene_federation/entity.py b/graphene_federation/entity.py index d536297..6e5e026 100644 --- a/graphene_federation/entity.py +++ b/graphene_federation/entity.py @@ -1,42 +1,63 @@ -from graphene import List, Union -from graphene.utils.str_converters import to_snake_case +from typing import Any, Dict + +from graphene import List, Union, Schema +from graphene.utils.str_converters import to_camel_case import graphene from .types import _Any -custom_entities = {} - - -def register_entity(typename, Type): - custom_entities[typename] = Type - - -def get_entity_cls(): +def get_entities(schema: Schema) -> Dict[str, Any]: + """ + Find all the entities from the schema. + They can be easily distinguished from the other type as + the `@key` and `@extend` decorators adds a `_sdl` attribute to them. + """ + entities = {} + for type_name, type_ in schema._type_map.items(): + if not hasattr(type_, "graphene_type"): + continue + if getattr(type_.graphene_type, "_sdl", None): + entities[type_name] = type_.graphene_type + return entities + + +def get_entity_cls(entities: Dict[str, Any]): + """ + Create _Entity type which is a union of all the entities types. + """ class _Entity(Union): class Meta: - types = tuple(custom_entities.values()) + types = tuple(entities.values()) return _Entity -def get_entity_query(auto_camelcase): - if not custom_entities: +def get_entity_query(schema: Schema): + """ + Create Entity query. + """ + entities_dict = get_entities(schema) + if not entities_dict: return + entity_type = get_entity_cls(entities_dict) + class EntityQuery: - entities = graphene.List(get_entity_cls(), name="_entities", representations=List(_Any)) + entities = graphene.List(entity_type, name="_entities", representations=List(_Any)) - def resolve_entities(parent, info, representations): + def resolve_entities(self, info, representations): entities = [] for representation in representations: - model = custom_entities[representation["__typename"]] - model_aguments = representation.copy() - model_aguments.pop("__typename") - # todo use schema to identify correct mapping for field names - if auto_camelcase: - model_aguments = {to_snake_case(k): v for k, v in model_aguments.items()} - model_instance = model(**model_aguments) + type_ = schema.get_type(representation["__typename"]) + model = type_.graphene_type + model_arguments = representation.copy() + model_arguments.pop("__typename") + if schema.auto_camelcase: + # Create field name conversion dict (from schema name to actual graphene_type field name) + field_names = {to_camel_case(name): name for name in model._meta.fields} + model_arguments = {field_names[k]: v for k, v in model_arguments.items()} + model_instance = model(**model_arguments) try: resolver = getattr( @@ -54,8 +75,6 @@ def resolve_entities(parent, info, representations): def key(fields: str): def decorator(Type): - register_entity(Type._meta.name, Type) - existing = getattr(Type, "_sdl", "") key_sdl = f'@key(fields: "{fields}")' diff --git a/graphene_federation/extend.py b/graphene_federation/extend.py index b13a567..667cced 100644 --- a/graphene_federation/extend.py +++ b/graphene_federation/extend.py @@ -1,23 +1,43 @@ -from .entity import register_entity -extended_types = {} +from typing import Any, Dict +from graphene import Schema -def register_extend_type(typename, Type): - extended_types[typename] = Type + +def get_extended_types(schema: Schema) -> Dict[str, Any]: + """ + Find all the extended types from the schema. + They can be easily distinguished from the other type as + the `@extend` decorator adds a `_extended` attribute to them. + """ + extended_types = {} + for type_name, type_ in schema._type_map.items(): + if not hasattr(type_, "graphene_type"): + continue + if getattr(type_.graphene_type, "_extended", False): + extended_types[type_name] = type_.graphene_type + return extended_types def extend(fields: str): + """ + Decorator to use to extend a given type. + The fields to extend must be provided as input. + """ def decorator(Type): - if hasattr(Type, '_sdl'): + if hasattr(Type, "_sdl"): raise RuntimeError("Can't extend type which is already extended or has @key") - register_entity(Type._meta.name, Type) - register_extend_type(Type._meta.name, Type) - setattr(Type, '_sdl', '@key(fields: "%s")' % fields) + # Set a `_sdl` attribute so it will be registered as an entity + setattr(Type, "_sdl", '@key(fields: "%s")' % fields) + # Set a `_extended` attribute to be able to distinguish it from the other entities + setattr(Type, "_extended", True) return Type return decorator def external(field): + """ + Mark a field as external. + """ field._external = True return field diff --git a/graphene_federation/main.py b/graphene_federation/main.py index 14312ac..52c384e 100644 --- a/graphene_federation/main.py +++ b/graphene_federation/main.py @@ -1,12 +1,12 @@ import graphene -from .entity import get_entity_query, register_entity +from .entity import get_entity_query from .service import get_service_query def _get_query(schema, query_cls=None): bases = [get_service_query(schema)] - entity_cls = get_entity_query(schema.auto_camelcase) + entity_cls = get_entity_query(schema) if entity_cls: bases.append(entity_cls) if query_cls is not None: diff --git a/graphene_federation/provides.py b/graphene_federation/provides.py index 7125074..67e2394 100644 --- a/graphene_federation/provides.py +++ b/graphene_federation/provides.py @@ -1,6 +1,21 @@ -from graphene import Field +from typing import Any, Dict -provides_parent_types = set() +from graphene import Field, Schema + + +def get_provides_parent_types(schema: Schema) -> Dict[str, Any]: + """ + Find all the types for which a field is provided from the schema. + They can be easily distinguished from the other type as + the `@provides` decorator used on the type itself adds a `_provide_parent_type` attribute to them. + """ + provides_parent_types = {} + for type_name, type_ in schema._type_map.items(): + if not hasattr(type_, "graphene_type"): + continue + if getattr(type_.graphene_type, "_provide_parent_type", False): + provides_parent_types[type_name] = type_.graphene_type + return provides_parent_types def provides(field, fields: str = None): @@ -13,7 +28,7 @@ def provides(field, fields: str = None): if fields is None: # used as decorator on base type if isinstance(field, Field): raise RuntimeError("Please specify fields") - provides_parent_types.add(field) + field._provide_parent_type = True else: # used as wrapper over field field._provides = fields return field diff --git a/graphene_federation/service.py b/graphene_federation/service.py index 0b0137f..ddddcc7 100644 --- a/graphene_federation/service.py +++ b/graphene_federation/service.py @@ -12,10 +12,10 @@ else: from graphql.utilities.print_schema import print_fields as print_fields -from graphene_federation.extend import extended_types -from graphene_federation.provides import provides_parent_types +from graphene_federation.extend import get_extended_types +from graphene_federation.provides import get_provides_parent_types -from .entity import custom_entities +from .entity import get_entities class MonoFieldType: @@ -46,7 +46,7 @@ def add_entity_fields_decorators(entity, schema: Schema, string_schema: str) -> schema string representation. """ entity_name = entity._meta.name - entity_type = schema._type_map[entity_name] + entity_type = schema.get_type(entity_name) str_fields = [] for name, field in entity_type.fields.items(): str_field = print_fields(MonoFieldType(name, field)) @@ -65,13 +65,15 @@ def add_entity_fields_decorators(entity, schema: Schema, string_schema: str) -> entity_name, re.escape(str_fields_original) ) ) + string_schema_original = string_schema + "" string_schema = pattern.sub( r"\g<1> {\n%s\n}" % str_fields_annotated, string_schema ) + return string_schema -def get_sdl(schema: Schema, custom_entities: Dict[str, Any]) -> str: +def get_sdl(schema: Schema) -> str: """ Add all needed decorators to the string representation of the schema. """ @@ -81,16 +83,14 @@ def get_sdl(schema: Schema, custom_entities: Dict[str, Any]) -> str: pattern = re.compile(regex) string_schema = pattern.sub(" ", string_schema) - # Add entity sdl - for entity_name, entity in custom_entities.items(): - type_def_re = r"(type %s [^\{]*)" % entity_name - repl_str = r"\1 %s " % entity._sdl - pattern = re.compile(type_def_re) - string_schema = pattern.sub(repl_str, string_schema) + # Get various objects that need to be amended + extended_types = get_extended_types(schema) + provides_parent_types = get_provides_parent_types(schema) + entities = get_entities(schema) # Add fields directives (@external, @provides, @requires) - for entity in provides_parent_types | set(extended_types.values()): - add_entity_fields_decorators(entity, schema, string_schema) + for entity in set(provides_parent_types.values()) | set(extended_types.values()): + string_schema = add_entity_fields_decorators(entity, schema, string_schema) # Prepend `extend` keyword to the type definition of extended types for entity_name, entity in extended_types.items(): @@ -98,11 +98,19 @@ def get_sdl(schema: Schema, custom_entities: Dict[str, Any]) -> str: repl_str = r"extend type %s \1" % entity_name string_schema = type_def.sub(repl_str, string_schema) + # Add entity sdl + for entity_name, entity in entities.items(): + type_def_re = r"(type %s [^\{]*)" % entity_name + repl_str = r"\1 %s " % entity._sdl + pattern = re.compile(type_def_re) + string_schema = pattern.sub(repl_str, string_schema) + + # print(string_schema) return string_schema def get_service_query(schema: Schema): - sdl_str = get_sdl(schema, custom_entities) + sdl_str = get_sdl(schema) class _Service(ObjectType): sdl = String() diff --git a/graphene_federation/tests/test_schema_annotation.py b/graphene_federation/tests/test_schema_annotation.py index af9ebaf..df9af16 100644 --- a/graphene_federation/tests/test_schema_annotation.py +++ b/graphene_federation/tests/test_schema_annotation.py @@ -9,33 +9,33 @@ # ------------------------ # User service # ------------------------ -# users = [ -# {"user_id": "1", "name": "Jane", "email": "jane@mail.com"}, -# {"user_id": "2", "name": "Jack", "email": "jack@mail.com"}, -# {"user_id": "3", "name": "Mary", "email": "mary@mail.com"}, -# ] - -# @key("user_id") -# @key("email") -# class User(ObjectType): -# user_id = ID(required=True) -# email = String(required=True) -# name = String() - -# def __resolve_reference(self, info, *args, **kwargs): -# if self.id: -# user = next(filter(lambda x: x["id"] == self.id, users)) -# elif self.email: -# user = next(filter(lambda x: x["email"] == self.email, users)) -# return User(**user) - -# class UserQuery(ObjectType): -# user = Field(User, user_id=ID(required=True)) - -# def resolve_user(self, info, user_id, *args, **kwargs): -# return User(**next(filter(lambda x: x["user_id"] == user_id, users))) - -# user_schema = build_schema(query=UserQuery) +users = [ + {"user_id": "1", "name": "Jane", "email": "jane@mail.com"}, + {"user_id": "2", "name": "Jack", "email": "jack@mail.com"}, + {"user_id": "3", "name": "Mary", "email": "mary@mail.com"}, +] + +@key("user_id") +@key("email") +class User(ObjectType): + user_id = ID(required=True) + email = String(required=True) + name = String() + + def __resolve_reference(self, info, *args, **kwargs): + if self.id: + user = next(filter(lambda x: x["id"] == self.id, users)) + elif self.email: + user = next(filter(lambda x: x["email"] == self.email, users)) + return User(**user) + +class UserQuery(ObjectType): + user = Field(User, user_id=ID(required=True)) + + def resolve_user(self, info, user_id, *args, **kwargs): + return User(**next(filter(lambda x: x["user_id"] == user_id, users))) + +user_schema = build_schema(query=UserQuery) # ------------------------ # Chat service @@ -65,7 +65,7 @@ def resolve_user(self, info, *args, **kwargs): class ChatQuery(ObjectType): message = Field(ChatMessage, id=ID(required=True)) - def resolve_user(self, info, id, *args, **kwargs): + def resolve_message(self, info, id, *args, **kwargs): return ChatMessage(**next(filter(lambda x: x["id"] == id, chat_messages))) chat_schema = build_schema(query=ChatQuery) @@ -74,7 +74,7 @@ def resolve_user(self, info, id, *args, **kwargs): # Tests # ------------------------ -def xtest_user_schema(): +def test_user_schema(): """ Check that the user schema has been annotated correctly and that a request to retrieve a user works. @@ -151,7 +151,7 @@ def test_chat_schema(): query = """ query { message(id: "4") { - name + text userId } } From 618017f8d735afb680a92fa5dd1f0c043ec6f910 Mon Sep 17 00:00:00 2001 From: Thomas Leonard Date: Tue, 27 Oct 2020 14:12:26 +0100 Subject: [PATCH 04/15] Add unit tests, linting and fix some minor errors --- .circleci/config.yml | 26 +- README.md | 16 +- graphene_federation/entity.py | 44 ++- graphene_federation/extend.py | 33 +- graphene_federation/main.py | 2 +- graphene_federation/provides.py | 10 +- graphene_federation/service.py | 52 +-- .../tests/test_annotation_corner_cases.py | 325 ++++++++++++++++++ graphene_federation/tests/test_entity.py | 1 + graphene_federation/tests/test_extend.py | 33 ++ graphene_federation/tests/test_key.py | 102 ++++++ graphene_federation/tests/test_provides.py | 256 ++++++++++++++ graphene_federation/tests/test_requires.py | 232 +++++++++++++ .../tests/test_schema_annotation.py | 79 ++++- graphene_federation/types.py | 2 +- graphene_federation/utils.py | 28 ++ 16 files changed, 1166 insertions(+), 75 deletions(-) create mode 100644 graphene_federation/tests/test_annotation_corner_cases.py create mode 100644 graphene_federation/tests/test_entity.py create mode 100644 graphene_federation/tests/test_extend.py create mode 100644 graphene_federation/tests/test_key.py create mode 100644 graphene_federation/tests/test_provides.py create mode 100644 graphene_federation/tests/test_requires.py create mode 100644 graphene_federation/utils.py diff --git a/.circleci/config.yml b/.circleci/config.yml index 275ac92..057aa66 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -11,14 +11,22 @@ jobs: steps: - checkout - - run: - name: Build - command: make build + - run: + name: Linting + command: black graphene_federation --check - - run: - name: Tests - command: make test + - run: + name: Unit Tests + command: make tests - - store_artifacts: - path: test-reports - destination: test-reports \ No newline at end of file + - run: + name: Integration Build + command: make integration-build + + - run: + name: Integration Tests + command: make integration-test + + - store_artifacts: + path: test-reports + destination: test-reports diff --git a/README.md b/README.md index a9f1e37..de70992 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Federation support for graphene Build: [![CircleCI](https://circleci.com/gh/preply/graphene-federation.svg?style=svg)](https://circleci.com/gh/preply/graphene-federation) -Federation specs implementation on top of Python graphene lib +Federation specs implementation on top of Python graphene lib https://www.apollographql.com/docs/apollo-server/federation/federation-spec/ Based on discussion: https://github.com/graphql-python/graphene/issues/953#issuecomment-508481652 @@ -19,21 +19,21 @@ Supports now: class User(ObjectType): id = Int(required=True) email = String() - + def __resolve_reference(self, info, **kwargs): if self.id is not None: return User(id=self.id, email=f'name_{self.id}@gmail.com') - return User(id=123, email=self.email) + return User(id=123, email=self.email) ``` * extend # extend remote types -* external # mark field as external +* external # mark field as external * requires # mark that field resolver requires other fields to be pre-fetched -* provides # to annotate the expected returned fieldset from a field on a base type that is guaranteed to be selectable by the gateway. +* provides # to annotate the expected returned fieldset from a field on a base type that is guaranteed to be selectable by the gateway. * **Base class should be decorated with `@provides`** as well as field on a base type that provides. Check example bellow: ```python import graphene from graphene_federation import provides - + @provides class ArticleThatProvideAuthorAge(graphene.ObjectType): id = Int(required=True) @@ -102,7 +102,7 @@ If not explicitly defined, default resolver is used. Default resolver just creat 1. decorators will not work properly * on fields with capitalised letters with `auto_camelcase=True`, for example: `my_ABC_field = String()` * on fields with custom names for example `some_field = String(name='another_name')` - +1. `@key` decorator will not work on [compound primary key](https://www.apollographql.com/docs/federation/entities/#defining-a-compound-primary-key) --------------------------- For more details see [examples](examples/) @@ -119,4 +119,4 @@ Also cool [example](https://github.com/preply/graphene-federation/issues/1) of i --------------------------- -Also, you can read about how we've come to federation at Preply [here](https://medium.com/preply-engineering/apollo-federation-support-in-graphene-761a0512456d) \ No newline at end of file +Also, you can read about how we've come to federation at Preply [here](https://medium.com/preply-engineering/apollo-federation-support-in-graphene-761a0512456d) diff --git a/graphene_federation/entity.py b/graphene_federation/entity.py index 6e5e026..b2ea06a 100644 --- a/graphene_federation/entity.py +++ b/graphene_federation/entity.py @@ -1,11 +1,11 @@ -from typing import Any, Dict +from typing import Any, Dict, Union from graphene import List, Union, Schema -from graphene.utils.str_converters import to_camel_case import graphene from .types import _Any +from .utils import field_name_to_type_attribute def get_entities(schema: Schema) -> Dict[str, Any]: @@ -18,7 +18,7 @@ def get_entities(schema: Schema) -> Dict[str, Any]: for type_name, type_ in schema._type_map.items(): if not hasattr(type_, "graphene_type"): continue - if getattr(type_.graphene_type, "_sdl", None): + if getattr(type_.graphene_type, "_keys", None): entities[type_name] = type_.graphene_type return entities @@ -27,9 +27,11 @@ def get_entity_cls(entities: Dict[str, Any]): """ Create _Entity type which is a union of all the entities types. """ + class _Entity(Union): class Meta: types = tuple(entities.values()) + return _Entity @@ -44,7 +46,9 @@ def get_entity_query(schema: Schema): entity_type = get_entity_cls(entities_dict) class EntityQuery: - entities = graphene.List(entity_type, name="_entities", representations=List(_Any)) + entities = graphene.List( + entity_type, name="_entities", representations=List(_Any) + ) def resolve_entities(self, info, representations): entities = [] @@ -54,14 +58,14 @@ def resolve_entities(self, info, representations): model_arguments = representation.copy() model_arguments.pop("__typename") if schema.auto_camelcase: - # Create field name conversion dict (from schema name to actual graphene_type field name) - field_names = {to_camel_case(name): name for name in model._meta.fields} - model_arguments = {field_names[k]: v for k, v in model_arguments.items()} + get_model_attr = field_name_to_type_attribute(schema, model) + model_arguments = { + get_model_attr(k): v for k, v in model_arguments.items() + } model_instance = model(**model_arguments) try: - resolver = getattr( - model, "_%s__resolve_reference" % model.__name__) + resolver = getattr(model, "_%s__resolve_reference" % model.__name__) except AttributeError: pass else: @@ -74,12 +78,26 @@ def resolve_entities(self, info, representations): def key(fields: str): + """ + Take as input a field that should be used as key for that entity. + See specification: https://www.apollographql.com/docs/federation/federation-spec/#key + + If the input contains a space it means it's a [compound primary key](https://www.apollographql.com/docs/federation/entities/#defining-a-compound-primary-key) + which is not yet supported. + """ + if " " in fields: + raise NotImplementedError("Compound primary keys are not supported.") + def decorator(Type): - existing = getattr(Type, "_sdl", "") + # Check the provided fields actually exist on the Type. + assert ( + fields in Type._meta.fields + ), f'Field "{fields}" does not exist on type "{Type._meta.name}"' - key_sdl = f'@key(fields: "{fields}")' - updated = f"{key_sdl} {existing}" if existing else key_sdl + keys = getattr(Type, "_keys", []) + keys.append(fields) + setattr(Type, "_keys", keys) - setattr(Type, '_sdl', updated) return Type + return decorator diff --git a/graphene_federation/extend.py b/graphene_federation/extend.py index 667cced..2e4bd8d 100644 --- a/graphene_federation/extend.py +++ b/graphene_federation/extend.py @@ -1,4 +1,4 @@ -from typing import Any, Dict +from typing import Any, Dict, List, Union from graphene import Schema @@ -21,16 +21,23 @@ def get_extended_types(schema: Schema) -> Dict[str, Any]: def extend(fields: str): """ Decorator to use to extend a given type. - The fields to extend must be provided as input. + The field to extend must be provided as input as a string. """ + def decorator(Type): - if hasattr(Type, "_sdl"): - raise RuntimeError("Can't extend type which is already extended or has @key") - # Set a `_sdl` attribute so it will be registered as an entity - setattr(Type, "_sdl", '@key(fields: "%s")' % fields) + assert not hasattr( + Type, "_keys" + ), "Can't extend type which is already extended or has @key" + # Check the provided fields actually exist on the Type. + assert ( + fields in Type._meta.fields + ), f'Field "{fields}" does not exist on type "{Type._meta.name}"' + # Set a `_keys` attribute so it will be registered as an entity + setattr(Type, "_keys", [fields]) # Set a `_extended` attribute to be able to distinguish it from the other entities setattr(Type, "_extended", True) return Type + return decorator @@ -42,6 +49,18 @@ def external(field): return field -def requires(field, fields: str): +def requires(field, fields: Union[str, List[str]]): + """ + Mark the required fields for a given field. + The input `fields` can be either a string or a list. + When it is a string we split at spaces to get the list of fields. + """ + # TODO: We should validate the `fields` input to check it is actually existing fields but we + # don't have access here to the parent graphene type. + if isinstance(fields, str): + fields = fields.split() + assert not hasattr( + field, "_requires" + ), "Can't chain `requires()` method calls on one field." field._requires = fields return field diff --git a/graphene_federation/main.py b/graphene_federation/main.py index 52c384e..b87401c 100644 --- a/graphene_federation/main.py +++ b/graphene_federation/main.py @@ -12,7 +12,7 @@ def _get_query(schema, query_cls=None): if query_cls is not None: bases.append(query_cls) bases = tuple(bases) - federated_query_cls = type('Query', bases, {}) + federated_query_cls = type("Query", bases, {}) return federated_query_cls diff --git a/graphene_federation/provides.py b/graphene_federation/provides.py index 67e2394..63bdd0b 100644 --- a/graphene_federation/provides.py +++ b/graphene_federation/provides.py @@ -1,4 +1,4 @@ -from typing import Any, Dict +from typing import Any, Dict, List, Union from graphene import Field, Schema @@ -18,7 +18,7 @@ def get_provides_parent_types(schema: Schema) -> Dict[str, Any]: return provides_parent_types -def provides(field, fields: str = None): +def provides(field, fields: Union[str, List[str]] = None): """ :param field: base type (when used as decorator) or field of base type @@ -27,8 +27,12 @@ def provides(field, fields: str = None): """ if fields is None: # used as decorator on base type if isinstance(field, Field): - raise RuntimeError("Please specify fields") + raise ValueError("Please specify fields") field._provide_parent_type = True else: # used as wrapper over field + # TODO: We should validate the `fields` input to check it is actually existing fields but we + # don't have access here to the graphene type of the object it provides those fields for. + if isinstance(fields, str): + fields = fields.split() field._provides = fields return field diff --git a/graphene_federation/service.py b/graphene_federation/service.py index ddddcc7..d18139e 100644 --- a/graphene_federation/service.py +++ b/graphene_federation/service.py @@ -1,13 +1,13 @@ import re -from typing import Any, Dict +from typing import Any, Dict, List from packaging import version import graphene from graphene import ObjectType, String, Field, Schema, __version__ as graphene_version -if version.parse(graphene_version) < version.parse('3.0.0'): +if version.parse(graphene_version) < version.parse("3.0.0"): from graphql.utils.schema_printer import _print_fields as print_fields else: from graphql.utilities.print_schema import print_fields as print_fields @@ -16,6 +16,7 @@ from graphene_federation.provides import get_provides_parent_types from .entity import get_entities +from .utils import field_name_to_type_attribute, type_attribute_to_field_name class MonoFieldType: @@ -23,17 +24,23 @@ class MonoFieldType: In order to be able to reuse the `print_fields` method to get a singular field string definition, we need to define an object that has a `.fields` attribute. """ + def __init__(self, name, field): - self.fields = { - name: field - } + self.fields = {name: field} + + +def convert_fields(schema: Schema, fields: List[str]) -> str: + get_field_name = type_attribute_to_field_name(schema) + return " ".join([get_field_name(field) for field in fields]) + DECORATORS = { - "_external": lambda _: "@external", - "_requires": lambda fields: f'@requires(fields: "{fields}")', - "_provides": lambda fields: f'@provides(fields: "{fields}")', + "_external": lambda _schema, _fields: "@external", + "_requires": lambda schema, fields: f'@requires(fields: "{convert_fields(schema, fields)}")', + "_provides": lambda schema, fields: f'@provides(fields: "{convert_fields(schema, fields)}")', } + def add_entity_fields_decorators(entity, schema: Schema, string_schema: str) -> str: """ For a given entity, go through all its field and see if any directive decorator need to be added. @@ -48,28 +55,26 @@ def add_entity_fields_decorators(entity, schema: Schema, string_schema: str) -> entity_name = entity._meta.name entity_type = schema.get_type(entity_name) str_fields = [] - for name, field in entity_type.fields.items(): - str_field = print_fields(MonoFieldType(name, field)) - f = getattr(entity, name, None) + get_model_attr = field_name_to_type_attribute(schema, entity) + for field_name, field in entity_type.fields.items(): + str_field = print_fields(MonoFieldType(field_name, field)) + # Check if we need to annotate the field by checking if it has the decorator attribute set on the field. + f = getattr(entity, get_model_attr(field_name), None) if f is not None: for decorator, decorator_resolver in DECORATORS.items(): decorator_value = getattr(f, decorator, None) if decorator_value: - str_field += f" {decorator_resolver(decorator_value)}" + str_field += f" {decorator_resolver(schema, decorator_value)}" str_fields.append(str_field) str_fields_annotated = "\n".join(str_fields) # Replace the original field declaration by the annotated one str_fields_original = print_fields(entity_type) pattern = re.compile( - r"(type\s%s\s[^\{]*)\{\s*%s\s*\}" % ( - entity_name, re.escape(str_fields_original) - ) + r"(type\s%s\s[^\{]*)\{\s*%s\s*\}" + % (entity_name, re.escape(str_fields_original)) ) string_schema_original = string_schema + "" - string_schema = pattern.sub( - r"\g<1> {\n%s\n}" % str_fields_annotated, - string_schema - ) + string_schema = pattern.sub(r"\g<1> {\n%s\n}" % str_fields_annotated, string_schema) return string_schema @@ -98,14 +103,17 @@ def get_sdl(schema: Schema) -> str: repl_str = r"extend type %s \1" % entity_name string_schema = type_def.sub(repl_str, string_schema) - # Add entity sdl + # Add entity keys declarations + get_field_name = type_attribute_to_field_name(schema) for entity_name, entity in entities.items(): type_def_re = r"(type %s [^\{]*)" % entity_name - repl_str = r"\1 %s " % entity._sdl + type_annotation = " ".join( + [f'@key(fields: "{get_field_name(key)}")' for key in entity._keys] + ) + repl_str = r"\1%s " % type_annotation pattern = re.compile(type_def_re) string_schema = pattern.sub(repl_str, string_schema) - # print(string_schema) return string_schema diff --git a/graphene_federation/tests/test_annotation_corner_cases.py b/graphene_federation/tests/test_annotation_corner_cases.py new file mode 100644 index 0000000..4b35c8b --- /dev/null +++ b/graphene_federation/tests/test_annotation_corner_cases.py @@ -0,0 +1,325 @@ +import pytest + +from graphql import graphql + +from graphene import ObjectType, ID, String, Field + +from ..entity import key +from ..extend import extend, external, requires +from ..main import build_schema + + +def test_similar_field_name(): + """ + Test annotation with fields that have similar names. + """ + + @extend("id") + class ChatUser(ObjectType): + uid = ID() + identified = ID() + id = external(ID()) + i_d = ID() + ID = ID() + + class ChatMessage(ObjectType): + id = ID(required=True) + user = Field(ChatUser) + + class ChatQuery(ObjectType): + message = Field(ChatMessage, id=ID(required=True)) + + chat_schema = build_schema(query=ChatQuery) + assert ( + str(chat_schema) + == """schema { + query: Query +} + +type ChatMessage { + id: ID! + user: ChatUser +} + +type ChatUser { + uid: ID + identified: ID + id: ID + iD: ID + ID: ID +} + +type Query { + message(id: ID!): ChatMessage + _entities(representations: [_Any]): [_Entity] + _service: _Service +} + +scalar _Any + +union _Entity = ChatUser + +type _Service { + sdl: String +} +""" + ) + # Check the federation service schema definition language + query = """ + query { + _service { + sdl + } + } + """ + result = graphql(chat_schema, query) + assert not result.errors + assert ( + result.data["_service"]["sdl"].strip() + == """ +type ChatMessage { + id: ID! + user: ChatUser +} + +type ChatQuery { + message(id: ID!): ChatMessage +} + +extend type ChatUser @key(fields: "id") { + uid: ID + identified: ID + id: ID @external + iD: ID + ID: ID +} +""".strip() + ) + + +def test_camel_case_field_name(): + """ + Test annotation with fields that have camel cases or snake case. + """ + + @extend("auto_camel") + class Camel(ObjectType): + auto_camel = external(String()) + forcedCamel = requires(String(), fields="auto_camel") + a_snake = String() + aCamel = String() + + class Query(ObjectType): + camel = Field(Camel) + + schema = build_schema(query=Query) + assert ( + str(schema) + == """schema { + query: Query +} + +type Camel { + autoCamel: String + forcedCamel: String + aSnake: String + aCamel: String +} + +type Query { + camel: Camel + _entities(representations: [_Any]): [_Entity] + _service: _Service +} + +scalar _Any + +union _Entity = Camel + +type _Service { + sdl: String +} +""" + ) + # Check the federation service schema definition language + query = """ + query { + _service { + sdl + } + } + """ + result = graphql(schema, query) + assert not result.errors + assert ( + result.data["_service"]["sdl"].strip() + == """ +extend type Camel @key(fields: "autoCamel") { + autoCamel: String @external + forcedCamel: String @requires(fields: "autoCamel") + aSnake: String + aCamel: String +} + +type Query { + camel: Camel +} +""".strip() + ) + + +def test_annotated_field_also_used_in_filter(): + """ + Test that when a field also used in filter needs to get annotated, it really annotates only the field. + See issue https://github.com/preply/graphene-federation/issues/50 + """ + + @key("id") + class B(ObjectType): + id = ID() + + @extend("id") + class A(ObjectType): + id = external(ID()) + b = Field(B, id=ID()) + + class Query(ObjectType): + a = Field(A) + + schema = build_schema(query=Query) + assert ( + str(schema) + == """schema { + query: Query +} + +type A { + id: ID + b(id: ID): B +} + +type B { + id: ID +} + +type Query { + a: A + _entities(representations: [_Any]): [_Entity] + _service: _Service +} + +scalar _Any + +union _Entity = A | B + +type _Service { + sdl: String +} +""" + ) + # Check the federation service schema definition language + query = """ + query { + _service { + sdl + } + } + """ + result = graphql(schema, query) + assert not result.errors + assert ( + result.data["_service"]["sdl"].strip() + == """ +extend type A @key(fields: "id") { + id: ID @external + b(id: ID): B +} + +type B @key(fields: "id") { + id: ID +} + +type Query { + a: A +} +""".strip() + ) + + +def test_annotate_object_with_meta_name(): + @key("id") + class B(ObjectType): + class Meta: + name = "Potato" + + id = ID() + + @extend("id") + class A(ObjectType): + class Meta: + name = "Banana" + + id = external(ID()) + b = Field(B, id=ID()) + + class Query(ObjectType): + a = Field(A) + + schema = build_schema(query=Query) + assert ( + str(schema) + == """schema { + query: Query +} + +type Banana { + id: ID + b(id: ID): Potato +} + +type Potato { + id: ID +} + +type Query { + a: Banana + _entities(representations: [_Any]): [_Entity] + _service: _Service +} + +scalar _Any + +union _Entity = Banana | Potato + +type _Service { + sdl: String +} +""" + ) + # Check the federation service schema definition language + query = """ + query { + _service { + sdl + } + } + """ + result = graphql(schema, query) + assert not result.errors + assert ( + result.data["_service"]["sdl"].strip() + == """ +extend type Banana @key(fields: "id") { + id: ID @external + b(id: ID): Potato +} + +type Potato @key(fields: "id") { + id: ID +} + +type Query { + a: Banana +} +""".strip() + ) diff --git a/graphene_federation/tests/test_entity.py b/graphene_federation/tests/test_entity.py new file mode 100644 index 0000000..fab55d2 --- /dev/null +++ b/graphene_federation/tests/test_entity.py @@ -0,0 +1 @@ +# test resolve_entities method diff --git a/graphene_federation/tests/test_extend.py b/graphene_federation/tests/test_extend.py new file mode 100644 index 0000000..896d80d --- /dev/null +++ b/graphene_federation/tests/test_extend.py @@ -0,0 +1,33 @@ +import pytest + +from graphene import ObjectType, ID, String + +from ..extend import extend + + +def test_extend_non_existing_field_failure(): + """ + Test that using the key decorator and providing a field that does not exist fails. + """ + with pytest.raises(AssertionError) as err: + + @extend("potato") + class A(ObjectType): + id = ID() + + assert 'Field "potato" does not exist on type "A"' == str(err.value) + + +def test_multiple_extend_failure(): + """ + Test that the extend decorator can't be used more than once on a type. + """ + with pytest.raises(AssertionError) as err: + + @extend("id") + @extend("potato") + class A(ObjectType): + id = ID() + potato = String() + + assert "Can't extend type which is already extended or has @key" == str(err.value) diff --git a/graphene_federation/tests/test_key.py b/graphene_federation/tests/test_key.py new file mode 100644 index 0000000..6cad9b0 --- /dev/null +++ b/graphene_federation/tests/test_key.py @@ -0,0 +1,102 @@ +import pytest + +from graphql import graphql + +from graphene import ObjectType, ID, String, Field + +from ..entity import key +from ..main import build_schema + + +def test_multiple_keys(): + @key("identifier") + @key("email") + class User(ObjectType): + identifier = ID() + email = String() + + class Query(ObjectType): + user = Field(User) + + schema = build_schema(query=Query) + assert ( + str(schema) + == """schema { + query: Query +} + +type Query { + user: User + _entities(representations: [_Any]): [_Entity] + _service: _Service +} + +type User { + identifier: ID + email: String +} + +scalar _Any + +union _Entity = User + +type _Service { + sdl: String +} +""" + ) + # Check the federation service schema definition language + query = """ + query { + _service { + sdl + } + } + """ + result = graphql(schema, query) + assert not result.errors + assert ( + result.data["_service"]["sdl"].strip() + == """ +type Query { + user: User +} + +type User @key(fields: "email") @key(fields: "identifier") { + identifier: ID + email: String +} +""".strip() + ) + + +def test_key_non_existing_field_failure(): + """ + Test that using the key decorator and providing a field that does not exist fails. + """ + with pytest.raises(AssertionError) as err: + + @key("potato") + class A(ObjectType): + id = ID() + + assert 'Field "potato" does not exist on type "A"' == str(err.value) + + +def test_compound_primary_keys_failure(): + """ + Compound primary keys are not implemented as of now so this test checks that at least the user get + an explicit failure. + """ + + class Organization(ObjectType): + id = ID() + + with pytest.raises(NotImplementedError) as err: + + @key("id organization { id }") + class User(ObjectType): + id = ID() + organization = Field(Organization) + + assert "Compound primary keys are not supported." == str(err.value) diff --git a/graphene_federation/tests/test_provides.py b/graphene_federation/tests/test_provides.py new file mode 100644 index 0000000..cf7525a --- /dev/null +++ b/graphene_federation/tests/test_provides.py @@ -0,0 +1,256 @@ +from graphql import graphql + +from graphene import Field, Int, ObjectType, String + +from ..provides import provides +from ..main import build_schema +from ..extend import extend, external + + +def test_provides(): + """ + https://www.apollographql.com/docs/federation/entities/#resolving-another-services-field-advanced + """ + + @extend("sku") + class Product(ObjectType): + sku = external(String(required=True)) + name = external(String()) + weight = external(Int()) + + @provides + class InStockCount(ObjectType): + product = provides(Field(Product, required=True), fields="name") + quantity = Int(required=True) + + class Query(ObjectType): + in_stock_count = Field(InStockCount) + + schema = build_schema(query=Query) + assert ( + str(schema) + == """schema { + query: Query +} + +type InStockCount { + product: Product! + quantity: Int! +} + +type Product { + sku: String! + name: String + weight: Int +} + +type Query { + inStockCount: InStockCount + _entities(representations: [_Any]): [_Entity] + _service: _Service +} + +scalar _Any + +union _Entity = Product + +type _Service { + sdl: String +} +""" + ) + # Check the federation service schema definition language + query = """ + query { + _service { + sdl + } + } + """ + result = graphql(schema, query) + assert not result.errors + assert ( + result.data["_service"]["sdl"].strip() + == """ +type InStockCount { + product: Product! @provides(fields: "name") + quantity: Int! +} + +extend type Product @key(fields: "sku") { + sku: String! @external + name: String @external + weight: Int @external +} + +type Query { + inStockCount: InStockCount +} +""".strip() + ) + + +def test_provides_multiple_fields(): + """ + https://www.apollographql.com/docs/federation/entities/#resolving-another-services-field-advanced + """ + + @extend("sku") + class Product(ObjectType): + sku = external(String(required=True)) + name = external(String()) + weight = external(Int()) + + @provides + class InStockCount(ObjectType): + product = provides(Field(Product, required=True), fields="name weight") + quantity = Int(required=True) + + class Query(ObjectType): + in_stock_count = Field(InStockCount) + + schema = build_schema(query=Query) + assert ( + str(schema) + == """schema { + query: Query +} + +type InStockCount { + product: Product! + quantity: Int! +} + +type Product { + sku: String! + name: String + weight: Int +} + +type Query { + inStockCount: InStockCount + _entities(representations: [_Any]): [_Entity] + _service: _Service +} + +scalar _Any + +union _Entity = Product + +type _Service { + sdl: String +} +""" + ) + # Check the federation service schema definition language + query = """ + query { + _service { + sdl + } + } + """ + result = graphql(schema, query) + assert not result.errors + assert ( + result.data["_service"]["sdl"].strip() + == """ +type InStockCount { + product: Product! @provides(fields: "name weight") + quantity: Int! +} + +extend type Product @key(fields: "sku") { + sku: String! @external + name: String @external + weight: Int @external +} + +type Query { + inStockCount: InStockCount +} +""".strip() + ) + + +def test_provides_multiple_fields_as_list(): + """ + https://www.apollographql.com/docs/federation/entities/#resolving-another-services-field-advanced + """ + + @extend("sku") + class Product(ObjectType): + sku = external(String(required=True)) + name = external(String()) + weight = external(Int()) + + @provides + class InStockCount(ObjectType): + product = provides(Field(Product, required=True), fields=["name", "weight"]) + quantity = Int(required=True) + + class Query(ObjectType): + in_stock_count = Field(InStockCount) + + schema = build_schema(query=Query) + assert ( + str(schema) + == """schema { + query: Query +} + +type InStockCount { + product: Product! + quantity: Int! +} + +type Product { + sku: String! + name: String + weight: Int +} + +type Query { + inStockCount: InStockCount + _entities(representations: [_Any]): [_Entity] + _service: _Service +} + +scalar _Any + +union _Entity = Product + +type _Service { + sdl: String +} +""" + ) + # Check the federation service schema definition language + query = """ + query { + _service { + sdl + } + } + """ + result = graphql(schema, query) + assert not result.errors + assert ( + result.data["_service"]["sdl"].strip() + == """ +type InStockCount { + product: Product! @provides(fields: "name weight") + quantity: Int! +} + +extend type Product @key(fields: "sku") { + sku: String! @external + name: String @external + weight: Int @external +} + +type Query { + inStockCount: InStockCount +} +""".strip() + ) diff --git a/graphene_federation/tests/test_requires.py b/graphene_federation/tests/test_requires.py new file mode 100644 index 0000000..6122d7e --- /dev/null +++ b/graphene_federation/tests/test_requires.py @@ -0,0 +1,232 @@ +import pytest + +from graphql import graphql + +from graphene import Field, ID, Int, ObjectType, String + +from ..extend import extend, external, requires +from ..main import build_schema + + +def test_chain_requires_failure(): + """ + Check that we can't nest call the requires method on a field. + """ + with pytest.raises(AssertionError) as err: + + @extend("id") + class A(ObjectType): + id = external(ID()) + something = requires(requires(String(), fields="id"), fields="id") + + assert "Can't chain `requires()` method calls on one field." == str(err.value) + + +def test_requires_multiple_fields(): + """ + Check that requires can take more than one field as input. + """ + + @extend("sku") + class Product(ObjectType): + sku = external(ID()) + size = external(Int()) + weight = external(Int()) + shipping_estimate = requires(String(), fields="size weight") + + class Query(ObjectType): + product = Field(Product) + + schema = build_schema(query=Query) + assert ( + str(schema) + == """schema { + query: Query +} + +type Product { + sku: ID + size: Int + weight: Int + shippingEstimate: String +} + +type Query { + product: Product + _entities(representations: [_Any]): [_Entity] + _service: _Service +} + +scalar _Any + +union _Entity = Product + +type _Service { + sdl: String +} +""" + ) + # Check the federation service schema definition language + query = """ + query { + _service { + sdl + } + } + """ + result = graphql(schema, query) + assert not result.errors + assert ( + result.data["_service"]["sdl"].strip() + == """ +extend type Product @key(fields: "sku") { + sku: ID @external + size: Int @external + weight: Int @external + shippingEstimate: String @requires(fields: "size weight") +} + +type Query { + product: Product +} +""".strip() + ) + + +def test_requires_multiple_fields_as_list(): + """ + Check that requires can take more than one field as input. + """ + + @extend("sku") + class Product(ObjectType): + sku = external(ID()) + size = external(Int()) + weight = external(Int()) + shipping_estimate = requires(String(), fields=["size", "weight"]) + + class Query(ObjectType): + product = Field(Product) + + schema = build_schema(query=Query) + assert ( + str(schema) + == """schema { + query: Query +} + +type Product { + sku: ID + size: Int + weight: Int + shippingEstimate: String +} + +type Query { + product: Product + _entities(representations: [_Any]): [_Entity] + _service: _Service +} + +scalar _Any + +union _Entity = Product + +type _Service { + sdl: String +} +""" + ) + # Check the federation service schema definition language + query = """ + query { + _service { + sdl + } + } + """ + result = graphql(schema, query) + assert not result.errors + assert ( + result.data["_service"]["sdl"].strip() + == """ +extend type Product @key(fields: "sku") { + sku: ID @external + size: Int @external + weight: Int @external + shippingEstimate: String @requires(fields: "size weight") +} + +type Query { + product: Product +} +""".strip() + ) + + +def test_requires_with_input(): + """ + Test checking that the issue https://github.com/preply/graphene-federation/pull/47 is resolved. + """ + + @extend("id") + class Acme(ObjectType): + id = external(ID(required=True)) + age = external(Int()) + foo = requires(Field(String, someInput=String()), fields="age") + + class Query(ObjectType): + acme = Field(Acme) + + schema = build_schema(query=Query) + assert ( + str(schema) + == """schema { + query: Query +} + +type Acme { + id: ID! + age: Int + foo(someInput: String): String +} + +type Query { + acme: Acme + _entities(representations: [_Any]): [_Entity] + _service: _Service +} + +scalar _Any + +union _Entity = Acme + +type _Service { + sdl: String +} +""" + ) + # Check the federation service schema definition language + query = """ + query { + _service { + sdl + } + } + """ + result = graphql(schema, query) + assert not result.errors + assert ( + result.data["_service"]["sdl"].strip() + == """ +extend type Acme @key(fields: "id") { + id: ID! @external + age: Int @external + foo(someInput: String): String @requires(fields: "age") +} + +type Query { + acme: Acme +} +""".strip() + ) diff --git a/graphene_federation/tests/test_schema_annotation.py b/graphene_federation/tests/test_schema_annotation.py index df9af16..8a2ff0f 100644 --- a/graphene_federation/tests/test_schema_annotation.py +++ b/graphene_federation/tests/test_schema_annotation.py @@ -15,6 +15,7 @@ {"user_id": "3", "name": "Mary", "email": "mary@mail.com"}, ] + @key("user_id") @key("email") class User(ObjectType): @@ -29,12 +30,14 @@ def __resolve_reference(self, info, *args, **kwargs): user = next(filter(lambda x: x["email"] == self.email, users)) return User(**user) + class UserQuery(ObjectType): user = Field(User, user_id=ID(required=True)) def resolve_user(self, info, user_id, *args, **kwargs): return User(**next(filter(lambda x: x["user_id"] == user_id, users))) + user_schema = build_schema(query=UserQuery) # ------------------------ @@ -49,10 +52,12 @@ def resolve_user(self, info, user_id, *args, **kwargs): {"id": "6", "user_id": "2", "text": "Sorry but weather sucks so I am upset"}, ] + @extend("user_id") class ChatUser(ObjectType): user_id = external(ID(required=True)) + class ChatMessage(ObjectType): id = ID(required=True) text = String() @@ -62,24 +67,29 @@ class ChatMessage(ObjectType): def resolve_user(self, info, *args, **kwargs): return ChatUser(user_id=self.user_id) + class ChatQuery(ObjectType): message = Field(ChatMessage, id=ID(required=True)) def resolve_message(self, info, id, *args, **kwargs): return ChatMessage(**next(filter(lambda x: x["id"] == id, chat_messages))) + chat_schema = build_schema(query=ChatQuery) # ------------------------ # Tests # ------------------------ + def test_user_schema(): """ Check that the user schema has been annotated correctly and that a request to retrieve a user works. """ - assert str(user_schema) == """schema { + assert ( + str(user_schema) + == """schema { query: Query } @@ -103,6 +113,7 @@ def test_user_schema(): sdl: String } """ + ) query = """ query { user(userId: "2") { @@ -113,13 +124,40 @@ def test_user_schema(): result = graphql(user_schema, query) assert not result.errors assert result.data == {"user": {"name": "Jack"}} + # Check the federation service schema definition language + query = """ + query { + _service { + sdl + } + } + """ + result = graphql(user_schema, query) + assert not result.errors + assert ( + result.data["_service"]["sdl"].strip() + == """ +type User @key(fields: "email") @key(fields: "userId") { + userId: ID! + email: String! + name: String +} + +type UserQuery { + user(userId: ID!): User +} +""".strip() + ) + def test_chat_schema(): """ Check that the chat schema has been annotated correctly and that a request to retrieve a chat message works. """ - assert str(chat_schema) == """schema { + assert ( + str(chat_schema) + == """schema { query: Query } @@ -148,6 +186,7 @@ def test_chat_schema(): sdl: String } """ + ) query = """ query { message(id: "4") { @@ -159,14 +198,32 @@ def test_chat_schema(): result = graphql(chat_schema, query) assert not result.errors assert result.data == {"message": {"text": "Don't be rude Jack", "userId": "3"}} + # Check the federation service schema definition language + query = """ + query { + _service { + sdl + } + } + """ + result = graphql(chat_schema, query) + assert not result.errors + assert ( + result.data["_service"]["sdl"].strip() + == """ +type ChatMessage { + id: ID! + text: String + userId: ID + user: ChatUser! +} +type ChatQuery { + message(id: ID!): ChatMessage +} - -# Test type with external field also used in a connection -# Test type with 2 similar looking names -# test camel case situation -# test basic case for extend/external/provides - -# Add unit testing -# Add linting (black, flake) -# Add typing +extend type ChatUser @key(fields: "userId") { + userId: ID! @external +} +""".strip() + ) diff --git a/graphene_federation/types.py b/graphene_federation/types.py index 62e69b7..40ab434 100644 --- a/graphene_federation/types.py +++ b/graphene_federation/types.py @@ -2,7 +2,7 @@ class _Any(Scalar): - '''Anything''' + """Anything""" __typename = String(required=True) diff --git a/graphene_federation/utils.py b/graphene_federation/utils.py new file mode 100644 index 0000000..4eb7903 --- /dev/null +++ b/graphene_federation/utils.py @@ -0,0 +1,28 @@ +from typing import Any, Callable, Dict + +from graphene import Schema +from graphene.utils.str_converters import to_camel_case + + +def field_name_to_type_attribute(schema: Schema, model: Any) -> Callable[[str], str]: + """ + Create field name conversion method (from schema name to actual graphene_type attribute name). + """ + field_names = {} + if schema.auto_camelcase: + field_names = { + to_camel_case(attr_name): attr_name for attr_name in model._meta.fields + } + return lambda schema_field_name: field_names.get( + schema_field_name, schema_field_name + ) + + +def type_attribute_to_field_name(schema: Schema) -> Callable[[str], str]: + """ + Create a conversion method to convert from graphene_type attribute name to the schema field name. + """ + if schema.auto_camelcase: + return lambda attr_name: to_camel_case(attr_name) + else: + return lambda attr_name: attr_name From 32b693a66fdba0edf02bb6e2d0fac13c4cbf70d7 Mon Sep 17 00:00:00 2001 From: Thomas Leonard Date: Tue, 27 Oct 2020 18:43:41 +0100 Subject: [PATCH 05/15] Add unit tests --- .../tests/test_annotation_corner_cases.py | 71 +++++++++++++++++++ setup.py | 2 +- 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/graphene_federation/tests/test_annotation_corner_cases.py b/graphene_federation/tests/test_annotation_corner_cases.py index 4b35c8b..85fc4c9 100644 --- a/graphene_federation/tests/test_annotation_corner_cases.py +++ b/graphene_federation/tests/test_annotation_corner_cases.py @@ -168,6 +168,77 @@ class Query(ObjectType): ) +def test_camel_case_field_name_without_auto_camelcase(): + """ + Test annotation with fields that have camel cases or snake case but with the auto_camelcase disabled. + """ + + @extend("auto_camel") + class Camel(ObjectType): + auto_camel = external(String()) + forcedCamel = requires(String(), fields="auto_camel") + a_snake = String() + aCamel = String() + + class Query(ObjectType): + camel = Field(Camel) + + schema = build_schema(query=Query, auto_camelcase=False) + assert ( + str(schema) + == """schema { + query: Query +} + +type Camel { + auto_camel: String + forcedCamel: String + a_snake: String + aCamel: String +} + +type Query { + camel: Camel + _entities(representations: [_Any]): [_Entity] + _service: _Service +} + +scalar _Any + +union _Entity = Camel + +type _Service { + sdl: String +} +""" + ) + # Check the federation service schema definition language + query = """ + query { + _service { + sdl + } + } + """ + result = graphql(schema, query) + assert not result.errors + assert ( + result.data["_service"]["sdl"].strip() + == """ +extend type Camel @key(fields: "auto_camel") { + auto_camel: String @external + forcedCamel: String @requires(fields: "auto_camel") + a_snake: String + aCamel: String +} + +type Query { + camel: Camel +} +""".strip() + ) + + def test_annotated_field_also_used_in_filter(): """ Test that when a field also used in filter needs to get annotated, it really annotates only the field. diff --git a/setup.py b/setup.py index d8a615a..2e24be0 100644 --- a/setup.py +++ b/setup.py @@ -29,7 +29,7 @@ def read(*rnames): author_email='super.hang.glider@gmail.com', url='https://github.com/preply/graphene-federation', download_url=f'https://github.com/preply/graphene-federation/archive/{version}.tar.gz', - keywords=['graphene', 'gql', 'federation'], + keywords=["graphene", "graphql", "gql", "federation"], install_requires=[ "graphene>=2.1.0,<3" ], From 351dd6ca9bb8d12a24b5227a501cb64a815c41bb Mon Sep 17 00:00:00 2001 From: Thomas Leonard Date: Tue, 27 Oct 2020 18:44:01 +0100 Subject: [PATCH 06/15] Add travis config --- .travis.yml | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .travis.yml diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..eb52110 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,35 @@ +language: python +dist: xenial + +python: + - 3.5 + - 3.6 + - 3.7 + - 3.8 + +install: + - pip install -e ".[dev]" +after_success: + - pip install coveralls + - coveralls + +stages: + - test + - integration-test + +script: + - py.test graphene_federation --cov=graphene_federation -vv + +jobs: + include: + - stage: test + - name: linting + python: 3.7 + script: + - black graphene_federation --check + - stage: integration-test + services: + - docker + script: + - make integration-build + - make integration-test From 81e60d5f9ca58e0c52dca4f2e4b722bab96a0136 Mon Sep 17 00:00:00 2001 From: Thomas Leonard Date: Tue, 27 Oct 2020 18:52:13 +0100 Subject: [PATCH 07/15] Small changes --- .travis.yml | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/.travis.yml b/.travis.yml index eb52110..c023746 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,13 +2,16 @@ language: python dist: xenial python: - - 3.5 - 3.6 - 3.7 - 3.8 install: - - pip install -e ".[dev]" + - pip install -e ".[test]" + +script: + - py.test graphene_federation --cov=graphene_federation -vv + after_success: - pip install coveralls - coveralls @@ -16,15 +19,15 @@ after_success: stages: - test - integration-test + - linting -script: - - py.test graphene_federation --cov=graphene_federation -vv jobs: include: - - stage: test - - name: linting + - stage: linting python: 3.7 + install: + - pip install -e ".[dev]" script: - black graphene_federation --check - stage: integration-test From f399d24517cf672b3547fa64aca3bdf9e81d318b Mon Sep 17 00:00:00 2001 From: Thomas Leonard Date: Tue, 27 Oct 2020 18:55:32 +0100 Subject: [PATCH 08/15] Re-order stages and remove circle CI config --- .circleci/config.yml | 32 -------------------------------- .travis.yml | 6 +++--- 2 files changed, 3 insertions(+), 35 deletions(-) delete mode 100644 .circleci/config.yml diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index 057aa66..0000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,32 +0,0 @@ -# Python CircleCI 2.0 configuration file -# -# Check https://circleci.com/docs/2.0/language-python/ for more details -# -version: 2 -jobs: - build: - machine: true - working_directory: ~/graphene_federation - - steps: - - checkout - - - run: - name: Linting - command: black graphene_federation --check - - - run: - name: Unit Tests - command: make tests - - - run: - name: Integration Build - command: make integration-build - - - run: - name: Integration Tests - command: make integration-test - - - store_artifacts: - path: test-reports - destination: test-reports diff --git a/.travis.yml b/.travis.yml index c023746..ddf659f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -17,20 +17,20 @@ after_success: - coveralls stages: + - linting - test - integration-test - - linting - jobs: include: - stage: linting - python: 3.7 + python: 3.8 install: - pip install -e ".[dev]" script: - black graphene_federation --check - stage: integration-test + python: 3.8 services: - docker script: From 5c4b73c449b58cc3504504ff5c3058fdfbb76c62 Mon Sep 17 00:00:00 2001 From: Thomas Leonard Date: Wed, 28 Oct 2020 14:25:29 +0100 Subject: [PATCH 09/15] Fix integration tests --- graphene_federation/service.py | 9 ++------- integration_tests/README.md | 9 +++++++++ integration_tests/docker-compose.yml | 20 ++++++++++---------- integration_tests/service_b/src/schema.py | 2 +- integration_tests/tests/tests/test_main.py | 8 ++++---- 5 files changed, 26 insertions(+), 22 deletions(-) create mode 100644 integration_tests/README.md diff --git a/graphene_federation/service.py b/graphene_federation/service.py index d18139e..ac090a7 100644 --- a/graphene_federation/service.py +++ b/graphene_federation/service.py @@ -2,15 +2,10 @@ from typing import Any, Dict, List -from packaging import version - import graphene -from graphene import ObjectType, String, Field, Schema, __version__ as graphene_version +from graphene import ObjectType, String, Field, Schema -if version.parse(graphene_version) < version.parse("3.0.0"): - from graphql.utils.schema_printer import _print_fields as print_fields -else: - from graphql.utilities.print_schema import print_fields as print_fields +from graphql.utils.schema_printer import _print_fields as print_fields from graphene_federation.extend import get_extended_types from graphene_federation.provides import get_provides_parent_types diff --git a/integration_tests/README.md b/integration_tests/README.md new file mode 100644 index 0000000..ca8a6ad --- /dev/null +++ b/integration_tests/README.md @@ -0,0 +1,9 @@ +# Integration Test + +This is an integration test that have for purpose to check that the library creates schemas that are compatible between each other and can be federated in a single gateway schema. + +It first try to bring up the four separate services (a, b, c, d) and check that they are brought up successfully by polling the `/graphql` url to retrieve the schema. + +Once those are up and running the federation service is brought up and we check that it manages to aggregates all schemas into one. + +Finally the test service is brought up and does a few requests to the few services to check they act as intended. diff --git a/integration_tests/docker-compose.yml b/integration_tests/docker-compose.yml index face24b..f286ddd 100644 --- a/integration_tests/docker-compose.yml +++ b/integration_tests/docker-compose.yml @@ -7,10 +7,10 @@ services: - ../:/project/federation_deps command: sh -c "pip install ./federation_deps && python ./src/app.py" healthcheck: - test: ["CMD", "curl", "-XGET", "http://0.0.0.0:5000/graphql"] + test: curl http://0.0.0.0:5000/graphql -H "Content-Type:application/json" --data '{"query":"{__schema { types {name} }}"}' interval: 1s timeout: 10s - retries: 10 + retries: 20 service_b: build: service_b/. @@ -19,10 +19,10 @@ services: - ../:/project/federation_deps command: sh -c "pip install ./federation_deps && python ./src/app.py" healthcheck: - test: ["CMD", "curl", "-XGET", "http://0.0.0.0:5000/graphql"] + test: curl http://0.0.0.0:5000/graphql -H "Content-Type:application/json" --data '{"query":"{__schema { types {name} }}"}' interval: 1s timeout: 10s - retries: 10 + retries: 20 service_c: build: service_c/. @@ -31,10 +31,10 @@ services: - ../:/project/federation_deps command: sh -c "pip install ./federation_deps && python ./src/app.py" healthcheck: - test: ["CMD", "curl", "-XGET", "http://0.0.0.0:5000/graphql"] + test: curl http://0.0.0.0:5000/graphql -H "Content-Type:application/json" --data '{"query":"{__schema { types {name} }}"}' interval: 1s timeout: 10s - retries: 10 + retries: 20 service_d: build: service_d/. @@ -43,10 +43,10 @@ services: - ../:/project/federation_deps command: sh -c "pip install ./federation_deps && python ./src/app.py" healthcheck: - test: ["CMD", "curl", "-XGET", "http://0.0.0.0:5000/graphql"] + test: curl http://0.0.0.0:5000/graphql -H "Content-Type:application/json" --data '{"query":"{__schema { types {name} }}"}' interval: 1s timeout: 10s - retries: 10 + retries: 20 federation: build: federation/. @@ -55,7 +55,7 @@ services: ports: - 3000:3000 healthcheck: - test: ["CMD", "curl", "-f", "-XGET", "http://localhost:3000/.well-known/apollo/server-health"] + test: curl -f http://localhost:3000/.well-known/apollo/server-health interval: 3s timeout: 10s retries: 5 @@ -80,4 +80,4 @@ services: volumes: - ./tests:/project/tests depends_on: - - proxy_dep \ No newline at end of file + - proxy_dep diff --git a/integration_tests/service_b/src/schema.py b/integration_tests/service_b/src/schema.py index 73f44e0..206ba2b 100644 --- a/integration_tests/service_b/src/schema.py +++ b/integration_tests/service_b/src/schema.py @@ -27,7 +27,7 @@ def __resolve_reference(self, info, **kwargs): @key('id') -@key('primaryEmail') +@key('primary_email') class User(ObjectType): id = Int(required=True) primary_email = String() diff --git a/integration_tests/tests/tests/test_main.py b/integration_tests/tests/tests/test_main.py index 669e78c..d3d1708 100644 --- a/integration_tests/tests/tests/test_main.py +++ b/integration_tests/tests/tests/test_main.py @@ -89,8 +89,8 @@ def fetch_sdl(service_name='service_b'): def test_key_decorator_applied_by_exact_match_only(): sdl = fetch_sdl() - assert 'type FileNode @key(fields: "id")' in sdl - assert 'type FileNodeAnother @key(fields: "id")' not in sdl + assert 'type FileNode @key(fields: "id")' in sdl + assert 'type FileNodeAnother @key(fields: "id")' not in sdl def test_mutation_is_accessible_in_federation(): @@ -112,12 +112,12 @@ def test_mutation_is_accessible_in_federation(): def test_multiple_key_decorators_apply_multiple_key_annotations(): sdl = fetch_sdl() - assert 'type User @key(fields: "id") @key(fields: "primaryEmail")' in sdl + assert 'type User @key(fields: "primaryEmail") @key(fields: "id")' in sdl def test_avoid_duplication_of_key_decorator(): sdl = fetch_sdl('service_a') - assert 'extend type FileNode @key(fields: \"id\") {' in sdl + assert 'extend type FileNode @key(fields: \"id\") {' in sdl def test_requires(): From 3676085ad304619029cadcf56b58753448d485b4 Mon Sep 17 00:00:00 2001 From: Thomas Leonard Date: Wed, 28 Oct 2020 16:07:00 +0100 Subject: [PATCH 10/15] Clean up readme --- .coveragerc | 2 + Makefile | 2 +- README.md | 209 ++++++++++++++++++++++++++++++++-------------------- 3 files changed, 132 insertions(+), 81 deletions(-) create mode 100644 .coveragerc diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..321e5ca --- /dev/null +++ b/.coveragerc @@ -0,0 +1,2 @@ +[run] +omit = */tests/* diff --git a/Makefile b/Makefile index 69b16d3..d916751 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ integration-test: .PHONY: dev-setup ## Install development dependencies dev-setup: - docker-compose up -d + docker-compose up -d && docker-compose exec graphene_federation bash .PHONY: tests ## Run unit tests tests: diff --git a/README.md b/README.md index de70992..7531eb4 100644 --- a/README.md +++ b/README.md @@ -1,121 +1,170 @@ # graphene-federation -Federation support for graphene -Build: [![CircleCI](https://circleci.com/gh/preply/graphene-federation.svg?style=svg)](https://circleci.com/gh/preply/graphene-federation) +Federation support for ![Graphene Logo](http://graphene-python.org/favicon.png) [Graphene](http://graphene-python.org) following the [Federation specifications](https://www.apollographql.com/docs/apollo-server/federation/federation-spec/). - -Federation specs implementation on top of Python graphene lib -https://www.apollographql.com/docs/apollo-server/federation/federation-spec/ +Build [![Build Status](https://travis-ci.com/tcleonard/graphene-federation.svg?branch=master)](https://travis-ci.com/tcleonard/graphene-federation) +Coverage: [![Coverage Status](https://coveralls.io/repos/github/tcleonard/graphene-federation/badge.svg)](https://coveralls.io/github/tcleonard/graphene-federation) Based on discussion: https://github.com/graphql-python/graphene/issues/953#issuecomment-508481652 -Supports now: -* sdl (_service fields) # make possible to add schema in federation (as is) -* `@key` decorator (entity support) # to perform Queries across service boundaries - * You can use multiple `@key` per each ObjectType - ```python - @key('id') - @key('email') - class User(ObjectType): - id = Int(required=True) - email = String() - - def __resolve_reference(self, info, **kwargs): - if self.id is not None: - return User(id=self.id, email=f'name_{self.id}@gmail.com') - return User(id=123, email=self.email) - ``` -* extend # extend remote types -* external # mark field as external -* requires # mark that field resolver requires other fields to be pre-fetched -* provides # to annotate the expected returned fieldset from a field on a base type that is guaranteed to be selectable by the gateway. - * **Base class should be decorated with `@provides`** as well as field on a base type that provides. Check example bellow: - ```python - import graphene - from graphene_federation import provides - - @provides - class ArticleThatProvideAuthorAge(graphene.ObjectType): - id = Int(required=True) - text = String(required=True) - author = provides(Field(User), fields='age') - ``` +------------------------ + +## Supported Features + +At the moment it supports: + +* `sdl` (`_service` on field): enable to add schema in federation (as is) +* `@key` decorator (entity support): enable to perform queries across service boundaries (you can have more than one key per type) +* `@extend`: extend remote types +* `external()`: mark a field as external +* `requires()`: mark that field resolver requires other fields to be pre-fetched +* `provides()`/`@provides`: annotate the expected returned fieldset from a field on a base type that is guaranteed to be selectable by the gateway. + +Each type which is decorated with `@key` or `@extend` is added to the `_Entity` union. +The [`__resolve_reference` method](https://www.apollographql.com/docs/federation/api/apollo-federation/#__resolvereference) can be defined for each type that is an entity. +This method is called whenever an entity is requested as part of the fulfilling a query plan. +If not explicitly defined, the default resolver is used. +The default resolver just creates instance of type with passed fieldset as kwargs, see [`entity.get_entity_query`](graphene_federation/entity.py) for more details +* You should define `__resolve_reference`, if you need to extract object before passing it to fields resolvers (example: [FileNode](integration_tests/service_b/src/schema.py)) +* You should not define `__resolve_reference`, if fields resolvers need only data passed in fieldset (example: [FunnyText](integration_tests/service_a/src/schema.py)) +Read more in [official documentation](https://www.apollographql.com/docs/apollo-server/api/apollo-federation/#__resolvereference). + +------------------------ + +## Example + +Here is an example of implementation based on the [Apollo Federation introduction example](https://www.apollographql.com/docs/federation/). +It implements a federation schema for a basic e-commerce application over three services: accounts, products, reviews. +### Accounts +First add an account service that expose a `User` type that can then be referenced in other services by its `id` field: ```python -import graphene +from graphene import Field, ID, ObjectType, String from graphene_federation import build_schema, key -@key(fields='id') # mark File as Entity and add in EntityUnion https://www.apollographql.com/docs/apollo-server/federation/federation-spec/#key -class File(graphene.ObjectType): - id = graphene.Int(required=True) - name = graphene.String() +@key("id") +class User(ObjectType): + id = Int(required=True) + username = String(required=True) - def resolve_id(self, info, **kwargs): - return 1 + def __resolve_reference(self, info, **kwargs): + """ + Here we resolve the reference of the user entity referenced by its `id` field. + """ + return User(id=self.id, email=f"user_{self.id}@mail.com") - def resolve_name(self, info, **kwargs): - return self.name +class Query(ObjectType): + me = Field(User) - def __resolve_reference(self, info, **kwargs): # https://www.apollographql.com/docs/apollo-server/api/apollo-federation/#__resolvereference - return get_file_by_id(self.id) +schema = build_schema(query=Query) ``` +### Product +The product service exposes a `Product` type that can be used by other services via the `upc` field: ```python -import graphene -from graphene_federation import build_schema +from graphene import Argument, ID, Int, List, ObjectType, String +from graphene_federation import build_schema, key + +@key("upc") +class Product(ObjectType): + upc = String(required=True) + name = String(required=True) + price = Int() + def __resolve_reference(self, info, **kwargs): + """ + Here we resolve the reference of the product entity referenced by its `upc` field. + """ + return User(upc=self.upc, name=f"product {self.upc}") -class Query(graphene.ObjectType): - ... - pass +class Query(ObjectType): + topProducts = List(Product, first=Argument(Int, default_value=5)) -schema = build_schema(Query) # add _service{sdl} field in Query +schema = build_schema(query=Query) ``` +### Reviews +The reviews service exposes a `Review` type which has a link to both the `User` and `Product` types. +It also has the ability to provide the username of the `User`. +On top of that it adds to the `User`/`Product` types (that are both defined in other services) the ability to get their reviews. ```python -import graphene -from graphene_federation import external, extend +from graphene import Field, ID, Int, List, ObjectType, String +from graphene_federation import build_schema, extend, external, provides + +@extend("id") +class User(ObjectType): + id = external(Int(required=True)) + reviews = List(lambda: Review) + + def resolve_reviews(self, info, *args, **kwargs): + """ + Get all the reviews of a given user. (not implemented here) + """ + return [] + +@extend("upc") +class Product(ObjectType): + upc = external(String(required=True)) + reviews = List(lambda: Review) + +# Note that both the base type and the field need to be decorated with `provides` (on the field itself you need to specify which fields get provided). +@provides +class Review(ObjectType): + body = String() + author = provides(Field(User), fields="username") + product = Field(Product) + +class Query(ObjectType): + review = Field(Review) + +schema = build_schema(query=Query) +``` -@extend(fields='id') -class Message(graphene.ObjectType): - id = external(graphene.Int(required=True)) +### Federation - def resolve_id(self, **kwargs): - return 1 +Note that each schema declaration for the services is a valid graphql schema (it only adds the `_Entity` and `_Service` types). +The best way to check that the decorator are set correctly is to request the service sdl: +```python +from graphql import graphql + +query = """ +query { + _service { + sdl + } +} +""" + +result = graphql(schema, query) +print(result.data["_service"]["sdl"]) ``` -### __resolve_reference -* Each type which is decorated with `@key` or `@extend` is added to `_Entity` union -* `__resolve_reference` method can be defined for each type that is an entity. This method is called whenever an entity is requested as part of the fulfilling a query plan. -If not explicitly defined, default resolver is used. Default resolver just creates instance of type with passed fieldset as kwargs, see [`entity.get_entity_query`](graphene_federation/entity.py) for more details -* You should define `__resolve_reference`, if you need to extract object before passing it to fields resolvers (example: [FileNode](integration_tests/service_b/src/schema.py)) -* You should not define `__resolve_reference`, if fileds resolvers need only data passed in fieldset (example: [FunnyText](integration_tests/service_a/src/schema.py)) -* read more in [official documentation](https://www.apollographql.com/docs/apollo-server/api/apollo-federation/#__resolvereference) +Those can then be used in a federated schema. + +You can find more examples in the unit / integration tests and [examples folder](examples/) +There is also a cool [example](https://github.com/preply/graphene-federation/issues/1) of integration with Mongoengine. + ------------------------ +## Known issues -### Known issues: -1. decorators will not work properly -* on fields with capitalised letters with `auto_camelcase=True`, for example: `my_ABC_field = String()` -* on fields with custom names for example `some_field = String(name='another_name')` +1. decorators will not work properly on fields with custom names for example `some_field = String(name='another_name')` 1. `@key` decorator will not work on [compound primary key](https://www.apollographql.com/docs/federation/entities/#defining-a-compound-primary-key) ---------------------------- -For more details see [examples](examples/) - -Or better check [integration_tests](integration_tests/) +------------------------ -Also cool [example](https://github.com/preply/graphene-federation/issues/1) of integration with Mongoengine +## Contributing +* You can run the unit tests by doing: `make tests`. +* You can run the integration tests by doing `make integration-build && make integration-test`. +* You can get a development environment (on a Docker container) with `make dev-setup`. +* You should use `black` to format your code. -### For contribution: -#### Run tests: -* `make test` -* if you've changed Dockerfile or requirements run `make build` before `make test` +The tests are automatically run on Travis CI on push to GitHub. --------------------------- From ca12c6c092472050ce377b294b2e38ee6ebb05c3 Mon Sep 17 00:00:00 2001 From: Thomas Leonard Date: Wed, 28 Oct 2020 16:09:02 +0100 Subject: [PATCH 11/15] bs --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 7531eb4..7114bf8 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ Federation support for ![Graphene Logo](http://graphene-python.org/favicon.png) [Graphene](http://graphene-python.org) following the [Federation specifications](https://www.apollographql.com/docs/apollo-server/federation/federation-spec/). -Build [![Build Status](https://travis-ci.com/tcleonard/graphene-federation.svg?branch=master)](https://travis-ci.com/tcleonard/graphene-federation) -Coverage: [![Coverage Status](https://coveralls.io/repos/github/tcleonard/graphene-federation/badge.svg)](https://coveralls.io/github/tcleonard/graphene-federation) +[![Build Status](https://travis-ci.com/tcleonard/graphene-federation.svg?branch=master)](https://travis-ci.com/tcleonard/graphene-federation) +[![Coverage Status](https://coveralls.io/repos/github/tcleonard/graphene-federation/badge.svg)](https://coveralls.io/github/tcleonard/graphene-federation) Based on discussion: https://github.com/graphql-python/graphene/issues/953#issuecomment-508481652 @@ -145,7 +145,8 @@ print(result.data["_service"]["sdl"]) Those can then be used in a federated schema. -You can find more examples in the unit / integration tests and [examples folder](examples/) +You can find more examples in the unit / integration tests and [examples folder](examples/). + There is also a cool [example](https://github.com/preply/graphene-federation/issues/1) of integration with Mongoengine. ------------------------ From 3349f08c9891c1fc510ef46a1ddfb9e08cf89390 Mon Sep 17 00:00:00 2001 From: Thomas Leonard Date: Fri, 20 Nov 2020 12:50:56 +0100 Subject: [PATCH 12/15] Add GitHub actions --- .github/workflows/integration_tests.yml | 14 +++++++++ .github/workflows/lint.yml | 20 +++++++++++++ .github/workflows/tests.yml | 25 ++++++++++++++++ .travis.yml | 38 ------------------------- 4 files changed, 59 insertions(+), 38 deletions(-) create mode 100644 .github/workflows/integration_tests.yml create mode 100644 .github/workflows/lint.yml create mode 100644 .github/workflows/tests.yml delete mode 100644 .travis.yml diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml new file mode 100644 index 0000000..86cbe2f --- /dev/null +++ b/.github/workflows/integration_tests.yml @@ -0,0 +1,14 @@ +name: Integration Tests + +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v1 + - name: Build environment + run: make integration-build + - name: Run Integration Tests + run: make integration-test diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..b09bf41 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,20 @@ +name: Lint + +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v1 + - name: Set up Python 3.8 + uses: actions/setup-python@v1 + with: + python-version: 3.8 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + - name: Run lint 💅 + run: black graphene_federation --check diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..92735fb --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,25 @@ +name: Unit Tests + +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + max-parallel: 4 + matrix: + python-version: ["3.6", "3.7", "3.8"] + + steps: + - uses: actions/checkout@v1 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v1 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[test]" + - name: Run Unit Tests + run: py.test graphene_federation --cov=graphene_federation -vv + diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index ddf659f..0000000 --- a/.travis.yml +++ /dev/null @@ -1,38 +0,0 @@ -language: python -dist: xenial - -python: - - 3.6 - - 3.7 - - 3.8 - -install: - - pip install -e ".[test]" - -script: - - py.test graphene_federation --cov=graphene_federation -vv - -after_success: - - pip install coveralls - - coveralls - -stages: - - linting - - test - - integration-test - -jobs: - include: - - stage: linting - python: 3.8 - install: - - pip install -e ".[dev]" - script: - - black graphene_federation --check - - stage: integration-test - python: 3.8 - services: - - docker - script: - - make integration-build - - make integration-test From 8376e0abcd169212ee0cba74770d774f290b8f78 Mon Sep 17 00:00:00 2001 From: Thomas Leonard Date: Fri, 20 Nov 2020 13:09:40 +0100 Subject: [PATCH 13/15] Add coverage --- .github/workflows/tests.yml | 4 ++++ README.md | 10 ++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 92735fb..cecd20c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -22,4 +22,8 @@ jobs: pip install -e ".[test]" - name: Run Unit Tests run: py.test graphene_federation --cov=graphene_federation -vv + - name: Upload Coverage + run: | + pip install coveralls + coveralls diff --git a/README.md b/README.md index 7114bf8..61b11e4 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,14 @@ Federation support for ![Graphene Logo](http://graphene-python.org/favicon.png) [Graphene](http://graphene-python.org) following the [Federation specifications](https://www.apollographql.com/docs/apollo-server/federation/federation-spec/). -[![Build Status](https://travis-ci.com/tcleonard/graphene-federation.svg?branch=master)](https://travis-ci.com/tcleonard/graphene-federation) -[![Coverage Status](https://coveralls.io/repos/github/tcleonard/graphene-federation/badge.svg)](https://coveralls.io/github/tcleonard/graphene-federation) +[![Build Status][build-image]][build-url] +[![Coverage Status][coveralls-image]][coveralls-url] + +[build-image]: https://github.com/loft-orbital/graphene-federation/workflows/Unit%20Tests/badge.svg?branch=loft-master +[build-url]: https://github.com/loft-orbital/graphene-federation/actions +[coveralls-image]: https://coveralls.io/repos/github/tcleonard/graphene-federation/badge.svg?branch=loft-master +[coveralls-url]: https://coveralls.io/repos/github/tcleonard/graphene-federation?branch=loft-master + Based on discussion: https://github.com/graphql-python/graphene/issues/953#issuecomment-508481652 From cb47f9d113052e9a1bac80d06aa0de5ba94d14bf Mon Sep 17 00:00:00 2001 From: Thomas Leonard Date: Fri, 20 Nov 2020 13:16:06 +0100 Subject: [PATCH 14/15] Fix coverall --- .github/workflows/tests.yml | 6 +++--- README.md | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index cecd20c..0fb5d61 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -23,7 +23,7 @@ jobs: - name: Run Unit Tests run: py.test graphene_federation --cov=graphene_federation -vv - name: Upload Coverage - run: | - pip install coveralls - coveralls + uses: coverallsapp/github-action@master + with: + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index 61b11e4..3654d34 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,8 @@ Federation support for ![Graphene Logo](http://graphene-python.org/favicon.png) [build-image]: https://github.com/loft-orbital/graphene-federation/workflows/Unit%20Tests/badge.svg?branch=loft-master [build-url]: https://github.com/loft-orbital/graphene-federation/actions -[coveralls-image]: https://coveralls.io/repos/github/tcleonard/graphene-federation/badge.svg?branch=loft-master -[coveralls-url]: https://coveralls.io/repos/github/tcleonard/graphene-federation?branch=loft-master +[coveralls-image]: https://coveralls.io/repos/github/loft-orbital/graphene-federation/badge.svg?branch=loft-master +[coveralls-url]: https://coveralls.io/github/loft-orbital/graphene-federation?branch=loft-master Based on discussion: https://github.com/graphql-python/graphene/issues/953#issuecomment-508481652 From 41ec6ef69988259f551f4717bf9b860971834bc6 Mon Sep 17 00:00:00 2001 From: Thomas Leonard Date: Fri, 20 Nov 2020 13:28:34 +0100 Subject: [PATCH 15/15] Default actions only support lcov files --- .github/workflows/tests.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0fb5d61..06a4bac 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -23,7 +23,8 @@ jobs: - name: Run Unit Tests run: py.test graphene_federation --cov=graphene_federation -vv - name: Upload Coverage - uses: coverallsapp/github-action@master - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + pip install coveralls + coveralls