diff --git a/graphene_federation/__init__.py b/graphene_federation/__init__.py index 73786d0..22502c5 100644 --- a/graphene_federation/__init__.py +++ b/graphene_federation/__init__.py @@ -1,10 +1,32 @@ +from .directives import ( + extends, + external, + inaccessible, + interface_object, + key, + override, + provides, + requires, + shareable, + tag, +) from .main import build_schema -from .entity import key -from .extend import extend -from .external import external -from .requires import requires -from .shareable import shareable -from .inaccessible import inaccessible -from .provides import provides -from .override import override -from .compose_directive import mark_composable, is_composable +from .schema_directives import compose_directive, link_directive +from .service import get_sdl + +__all__ = [ + "build_schema", + "extends", + "external", + "inaccessible", + "interface_object", + "key", + "override", + "provides", + "requires", + "shareable", + "tag", + "compose_directive", + "link_directive", + "get_sdl", +] diff --git a/graphene_federation/appolo_versions/__init__.py b/graphene_federation/appolo_versions/__init__.py new file mode 100644 index 0000000..6ef0c50 --- /dev/null +++ b/graphene_federation/appolo_versions/__init__.py @@ -0,0 +1,35 @@ +from graphql import GraphQLDirective + +from .v1_0 import get_directives as get_directives_v1_0 +from .v2_0 import get_directives as get_directives_v2_0 +from .v2_1 import get_directives as get_directives_v2_1 +from .v2_2 import get_directives as get_directives_v2_2 +from .v2_3 import get_directives as get_directives_v2_3 + +LATEST_VERSION = "2.3" + + +def get_directives_based_on_version(version: str) -> dict[str, GraphQLDirective]: + if version < "2.0.0": + return get_directives_v1_0() + elif version <= "2.0.0": + return get_directives_v2_0() + elif version <= "2.1.0": + return get_directives_v2_1() + elif version <= "2.2.0": + return get_directives_v2_2() + else: + return get_directives_v2_3() + + +def get_directive_from_name( + directive_name: str, federation_version: str +) -> GraphQLDirective: + directive = get_directives_based_on_version(federation_version).get( + directive_name, None + ) + if directive is None: + raise ValueError( + f"@{directive_name} not found on federation version {federation_version}" + ) + return directive diff --git a/graphene_federation/appolo_versions/spec/federation-v1.0.graphql b/graphene_federation/appolo_versions/spec/federation-v1.0.graphql new file mode 100644 index 0000000..19d492f --- /dev/null +++ b/graphene_federation/appolo_versions/spec/federation-v1.0.graphql @@ -0,0 +1,9 @@ +directive @key(fields: _FieldSet!) repeatable on OBJECT | INTERFACE +directive @requires(fields: _FieldSet!) on FIELD_DEFINITION +directive @provides(fields: _FieldSet!) on FIELD_DEFINITION +directive @external on FIELD_DEFINITION +scalar _FieldSet + +# this is an optional directive +# used in frameworks that don't natively support GraphQL extend syntax +directive @extends on OBJECT | INTERFACE diff --git a/graphene_federation/appolo_versions/spec/federation-v2.0.graphql b/graphene_federation/appolo_versions/spec/federation-v2.0.graphql new file mode 100644 index 0000000..494e97a --- /dev/null +++ b/graphene_federation/appolo_versions/spec/federation-v2.0.graphql @@ -0,0 +1,30 @@ +directive @key(fields: FieldSet!, resolvable: Boolean = true) repeatable on OBJECT | INTERFACE +directive @requires(fields: FieldSet!) on FIELD_DEFINITION +directive @provides(fields: FieldSet!) on FIELD_DEFINITION +directive @external on OBJECT | FIELD_DEFINITION +directive @shareable on FIELD_DEFINITION | OBJECT +directive @extends on OBJECT | INTERFACE +directive @override(from: String!) on FIELD_DEFINITION +directive @inaccessible on + | FIELD_DEFINITION + | OBJECT + | INTERFACE + | UNION + | ENUM + | ENUM_VALUE + | SCALAR + | INPUT_OBJECT + | INPUT_FIELD_DEFINITION + | ARGUMENT_DEFINITION +directive @tag(name: String!) repeatable on + | FIELD_DEFINITION + | INTERFACE + | OBJECT + | UNION + | ARGUMENT_DEFINITION + | SCALAR + | ENUM + | ENUM_VALUE + | INPUT_OBJECT + | INPUT_FIELD_DEFINITION +scalar FieldSet \ No newline at end of file diff --git a/graphene_federation/appolo_versions/spec/federation-v2.1.graphql b/graphene_federation/appolo_versions/spec/federation-v2.1.graphql new file mode 100644 index 0000000..d07dfaf --- /dev/null +++ b/graphene_federation/appolo_versions/spec/federation-v2.1.graphql @@ -0,0 +1,31 @@ +directive @composeDirective(name: String!) repeatable on SCHEMA +directive @extends on OBJECT | INTERFACE +directive @external on OBJECT | FIELD_DEFINITION +directive @key(fields: FieldSet!, resolvable: Boolean = true) repeatable on OBJECT | INTERFACE +directive @inaccessible on + | FIELD_DEFINITION + | OBJECT + | INTERFACE + | UNION + | ENUM + | ENUM_VALUE + | SCALAR + | INPUT_OBJECT + | INPUT_FIELD_DEFINITION + | ARGUMENT_DEFINITION +directive @override(from: String!) on FIELD_DEFINITION +directive @provides(fields: FieldSet!) on FIELD_DEFINITION +directive @requires(fields: FieldSet!) on FIELD_DEFINITION +directive @shareable on FIELD_DEFINITION | OBJECT +directive @tag(name: String!) repeatable on + | FIELD_DEFINITION + | INTERFACE + | OBJECT + | UNION + | ARGUMENT_DEFINITION + | SCALAR + | ENUM + | ENUM_VALUE + | INPUT_OBJECT + | INPUT_FIELD_DEFINITION +scalar FieldSet diff --git a/graphene_federation/appolo_versions/spec/federation-v2.2.graphql b/graphene_federation/appolo_versions/spec/federation-v2.2.graphql new file mode 100644 index 0000000..b75272b --- /dev/null +++ b/graphene_federation/appolo_versions/spec/federation-v2.2.graphql @@ -0,0 +1,31 @@ +directive @composeDirective(name: String!) repeatable on SCHEMA +directive @extends on OBJECT | INTERFACE +directive @external on OBJECT | FIELD_DEFINITION +directive @key(fields: FieldSet!, resolvable: Boolean = true) repeatable on OBJECT | INTERFACE +directive @inaccessible on + | FIELD_DEFINITION + | OBJECT + | INTERFACE + | UNION + | ENUM + | ENUM_VALUE + | SCALAR + | INPUT_OBJECT + | INPUT_FIELD_DEFINITION + | ARGUMENT_DEFINITION +directive @override(from: String!) on FIELD_DEFINITION +directive @provides(fields: FieldSet!) on FIELD_DEFINITION +directive @requires(fields: FieldSet!) on FIELD_DEFINITION +directive @shareable repeatable on FIELD_DEFINITION | OBJECT +directive @tag(name: String!) repeatable on + | FIELD_DEFINITION + | INTERFACE + | OBJECT + | UNION + | ARGUMENT_DEFINITION + | SCALAR + | ENUM + | ENUM_VALUE + | INPUT_OBJECT + | INPUT_FIELD_DEFINITION +scalar FieldSet diff --git a/graphene_federation/appolo_versions/spec/federation-v2.3.graphql b/graphene_federation/appolo_versions/spec/federation-v2.3.graphql new file mode 100644 index 0000000..8bf0015 --- /dev/null +++ b/graphene_federation/appolo_versions/spec/federation-v2.3.graphql @@ -0,0 +1,32 @@ +directive @composeDirective(name: String!) repeatable on SCHEMA +directive @extends on OBJECT | INTERFACE +directive @external on OBJECT | FIELD_DEFINITION +directive @key(fields: FieldSet!, resolvable: Boolean = true) repeatable on OBJECT | INTERFACE +directive @inaccessible on + | FIELD_DEFINITION + | OBJECT + | INTERFACE + | UNION + | ENUM + | ENUM_VALUE + | SCALAR + | INPUT_OBJECT + | INPUT_FIELD_DEFINITION + | ARGUMENT_DEFINITION +directive @interfaceObject on OBJECT +directive @override(from: String!) on FIELD_DEFINITION +directive @provides(fields: FieldSet!) on FIELD_DEFINITION +directive @requires(fields: FieldSet!) on FIELD_DEFINITION +directive @shareable repeatable on FIELD_DEFINITION | OBJECT +directive @tag(name: String!) repeatable on + | FIELD_DEFINITION + | INTERFACE + | OBJECT + | UNION + | ARGUMENT_DEFINITION + | SCALAR + | ENUM + | ENUM_VALUE + | INPUT_OBJECT + | INPUT_FIELD_DEFINITION +scalar FieldSet diff --git a/graphene_federation/appolo_versions/v1_0.py b/graphene_federation/appolo_versions/v1_0.py new file mode 100644 index 0000000..5e2f907 --- /dev/null +++ b/graphene_federation/appolo_versions/v1_0.py @@ -0,0 +1,73 @@ +from graphene_directives import CustomDirective, DirectiveLocation +from graphql import GraphQLArgument, GraphQLDirective, GraphQLNonNull + +from ..scalars import _FieldSet +from ..validators import validate_key, validate_requires + +key_directive = CustomDirective( + name="key", + locations=[ + DirectiveLocation.OBJECT, + DirectiveLocation.INTERFACE, + ], + args={"fields": GraphQLArgument(GraphQLNonNull(_FieldSet))}, + description="Federation @key directive", + is_repeatable=True, + add_definition_to_schema=False, + non_field_validator=validate_key, +) + +requires_directive = CustomDirective( + name="requires", + locations=[ + DirectiveLocation.FIELD_DEFINITION, + ], + args={"fields": GraphQLArgument(GraphQLNonNull(_FieldSet))}, + description="Federation @requires directive", + is_repeatable=True, + add_definition_to_schema=False, + field_validator=validate_requires, +) + + +provides_directive = CustomDirective( + name="provides", + locations=[ + DirectiveLocation.FIELD_DEFINITION, + ], + args={"fields": GraphQLArgument(GraphQLNonNull(_FieldSet))}, + description="Federation @provides directive", + add_definition_to_schema=False, +) + +external_directive = CustomDirective( + name="external", + locations=[ + DirectiveLocation.FIELD_DEFINITION, + ], + description="Federation @external directive", + add_definition_to_schema=False, +) + +extends_directive = CustomDirective( + name="extends", + locations=[ + DirectiveLocation.OBJECT, + DirectiveLocation.INTERFACE, + ], + description="Federation @extends directive", + add_definition_to_schema=False, +) + + +def get_directives() -> dict[str, GraphQLDirective]: + return { + directive.name: directive + for directive in [ + key_directive, + requires_directive, + provides_directive, + external_directive, + extends_directive, + ] + } diff --git a/graphene_federation/appolo_versions/v2_0.py b/graphene_federation/appolo_versions/v2_0.py new file mode 100644 index 0000000..0dccdce --- /dev/null +++ b/graphene_federation/appolo_versions/v2_0.py @@ -0,0 +1,140 @@ +from graphene_directives import CustomDirective, DirectiveLocation +from graphql import ( + GraphQLArgument, + GraphQLBoolean, + GraphQLDirective, + GraphQLNonNull, + GraphQLString, +) + +from .v1_0 import extends_directive +from ..scalars import FieldSet +from ..validators import validate_key, validate_requires + +key_directive = CustomDirective( + name="key", + locations=[ + DirectiveLocation.OBJECT, + DirectiveLocation.INTERFACE, + ], + args={ + "fields": GraphQLArgument(GraphQLNonNull(FieldSet)), + # Changed + "resolvable": GraphQLArgument(GraphQLBoolean, default_value=True), + }, + description="Federation @key directive", + is_repeatable=True, + add_definition_to_schema=False, + non_field_validator=validate_key, +) + +requires_directive = CustomDirective( + name="requires", + locations=[ + DirectiveLocation.FIELD_DEFINITION, + ], + args={"fields": GraphQLArgument(GraphQLNonNull(FieldSet))}, + description="Federation @requires directive", + is_repeatable=True, + add_definition_to_schema=False, + field_validator=validate_requires, +) + + +provides_directive = CustomDirective( + name="provides", + locations=[ + DirectiveLocation.FIELD_DEFINITION, + ], + args={"fields": GraphQLArgument(GraphQLNonNull(FieldSet))}, + description="Federation @provides directive", + add_definition_to_schema=False, +) + + +external_directive = CustomDirective( + name="external", + locations=[ + DirectiveLocation.OBJECT, + DirectiveLocation.FIELD_DEFINITION, + ], + description="Federation @external directive", + add_definition_to_schema=False, +) + + +shareable_directive = CustomDirective( + name="shareable", + locations=[ + DirectiveLocation.FIELD_DEFINITION, + DirectiveLocation.OBJECT, + ], + description="Federation @shareable directive", + add_definition_to_schema=False, +) + + +override_directive = CustomDirective( + name="override", + locations=[ + DirectiveLocation.FIELD_DEFINITION, + ], + args={ + "from": GraphQLArgument(GraphQLNonNull(GraphQLString)), + }, + description="Federation @override directive", + add_definition_to_schema=False, +) + +inaccessible_directive = CustomDirective( + name="inaccessible", + locations=[ + DirectiveLocation.FIELD_DEFINITION, + DirectiveLocation.OBJECT, + DirectiveLocation.INTERFACE, + DirectiveLocation.UNION, + DirectiveLocation.ENUM, + DirectiveLocation.ENUM_VALUE, + DirectiveLocation.SCALAR, + DirectiveLocation.INPUT_OBJECT, + DirectiveLocation.INPUT_FIELD_DEFINITION, + DirectiveLocation.ARGUMENT_DEFINITION, + ], + description="Federation @inaccessible directive", + add_definition_to_schema=False, +) + +tag_directive = CustomDirective( + name="tag", + locations=[ + DirectiveLocation.FIELD_DEFINITION, + DirectiveLocation.INTERFACE, + DirectiveLocation.OBJECT, + DirectiveLocation.UNION, + DirectiveLocation.ARGUMENT_DEFINITION, + DirectiveLocation.SCALAR, + DirectiveLocation.ENUM, + DirectiveLocation.ENUM_VALUE, + DirectiveLocation.INPUT_OBJECT, + DirectiveLocation.INPUT_FIELD_DEFINITION, + ], + description="Federation @tag directive", + add_definition_to_schema=False, +) + + +def get_directives() -> dict[str, GraphQLDirective]: + return { + directive.name: directive + for directive in [ + key_directive, + requires_directive, + provides_directive, + external_directive, + shareable_directive, + extends_directive, + override_directive, + inaccessible_directive, + tag_directive, + ] + } diff --git a/graphene_federation/appolo_versions/v2_1.py b/graphene_federation/appolo_versions/v2_1.py new file mode 100644 index 0000000..18bc3f5 --- /dev/null +++ b/graphene_federation/appolo_versions/v2_1.py @@ -0,0 +1,27 @@ +from graphene_directives import CustomDirective, DirectiveLocation +from graphql import ( + GraphQLArgument, + GraphQLDirective, + GraphQLNonNull, + GraphQLString, +) + +from .v2_0 import get_directives as get_directives_v2_0 + +compose_directive = CustomDirective( + name="composeDirective", + locations=[ + DirectiveLocation.SCHEMA, + ], + args={ + "name": GraphQLArgument(GraphQLNonNull(GraphQLString)), + }, + description="Federation @composeDirective directive", + add_definition_to_schema=False, +) + + +def get_directives() -> dict[str, GraphQLDirective]: + directives = get_directives_v2_0() + directives.update({directive.name: directive for directive in [compose_directive]}) + return directives diff --git a/graphene_federation/appolo_versions/v2_2.py b/graphene_federation/appolo_versions/v2_2.py new file mode 100644 index 0000000..e657351 --- /dev/null +++ b/graphene_federation/appolo_versions/v2_2.py @@ -0,0 +1,24 @@ +from graphene_directives import CustomDirective, DirectiveLocation +from graphql import GraphQLDirective + +from .v2_0 import shareable_directive as sharable_directive_v2_0 +from .v2_1 import get_directives as get_directives_v2_1 + +shareable_directive = CustomDirective( + name="shareable", + locations=[ + DirectiveLocation.FIELD_DEFINITION, + DirectiveLocation.OBJECT, + ], + description="Federation @shareable directive", + add_definition_to_schema=False, + is_repeatable=True, # Changed +) + + +def get_directives() -> dict[str, GraphQLDirective]: + directives = get_directives_v2_1() + directives.update( + {directive.name: directive for directive in [sharable_directive_v2_0]} + ) + return directives diff --git a/graphene_federation/appolo_versions/v2_3.py b/graphene_federation/appolo_versions/v2_3.py new file mode 100644 index 0000000..e179c9a --- /dev/null +++ b/graphene_federation/appolo_versions/v2_3.py @@ -0,0 +1,22 @@ +from graphene_directives import CustomDirective, DirectiveLocation +from graphql import GraphQLDirective + +from .v2_2 import get_directives as get_directives_v2_2 + +interface_object_directive = CustomDirective( + name="interfaceObject", + locations=[ + DirectiveLocation.OBJECT, + ], + description="Federation @interfaceObject directive", + add_definition_to_schema=False, + is_repeatable=True, +) + + +def get_directives() -> dict[str, GraphQLDirective]: + directives = get_directives_v2_2() + directives.update( + {directive.name: directive for directive in [interface_object_directive]} + ) + return directives diff --git a/graphene_federation/compose_directive.py b/graphene_federation/compose_directive.py deleted file mode 100644 index 824d697..0000000 --- a/graphene_federation/compose_directive.py +++ /dev/null @@ -1,64 +0,0 @@ -from typing import Optional - -from graphql import GraphQLDirective - - -def is_composable(directive: GraphQLDirective) -> bool: - """ - Checks if the directive will be composed to supergraph. - Validates the presence of _compose_import_url attribute - """ - return hasattr(directive, "_compose_import_url") - - -def mark_composable( - directive: GraphQLDirective, import_url: str, import_as: Optional[str] = None -) -> GraphQLDirective: - """ - Marks directive with _compose_import_url and _compose_import_as - Enables Identification of directives which are to be composed to supergraph - """ - setattr(directive, "_compose_import_url", import_url) - if import_as: - setattr(directive, "_compose_import_as", import_as) - return directive - - -def compose_directive_schema_extensions(directives: list[GraphQLDirective]): - """ - Generates schema extends string for ComposeDirective - """ - link_schema = "" - compose_directive_schema = "" - # Using dictionary to generate cleaner schema when multiple directives imports from same URL. - links: dict = {} - - for directive in directives: - # TODO: Replace with walrus operator when dropping Python 3.8 support - if hasattr(directive, "_compose_import_url"): - compose_import_url = getattr(directive, "_compose_import_url") - if hasattr(directive, "_compose_import_as"): - compose_import_as = getattr(directive, "_compose_import_as") - import_value = ( - f'{{ name: "@{directive.name}, as: "@{compose_import_as}" }}' - ) - imported_name = compose_import_as - else: - import_value = f'"@{directive.name}"' - imported_name = directive.name - - import_url = compose_import_url - - if links.get(import_url): - links[import_url] = links[import_url].append(import_value) - else: - links[import_url] = [import_value] - - compose_directive_schema += ( - f' @composeDirective(name: "@{imported_name}")\n' - ) - - for import_url in links: - link_schema += f' @link(url: "{import_url}", import: [{",".join(value for value in links[import_url])}])\n' - - return link_schema + compose_directive_schema diff --git a/graphene_federation/directives/__init__.py b/graphene_federation/directives/__init__.py new file mode 100644 index 0000000..e57b374 --- /dev/null +++ b/graphene_federation/directives/__init__.py @@ -0,0 +1,10 @@ +from .extends import extends +from .external import external +from .inaccessible import inaccessible +from .interface_object import interface_object +from .key import key +from .override import override +from .provides import provides +from .requires import requires +from .shareable import shareable +from .tag import tag diff --git a/graphene_federation/directives/extends.py b/graphene_federation/directives/extends.py new file mode 100644 index 0000000..62340f0 --- /dev/null +++ b/graphene_federation/directives/extends.py @@ -0,0 +1,18 @@ +from typing import Any, Callable + +from graphene_directives import directive_decorator + +from ..appolo_versions import LATEST_VERSION, get_directive_from_name + + +def extends( + non_field: Any = None, + *, + federation_version=LATEST_VERSION, +) -> Callable: + directive = get_directive_from_name("extends", federation_version) + + if non_field: + return directive_decorator(directive)(field=None)(non_field) + + return directive_decorator(directive) diff --git a/graphene_federation/directives/external.py b/graphene_federation/directives/external.py new file mode 100644 index 0000000..00a9074 --- /dev/null +++ b/graphene_federation/directives/external.py @@ -0,0 +1,18 @@ +from typing import Any, Callable + +from graphene_directives import directive_decorator + +from .utils import is_non_field +from ..appolo_versions import LATEST_VERSION, get_directive_from_name + + +def external( + field: Any = None, + *, + federation_version=LATEST_VERSION, +) -> Callable: + directive = get_directive_from_name("external", federation_version) + decorator = directive_decorator(directive) + return ( + decorator(field=None)(field) if is_non_field(field) else decorator(field=field) + ) diff --git a/graphene_federation/directives/inaccessible.py b/graphene_federation/directives/inaccessible.py new file mode 100644 index 0000000..487bbd2 --- /dev/null +++ b/graphene_federation/directives/inaccessible.py @@ -0,0 +1,18 @@ +from typing import Any, Callable + +from graphene_directives import directive_decorator + +from .utils import is_non_field +from ..appolo_versions import LATEST_VERSION, get_directive_from_name + + +def inaccessible( + field: Any = None, + *, + federation_version=LATEST_VERSION, +) -> Callable: + directive = get_directive_from_name("inaccessible", federation_version) + decorator = directive_decorator(directive) + return ( + decorator(field=None)(field) if is_non_field(field) else decorator(field=field) + ) diff --git a/graphene_federation/directives/interface_object.py b/graphene_federation/directives/interface_object.py new file mode 100644 index 0000000..5232efa --- /dev/null +++ b/graphene_federation/directives/interface_object.py @@ -0,0 +1,14 @@ +from typing import Callable + +from graphene_directives import directive_decorator + +from ..appolo_versions import LATEST_VERSION, get_directive_from_name + + +def interface_object( + federation_version=LATEST_VERSION, +) -> Callable: + directive = get_directive_from_name( + "interfaceObject", federation_version=federation_version + ) + return directive_decorator(directive) diff --git a/graphene_federation/directives/key.py b/graphene_federation/directives/key.py new file mode 100644 index 0000000..b131021 --- /dev/null +++ b/graphene_federation/directives/key.py @@ -0,0 +1,15 @@ +from typing import Callable + +from graphene_directives import directive_decorator + +from ..appolo_versions import LATEST_VERSION, get_directive_from_name + + +def key( + fields: str, + resolvable: bool = None, + *, + federation_version=LATEST_VERSION, +) -> Callable: + directive = get_directive_from_name("key", federation_version) + return directive_decorator(directive)(fields=fields, resolvable=resolvable) diff --git a/graphene_federation/directives/override.py b/graphene_federation/directives/override.py new file mode 100644 index 0000000..eb1da1c --- /dev/null +++ b/graphene_federation/directives/override.py @@ -0,0 +1,16 @@ +from typing import Any, Callable + +from graphene_directives import directive_decorator + +from ..appolo_versions import LATEST_VERSION, get_directive_from_name + + +def override( + field: Any, + from_: str, + federation_version=LATEST_VERSION, +) -> Callable: + directive = get_directive_from_name( + "override", federation_version=federation_version + ) + return directive_decorator(directive)(field=field, **{"from": from_}) diff --git a/graphene_federation/directives/provides.py b/graphene_federation/directives/provides.py new file mode 100644 index 0000000..c872a6f --- /dev/null +++ b/graphene_federation/directives/provides.py @@ -0,0 +1,18 @@ +from typing import Any, Callable, Union + +from graphene_directives import directive_decorator + +from ..appolo_versions import LATEST_VERSION, get_directive_from_name + + +def provides( + field: Any, + fields: Union[str, list[str]], + federation_version=LATEST_VERSION, +) -> Callable: + directive = get_directive_from_name( + "provides", federation_version=federation_version + ) + return directive_decorator(directive)( + field=field, fields=fields if isinstance(fields, str) else " ".join(fields) + ) diff --git a/graphene_federation/directives/requires.py b/graphene_federation/directives/requires.py new file mode 100644 index 0000000..46b0014 --- /dev/null +++ b/graphene_federation/directives/requires.py @@ -0,0 +1,39 @@ +from typing import Any, Callable, Union + +from graphene_directives import directive_decorator + +from ..appolo_versions import LATEST_VERSION, get_directive_from_name +from ..validators import build_ast + + +def add_typename(fields: dict, level: int = 0) -> str: + new_fields = [] + if level != 0: + new_fields.append("__typename") + for field, value in fields.items(): + if "typename" in field.lower(): + continue + elif len(value) == 0: + new_fields.append(field) + else: + new_fields.extend([field, "{", add_typename(value, level + 1), "}"]) + + return " ".join(new_fields) + + +def requires( + field: Any, + fields: Union[str, list[str]], + federation_version=LATEST_VERSION, +) -> Callable: + directive = get_directive_from_name("requires", federation_version) + fields = add_typename( + build_ast( + input_str=fields if isinstance(fields, str) else " ".join(fields), + valid_special_chars='_()"', + ) + ) + return directive_decorator(directive)( + field=field, + fields=fields, + ) diff --git a/graphene_federation/directives/shareable.py b/graphene_federation/directives/shareable.py new file mode 100644 index 0000000..d1e465f --- /dev/null +++ b/graphene_federation/directives/shareable.py @@ -0,0 +1,19 @@ +from typing import Any, Callable + +from graphene_directives import directive_decorator + +from .utils import is_non_field +from ..appolo_versions import LATEST_VERSION, get_directive_from_name + + +def shareable( + field: Any = None, + federation_version=LATEST_VERSION, +) -> Callable: + directive = get_directive_from_name( + "shareable", federation_version=federation_version + ) + decorator = directive_decorator(directive) + return ( + decorator(field=None)(field) if is_non_field(field) else decorator(field=field) + ) diff --git a/graphene_federation/directives/tag.py b/graphene_federation/directives/tag.py new file mode 100644 index 0000000..267f059 --- /dev/null +++ b/graphene_federation/directives/tag.py @@ -0,0 +1,17 @@ +from typing import Any, Callable + +from graphene_directives import directive_decorator + +from .utils import is_non_field +from ..appolo_versions import LATEST_VERSION, get_directive_from_name + + +def tag( + field: Any = None, + federation_version=LATEST_VERSION, +) -> Callable: + directive = get_directive_from_name("tag", federation_version=federation_version) + decorator = directive_decorator(directive) + return ( + decorator(field=None)(field) if is_non_field(field) else decorator(field=field) + ) diff --git a/graphene_federation/directives/utils.py b/graphene_federation/directives/utils.py new file mode 100644 index 0000000..5ccf8e4 --- /dev/null +++ b/graphene_federation/directives/utils.py @@ -0,0 +1,20 @@ +from typing import Any + +from graphene import Enum, InputObjectType, Interface, ObjectType, Scalar, Union + + +def is_non_field(field: Any): + try: + if ( + issubclass(field, ObjectType) + or issubclass(field, Interface) + or issubclass(field, InputObjectType) + or issubclass(field, Enum) + or issubclass(field, Union) + or issubclass(field, Scalar) + ): + return True + else: + return False + except TypeError: + return False diff --git a/graphene_federation/entity.py b/graphene_federation/entity.py index e99cff9..7c7adcc 100644 --- a/graphene_federation/entity.py +++ b/graphene_federation/entity.py @@ -1,27 +1,31 @@ from __future__ import annotations -import collections.abc -from typing import Any, Callable, Dict +from typing import Any, Callable +from typing import Dict, Type from graphene import Field, List, NonNull, ObjectType, Union -from graphene.types.schema import Schema +from graphene import Schema from graphene.types.schema import TypeMap +from graphene.utils.str_converters import to_camel_case +from graphene_directives.utils import has_non_field_attribute -from .types import _Any -from .utils import ( - check_fields_exist_on_type, - field_name_to_type_attribute, - is_valid_compound_key, -) +from .appolo_versions import LATEST_VERSION, get_directive_from_name +from .scalars import _Any -def update(d, u): - for k, v in u.items(): - if isinstance(v, collections.abc.Mapping): - d[k] = update(d.get(k, {}), v) - else: - d[k] = v - return d +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 getattr(model._meta, "fields", []) + } + return lambda schema_field_name: field_names.get( + schema_field_name, schema_field_name + ) def get_entities(schema: Schema) -> Dict[str, Any]: @@ -32,25 +36,27 @@ def get_entities(schema: Schema) -> Dict[str, Any]: """ type_map: TypeMap = schema.graphql_schema.type_map entities = {} + key_directive = get_directive_from_name("key", LATEST_VERSION) + extends_directive = get_directive_from_name("extends", LATEST_VERSION) for type_name, type_ in type_map.items(): if not hasattr(type_, "graphene_type"): continue - if getattr(type_.graphene_type, "_keys", None): - entities[type_name] = type_.graphene_type - - # Validation for compound keys - key_str = " ".join(type_.graphene_type._keys) - type_name = type_.graphene_type._meta.name - if "{" in key_str: # checking for subselection to identify compound key - assert is_valid_compound_key( - type_name, key_str, schema - ), f'Invalid compound key definition for type "{type_name}"' + + graphene_type = type_.graphene_type + is_entity = any( + [ + has_non_field_attribute(graphene_type, key_directive), + has_non_field_attribute(graphene_type, extends_directive), + ] + ) + if is_entity: + entities[type_name] = graphene_type return entities -def get_entity_cls(entities: Dict[str, Any]) -> Union: +def get_entity_cls(entities: Dict[str, Any]) -> Type[Union]: """ - Create _Entity type which is a union of all the entities types. + Create _Entity type which is a union of all the entity types. """ class _Entity(Union): @@ -136,40 +142,12 @@ def resolve_entities(self, info, representations, sub_field_resolution=False): resolver = getattr( model, "_%s__resolve_reference" % model.__name__, None ) or getattr(model, "_resolve_reference", None) + if resolver and not sub_field_resolution: model_instance = resolver(model_instance, info) entities.append(model_instance) + return entities return EntityQuery - - -def key(fields: str, resolvable: bool = True) -> Callable: - """ - 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 - """ - - def decorator(type_): - # Check the provided fields actually exist on the Type. - if " " not in fields: - assert ( - fields in type_._meta.fields - ), f'Field "{fields}" does not exist on type "{type_._meta.name}"' - if "{" not in fields: - # Skip valid fields check if the key is a compound key. The validation for compound keys - # is done on calling get_entities() - fields_set = set(fields.split(" ")) - assert check_fields_exist_on_type( - fields=fields_set, type_=type_ - ), f'Field "{fields}" does not exist on type "{type_._meta.name}"' - - keys = getattr(type_, "_keys", []) - keys.append(fields) - setattr(type_, "_keys", keys) - setattr(type_, "_resolvable", resolvable) - - return type_ - - return decorator diff --git a/graphene_federation/extend.py b/graphene_federation/extend.py deleted file mode 100644 index c6fb972..0000000 --- a/graphene_federation/extend.py +++ /dev/null @@ -1,62 +0,0 @@ -from typing import Any, Callable, Dict - -from graphene import Schema - -from graphene_federation.utils import check_fields_exist_on_type, is_valid_compound_key - - -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.graphql_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 - - # Validation for compound keys - key_str = " ".join(type_.graphene_type._keys) - type_name = type_.graphene_type._meta.name - if "{" in key_str: # checking for subselection to identify compound key - assert is_valid_compound_key( - type_name, key_str, schema - ), f'Invalid compound key definition for type "{type_name}"' - return extended_types - - -def extend(fields: str) -> Callable: - """ - Decorator to use to extend a given type. - The field to extend must be provided as input as a string. - """ - - def decorator(type_): - 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. - - if "{" not in fields: # Check for compound keys - # Skip valid fields check if the key is a compound key. The validation for compound keys - # is done on calling get_extended_types() - fields_set = set(fields.split(" ")) - assert check_fields_exist_on_type( - fields=fields_set, type_=type_ - ), f'Field "{fields}" does not exist on type "{type_._meta.name}"' - - assert getattr(type_._meta, "description", None) is None, ( - f'Type "{type_.__name__}" has a non empty description and it is also marked with extend.' - "\nThey are mutually exclusive." - "\nSee https://github.com/graphql/graphql-js/issues/2385#issuecomment-577997521" - ) - # 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 diff --git a/graphene_federation/external.py b/graphene_federation/external.py deleted file mode 100644 index 89b6713..0000000 --- a/graphene_federation/external.py +++ /dev/null @@ -1,41 +0,0 @@ -from graphene import Schema -from graphene.types.objecttype import ObjectTypeMeta - -from graphene_federation.utils import get_attributed_fields - - -def external(field): - """ - Mark a field as external. - """ - if isinstance(field, ObjectTypeMeta): - field._external_entity = True - else: - field._external = True - return field - - -def get_external_fields(schema: Schema) -> dict: - """ - Find all the extended types from the schema. - They can be easily distinguished from the other type as - the `@external` decorator adds a `_external` attribute to them. - """ - return get_attributed_fields(attribute="_external", schema=schema) - - -def get_external_object_types(schema: Schema) -> dict: - """ - Find all the extended object types from the schema. - They can be easily distinguished from the other type as - the `@external` decorator adds a `_external_entity` attribute to them. - """ - fields = {} - - for type_name, type_ in schema.graphql_schema.type_map.items(): - if hasattr(type_, "graphene_type") and hasattr( - type_.graphene_type, "_external_entity" - ): - fields[type_name] = type_ - - return fields diff --git a/graphene_federation/inaccessible.py b/graphene_federation/inaccessible.py deleted file mode 100644 index fffc44a..0000000 --- a/graphene_federation/inaccessible.py +++ /dev/null @@ -1,46 +0,0 @@ -from typing import Any, Dict, Optional - -from graphene import Schema - -from graphene_federation.utils import get_attributed_fields - - -def get_inaccessible_types(schema: Schema) -> Dict[str, Any]: - """ - Find all the inaccessible types from the schema. - They can be easily distinguished from the other type as - the `@inaccessible` decorator adds a `_inaccessible` attribute to them. - """ - inaccessible_types = {} - for type_name, type_ in schema.graphql_schema.type_map.items(): - if not hasattr(type_, "graphene_type"): - continue - if getattr(type_.graphene_type, "_inaccessible", False): - inaccessible_types[type_name] = type_.graphene_type - return inaccessible_types - - -def inaccessible(field: Optional[Any] = None) -> Any: - """ - Decorator to use to inaccessible a given type. - """ - - # noinspection PyProtectedMember,PyPep8Naming - def decorator(field_or_type): - # TODO Check the provided fields actually exist on the Type. - # Set a `_inaccessible` attribute to be able to distinguish it from the other entities - setattr(field_or_type, "_inaccessible", True) - return field_or_type - - if field: - return decorator(field) - return decorator - - -def get_inaccessible_fields(schema: Schema) -> dict: - """ - Find all the inacessible types from the schema. - They can be easily distinguished from the other type as - the `@inaccessible` decorator adds a `_inaccessible` attribute to them. - """ - return get_attributed_fields(attribute="_inaccessible", schema=schema) diff --git a/graphene_federation/main.py b/graphene_federation/main.py index 87ad5ca..518617d 100644 --- a/graphene_federation/main.py +++ b/graphene_federation/main.py @@ -1,13 +1,29 @@ +from typing import Collection, Type, Union from typing import Optional -from graphene import Schema -from graphene import ObjectType +from graphene import ObjectType, PageInfo +from graphene_directives import ( + SchemaDirective, + build_schema as build_directive_schema, + directive_decorator, +) +from graphene_directives.schema import Schema +from graphql import GraphQLDirective +from .appolo_versions import ( + LATEST_VERSION, + get_directive_from_name, + get_directives_based_on_version, +) +from .appolo_versions.v2_1 import compose_directive from .entity import get_entity_query +from .schema_directives import link_directive from .service import get_service_query -def _get_query(schema: Schema, query_cls: Optional[ObjectType] = None) -> ObjectType: +def _get_query( + schema: Schema, query_cls: Optional[ObjectType] = None +) -> Type[ObjectType]: type_name = "Query" bases = [get_service_query(schema)] entity_cls = get_entity_query(schema) @@ -21,22 +37,85 @@ def _get_query(schema: Schema, query_cls: Optional[ObjectType] = None) -> Object def build_schema( - query: Optional[ObjectType] = None, - mutation: Optional[ObjectType] = None, - federation_version: Optional[float] = None, - enable_federation_2: bool = False, - schema: Optional[Schema] = None, - **kwargs + query: Union[ObjectType, Type[ObjectType]] = None, + mutation: Union[ObjectType, Type[ObjectType]] = None, + subscription: Union[ObjectType, Type[ObjectType]] = None, + types: Collection[Union[ObjectType, Type[ObjectType]]] = None, + directives: Union[Collection[GraphQLDirective], None] = None, + schema_directives: Collection[SchemaDirective] = None, + auto_camelcase: bool = True, + enable_federation_2: bool = True, + federation_version: str = None, ) -> Schema: - schema = schema or Schema(query=query, mutation=mutation, **kwargs) - schema.auto_camelcase = kwargs.get("auto_camelcase", True) - schema.federation_version = float( - (federation_version or 2) if (enable_federation_2 or federation_version) else 1 + federation_version = ( + federation_version + if federation_version + else LATEST_VERSION + if enable_federation_2 + else "1.0" ) - federation_query = _get_query(schema, schema.query) - # Use shallow copy to prevent recursion error - kwargs = schema.__dict__.copy() - kwargs.pop("query") - kwargs.pop("graphql_schema") - kwargs.pop("federation_version") - return type(schema)(query=federation_query, **kwargs) + enable_federation_2 = federation_version > "1.0" + + _types = list(types) if types is not None else [] + + _directives = get_directives_based_on_version(federation_version) + federation_directives = set(_directives.keys()) + if directives is not None: + _directives.update({directive.name: directive for directive in directives}) + + schema_args = { + "mutation": mutation, + "subscription": subscription, + "types": _types, + "directives": _directives.values(), + "auto_camelcase": auto_camelcase, + } + + schema: Schema = build_directive_schema(query=query, **schema_args) + + if "PageInfo" in schema.graphql_schema.type_map: + # PageInfo needs @sharable directive + try: + sharable = get_directive_from_name("shareable", federation_version) + + _types.append( + directive_decorator(target_directive=sharable)(field=None)(PageInfo) + ) + except ValueError: + # Federation Version does not support @sharable + pass + + _schema_directives = [] + directives_used = schema.get_directives_used() + if schema_directives: + if not enable_federation_2: + raise ValueError( + f"Schema Directives are not supported on {federation_version=}. Use >=2.0 " + ) + + if any( + schema_directive.target_directive == compose_directive + for schema_directive in schema_directives + ): + directives_used.append(compose_directive) + + if directives_used: + imports = [ + str(directive) + for directive in directives_used + if directive.name in federation_directives + ] + if imports: + _schema_directives.append( + link_directive( + url=f"https://specs.apollo.dev/federation/v{federation_version}", + import_=sorted(imports), + ) + ) + + if schema_directives: + _schema_directives.extend(list(schema_directives)) + + schema_args["schema_directives"] = _schema_directives if enable_federation_2 else [] + schema = build_directive_schema(query=query, **schema_args) + return build_directive_schema(query=_get_query(schema, schema.query), **schema_args) diff --git a/graphene_federation/override.py b/graphene_federation/override.py deleted file mode 100644 index fb8dac8..0000000 --- a/graphene_federation/override.py +++ /dev/null @@ -1,20 +0,0 @@ -from graphene import Schema - -from graphene_federation.utils import get_attributed_fields - - -def override(field, from_: str): - """ - Decorator to use to override a given type. - """ - field._override = from_ - return field - - -def get_override_fields(schema: Schema) -> dict: - """ - Find all the overridden types from the schema. - They can be easily distinguished from the other type as - the `@override` decorator adds a `_override` attribute to them. - """ - return get_attributed_fields(attribute="_override", schema=schema) diff --git a/graphene_federation/provides.py b/graphene_federation/provides.py deleted file mode 100644 index ee59bc7..0000000 --- a/graphene_federation/provides.py +++ /dev/null @@ -1,50 +0,0 @@ -from typing import Any, Union, Dict, List - -from graphene import Field -from graphene import Schema - -from graphene_federation.utils import get_attributed_fields - - -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.graphql_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: Union[str, List[str]] = None): - """ - - :param field: base type (when used as decorator) or field of base type - :param fields: - :return: - """ - if fields is None: # used as decorator on base type - if isinstance(field, Field): - 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 - - -def get_provides_fields(schema: Schema) -> dict: - """ - Find all the extended types from the schema. - They can be easily distinguished from the other type as - the `@provides` decorator adds a `_provides` attribute to them. - """ - return get_attributed_fields(attribute="_provides", schema=schema) diff --git a/graphene_federation/requires.py b/graphene_federation/requires.py deleted file mode 100644 index e270e5b..0000000 --- a/graphene_federation/requires.py +++ /dev/null @@ -1,31 +0,0 @@ -from typing import List, Union - -from graphene import Schema - -from graphene_federation.utils import get_attributed_fields - - -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 - - -def get_required_fields(schema: Schema) -> dict: - """ - Find all the extended types with required fields from the schema. - They can be easily distinguished from the other type as - the `@requires` decorator adds a `_requires` attribute to them. - """ - return get_attributed_fields(attribute="_requires", schema=schema) diff --git a/graphene_federation/scalars/__init__.py b/graphene_federation/scalars/__init__.py new file mode 100644 index 0000000..9fb3066 --- /dev/null +++ b/graphene_federation/scalars/__init__.py @@ -0,0 +1,5 @@ +from ._any import _Any +from .field_set_v1 import _FieldSet +from .field_set_v2 import FieldSet +from .link_import import link_import +from .link_purpose import link_purpose diff --git a/graphene_federation/types.py b/graphene_federation/scalars/_any.py similarity index 80% rename from graphene_federation/types.py rename to graphene_federation/scalars/_any.py index 65d7dea..63f526a 100644 --- a/graphene_federation/types.py +++ b/graphene_federation/scalars/_any.py @@ -2,7 +2,10 @@ class _Any(Scalar): + name = "_Any" __typename = String(required=True) + description = None + specified_by_url = None @staticmethod def serialize(dt): diff --git a/graphene_federation/scalars/field_set_v1.py b/graphene_federation/scalars/field_set_v1.py new file mode 100644 index 0000000..3bd6d5a --- /dev/null +++ b/graphene_federation/scalars/field_set_v1.py @@ -0,0 +1,9 @@ +from graphql import GraphQLString + +# _FieldSet = GraphQLScalarType(name="_FieldSet") + +""" +To avoid _FieldSet from coming into schema we are defining it as String +""" + +_FieldSet = GraphQLString diff --git a/graphene_federation/scalars/field_set_v2.py b/graphene_federation/scalars/field_set_v2.py new file mode 100644 index 0000000..7e26458 --- /dev/null +++ b/graphene_federation/scalars/field_set_v2.py @@ -0,0 +1,9 @@ +from graphql import GraphQLString + +# FieldSet = GraphQLScalarType(name="FieldSet") + +""" +To avoid FieldSet from coming into schema we are defining it as String +""" + +FieldSet = GraphQLString diff --git a/graphene_federation/scalars/link_import.py b/graphene_federation/scalars/link_import.py new file mode 100644 index 0000000..06c4717 --- /dev/null +++ b/graphene_federation/scalars/link_import.py @@ -0,0 +1,59 @@ +from math import isfinite +from typing import Any + +from graphql import ( + GraphQLError, + GraphQLScalarType, + StringValueNode, + ValueNode, + print_ast, +) +from graphql.pyutils import inspect + + +def _serialize_string(output_value: Any) -> str: + if isinstance(output_value, str): + return output_value + if isinstance(output_value, bool): + return "true" if output_value else "false" + if isinstance(output_value, int) or ( + isinstance(output_value, float) and isfinite(output_value) + ): + return str(output_value) + # do not serialize builtin types as strings, but allow serialization of custom + # types via their `__str__` method + if type(output_value).__module__ == "builtins": + raise GraphQLError( + "link__Import cannot represent value: " + inspect(output_value) + ) + + return str(output_value) + + +def _coerce_string(input_value: Any) -> str: + if not isinstance(input_value, str): + raise GraphQLError( + "link__Import cannot represent a non string value: " + inspect(input_value) + ) + return input_value + + +def _parse_string_literal(value_node: ValueNode, _variables: Any = None) -> str: + """Parse a string value node in the AST.""" + if not isinstance(value_node, StringValueNode): + raise GraphQLError( + "link__Import cannot represent a non string value: " + + print_ast(value_node), + value_node, + ) + return value_node.value + + +# Reference: https://www.apollographql.com/docs/federation/subgraph-spec/ + +link_import = GraphQLScalarType( + name="link__Import", + serialize=_serialize_string, + parse_value=_coerce_string, + parse_literal=_parse_string_literal, +) diff --git a/graphene_federation/scalars/link_purpose.py b/graphene_federation/scalars/link_purpose.py new file mode 100644 index 0000000..2aade95 --- /dev/null +++ b/graphene_federation/scalars/link_purpose.py @@ -0,0 +1,17 @@ +from graphql import GraphQLEnumType, GraphQLEnumValue + +# Reference: https://www.apollographql.com/docs/federation/subgraph-spec/ + +link_purpose = GraphQLEnumType( + name="link__Purpose", + values={ + "SECURITY": GraphQLEnumValue( + value="SECURITY", + description="`SECURITY` features provide metadata necessary to securely resolve fields.", + ), + "EXECUTION": GraphQLEnumValue( + value="EXECUTION", + description="`EXECUTION` features provide metadata necessary for operation execution.", + ), + }, +) diff --git a/graphene_federation/schema_directives/__init__.py b/graphene_federation/schema_directives/__init__.py new file mode 100644 index 0000000..7339a26 --- /dev/null +++ b/graphene_federation/schema_directives/__init__.py @@ -0,0 +1,2 @@ +from .compose_directive import compose_directive +from .link_directive import link_directive diff --git a/graphene_federation/schema_directives/compose_directive.py b/graphene_federation/schema_directives/compose_directive.py new file mode 100644 index 0000000..b89dbf8 --- /dev/null +++ b/graphene_federation/schema_directives/compose_directive.py @@ -0,0 +1,14 @@ +from graphene_directives import SchemaDirective + +from ..appolo_versions import LATEST_VERSION, get_directive_from_name + + +def compose_directive( + name: str, + federation_version=LATEST_VERSION, +) -> SchemaDirective: + directive = get_directive_from_name("composeDirective", federation_version) + return SchemaDirective( + target_directive=directive, + arguments={"name": name}, + ) diff --git a/graphene_federation/schema_directives/link_directive.py b/graphene_federation/schema_directives/link_directive.py new file mode 100644 index 0000000..fb0d2e8 --- /dev/null +++ b/graphene_federation/schema_directives/link_directive.py @@ -0,0 +1,39 @@ +from typing import Optional + +from graphene_directives import CustomDirective, DirectiveLocation, SchemaDirective +from graphql import ( + GraphQLArgument, + GraphQLList, + GraphQLNonNull, + GraphQLString, +) + +from ..scalars import link_import, link_purpose + +_link_directive = CustomDirective( + name="link", + locations=[ + DirectiveLocation.SCHEMA, + ], + args={ + "url": GraphQLArgument(GraphQLNonNull(GraphQLString)), + "as": GraphQLArgument(GraphQLString), + "for": GraphQLArgument(link_purpose), + "import": GraphQLArgument(GraphQLList(link_import)), + }, + description="Federation @link directive", + add_definition_to_schema=False, + is_repeatable=True, +) + + +def link_directive( + url: str, + as_: Optional[str] = None, + for_: Optional[str] = None, + import_: Optional[list[str]] = None, +) -> SchemaDirective: + return SchemaDirective( + target_directive=_link_directive, + arguments={"url": url, "as": as_, "for": for_, "import": import_}, + ) diff --git a/graphene_federation/service.py b/graphene_federation/service.py index 84dbc72..ab0b2c2 100644 --- a/graphene_federation/service.py +++ b/graphene_federation/service.py @@ -1,286 +1,21 @@ -import re -from typing import List +from graphene import Field, ObjectType, String +from graphene_directives.schema import Schema +from graphql.utilities.print_schema import print_scalar -from graphene.types.interface import InterfaceOptions -from graphene.types.union import UnionOptions -from graphql import GraphQLInterfaceType, GraphQLObjectType +from .scalars import FieldSet, _FieldSet -from .compose_directive import is_composable, compose_directive_schema_extensions -from .external import get_external_fields, get_external_object_types -from .inaccessible import get_inaccessible_types, get_inaccessible_fields -from .override import get_override_fields -from .requires import get_required_fields -from .shareable import get_shareable_types, get_shareable_fields -from graphql.utilities.print_schema import print_fields -from graphene import ObjectType, String, Field, Schema - -from .extend import get_extended_types -from .provides import get_provides_parent_types, get_provides_fields - -from .entity import get_entities -from .tag import get_tagged_fields -from .utils import field_name_to_type_attribute, type_attribute_to_field_name - - -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} - - -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]) - - -def convert_fields_for_requires(schema: Schema, fields: List[str]) -> str: - """ - Adds __typename for resolving union,sub-field types - """ - get_field_name = type_attribute_to_field_name(schema) - new_fields = [] - for field in fields: - if "typename" not in field.lower(): # skip user defined typename - new_fields.append(get_field_name(field)) - if "{" in field: - new_fields.append("__typename") - - return " ".join(new_fields) - - -DECORATORS = { - "_external": lambda schema, fields: "@external", - "_requires": lambda schema, fields: f'@requires(fields: "{convert_fields_for_requires(schema, fields)}")', - "_provides": lambda schema, fields: f'@provides(fields: "{convert_fields(schema, fields)}")', - "_shareable": lambda schema, fields: "@shareable", - "_inaccessible": lambda schema, fields: "@inaccessible", - "_override": lambda schema, from_: f'@override(from: "{from_}")', - "_tag": lambda schema, name: f'@tag(name: "{name}")', -} - - -def field_to_string(field) -> str: - str_field = print_fields(field) - # Remove blocks added by `print_block` - block_match = re.match(r" \{\n(?P.*)\n\}", str_field, flags=re.DOTALL) - if block_match: - str_field = block_match.groups()[0] - return str_field - - -def add_entity_fields_decorators(entity, schema: Schema, string_schema: str) -> str: - """ - For a given entity, go through all its fields 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 fields 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.graphql_schema.get_type(entity_name) - str_fields = [] - get_model_attr = field_name_to_type_attribute(schema, entity) - for field_name, field in ( - entity_type.fields.items() if getattr(entity_type, "fields", None) else [] - ): - str_field = field_to_string(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(schema, decorator_value)}" - str_fields.append(str_field) - str_fields_annotated = "\n".join(str_fields) - # Replace the original field declaration by the annotated one - if isinstance(entity_type, GraphQLObjectType) or isinstance( - entity_type, GraphQLInterfaceType - ): - str_fields_original = field_to_string(entity_type) - else: - str_fields_original = "" - pattern = re.compile( - r"(type\s%s\s[^\{]*)\{\s*%s\s*\}" - % (entity_name, re.escape(str_fields_original)) - ) - string_schema = pattern.sub(r"\g<1> {\n%s\n}" % str_fields_annotated, string_schema) - return string_schema - - -def get_sdl(schema: Schema) -> str: +def get_sdl(schema) -> str: """ Add all needed decorators to the string representation of the schema. """ string_schema = str(schema) + # Remove All Scalar definitions + for scalar in [_FieldSet, FieldSet]: + string_schema = string_schema.replace(print_scalar(scalar), "") - regex = r"schema \{(\w|\!|\s|\:)*\}" - pattern = re.compile(regex) - string_schema = pattern.sub(" ", string_schema) - - # Get various objects that need to be amended - extended_types = get_extended_types(schema) - provides_parent_types = get_provides_parent_types(schema) - provides_fields = get_provides_fields(schema) - entities = get_entities(schema) - required_fields = get_required_fields(schema) - external_fields = get_external_fields(schema) - external_object_types = get_external_object_types(schema) - override_fields = get_override_fields(schema) - - schema_extensions = [] - - if schema.federation_version >= 2: - shareable_types = get_shareable_types(schema) - inaccessible_types = get_inaccessible_types(schema) - shareable_fields = get_shareable_fields(schema) - tagged_fields = get_tagged_fields(schema) - inaccessible_fields = get_inaccessible_fields(schema) - - federation_spec_import = [] - - if extended_types or external_object_types: - federation_spec_import.append('"@extends"') - if external_fields: - federation_spec_import.append('"@external"') - if entities: - federation_spec_import.append('"@key"') - if override_fields: - federation_spec_import.append('"@override"') - if provides_parent_types or provides_fields: - federation_spec_import.append('"@provides"') - if required_fields: - federation_spec_import.append('"@requires"') - if inaccessible_types or inaccessible_fields: - federation_spec_import.append('"@inaccessible"') - if shareable_types or shareable_fields: - federation_spec_import.append('"@shareable"') - if tagged_fields: - federation_spec_import.append('"@tag"') - - if schema.federation_version >= 2.1 and hasattr(schema, "directives"): - preserved_directives = [ - directive for directive in schema.directives if is_composable(directive) - ] - if preserved_directives: - federation_spec_import.append('"@composeDirective"') - schema_extensions.append( - compose_directive_schema_extensions(preserved_directives) - ) - - schema_import = ", ".join(federation_spec_import) - schema_extensions = [ - f'@link(url: "https://specs.apollo.dev/federation/v{schema.federation_version}", import: [{schema_import}])' - ] + schema_extensions - - # Add fields directives (@external, @provides, @requires, @shareable, @inaccessible) - entities_ = ( - set(provides_parent_types.values()) - | set(extended_types.values()) - | set(entities.values()) - | set(required_fields.values()) - | set(provides_fields.values()) - ) - - if schema.federation_version >= 2: - entities_ = ( - entities_ - | set(shareable_types.values()) - | set(inaccessible_types.values()) - | set(inaccessible_fields.values()) - | set(shareable_fields.values()) - | set(tagged_fields.values()) - ) - for entity in entities_: - string_schema = add_entity_fields_decorators(entity, schema, string_schema) - - # Prepend `extend` keyword to the type definition of extended types - # noinspection DuplicatedCode - for entity_name, entity in extended_types.items(): - type_def = re.compile(rf"type {entity_name} ([^{{]*)") - repl_str = rf"extend type {entity_name} \1" - string_schema = type_def.sub(repl_str, string_schema) - - # Add entity keys declarations - get_field_name = type_attribute_to_field_name(schema) - for entity_name, entity in entities.items(): - type_def_re = rf"(type {entity_name} [^\{{]*)" + " " - - # resolvable argument of @key directive is true by default. If false, we add 'resolvable: false' to sdl. - if ( - schema.federation_version >= 2 - and hasattr(entity, "_resolvable") - and not entity._resolvable - ): - type_annotation = ( - ( - " ".join( - [ - f'@key(fields: "{get_field_name(key)}"' - for key in entity._keys - ] - ) - ) - + f", resolvable: {str(entity._resolvable).lower()})" - + " " - ) - else: - type_annotation = ( - " ".join( - [f'@key(fields: "{get_field_name(key)}")' for key in entity._keys] - ) - + " " - ) - repl_str = rf"\1{type_annotation}" - pattern = re.compile(type_def_re) - string_schema = pattern.sub(repl_str, string_schema) - - if schema.federation_version >= 2: - # Add `@external` keyword to the type definition of external object types - for object_type_name, _ in external_object_types.items(): - type_def = re.compile(rf"type {object_type_name} ([^{{]*)") - repl_str = rf"type {object_type_name} @external \1" - string_schema = type_def.sub(repl_str, string_schema) - - for type_name, type in shareable_types.items(): - # noinspection PyProtectedMember - if isinstance(type._meta, UnionOptions): - type_def_re = rf"(union {type_name})" - else: - type_def_re = rf"(type {type_name} [^\{{]*)" + " " - type_annotation = " @shareable" - repl_str = rf"\1{type_annotation} " - pattern = re.compile(type_def_re) - string_schema = pattern.sub(repl_str, string_schema) - - for type_name, type in inaccessible_types.items(): - # noinspection PyProtectedMember - if isinstance(type._meta, InterfaceOptions): - type_def_re = rf"(interface {type_name}[^\{{]*)" - elif isinstance(type._meta, UnionOptions): - type_def_re = rf"(union {type_name})" - else: - type_def_re = rf"(type {type_name} [^\{{]*)" + " " - type_annotation = " @inaccessible" - repl_str = rf"\1{type_annotation} " - pattern = re.compile(type_def_re) - string_schema = pattern.sub(repl_str, string_schema) - - if schema_extensions: - string_schema = ( - "extend schema\n " + "\n".join(schema_extensions) + "\n" + string_schema - ) - - return string_schema + return string_schema.strip() def get_service_query(schema: Schema): @@ -289,13 +24,13 @@ def get_service_query(schema: Schema): class _Service(ObjectType): sdl = String() - def resolve_sdl(parent, _): + def resolve_sdl(self, _) -> str: # noqa return sdl_str class ServiceQuery(ObjectType): _service = Field(_Service, name="_service", required=True) - def resolve__service(parent, info): + def resolve__service(self, info) -> _Service: # noqa return _Service() return ServiceQuery diff --git a/graphene_federation/shareable.py b/graphene_federation/shareable.py deleted file mode 100644 index 29634f6..0000000 --- a/graphene_federation/shareable.py +++ /dev/null @@ -1,67 +0,0 @@ -from typing import Any, Dict, Optional - -from graphene import Schema -from graphene.types.interface import InterfaceOptions - -from graphene_federation.utils import get_attributed_fields - - -def get_shareable_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 `@shareable` decorator adds a `_shareable` attribute to them. - """ - shareable_types = {} - for type_name, type_ in schema.graphql_schema.type_map.items(): - if not hasattr(type_, "graphene_type"): - continue - if type_name == "PageInfo" or getattr(type_.graphene_type, "_shareable", False): - shareable_types[type_name] = type_.graphene_type - return shareable_types - - -def shareable(field: Optional[Any] = None) -> Any: - """ - Decorator to use to shareable a given type. - """ - - # noinspection PyProtectedMember,PyPep8Naming - def decorator(type_): - assert not hasattr( - type_, "_keys" - ), "Can't extend type which is already extended or has @key" - 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 getattr(type_._meta, "description", None) is None, ( - f'Type "{type_.__name__}" has a non empty description and it is also marked with extend.' - "\nThey are mutually exclusive." - "\nSee https://github.com/graphql/graphql-js/issues/2385#issuecomment-577997521" - ) - # Set a `_shareable` attribute to be able to distinguish it from the other entities - setattr(type_, "_shareable", True) - return type_ - - if field: - assert not isinstance(field._meta, InterfaceOptions), ( - "The @Shareable directive is about indicating when an object field " - "can be resolved by multiple subgraphs. As interface fields are not " - "directly resolved (their implementation is), @Shareable is not " - "meaningful on an interface field and is not allowed (at least since " - "federation 2.2; earlier versions of federation 2 mistakenly ignored " - "@Shareable on interface fields). " - ) - field._shareable = True - return field - return decorator - - -def get_shareable_fields(schema: Schema) -> dict: - """ - Find all the extended types from the schema. - They can be easily distinguished from the other type as - the `@shareable` decorator adds a `_shareable` attribute to them. - """ - return get_attributed_fields(attribute="_shareable", schema=schema) diff --git a/graphene_federation/tag.py b/graphene_federation/tag.py deleted file mode 100644 index 05f0fb3..0000000 --- a/graphene_federation/tag.py +++ /dev/null @@ -1,20 +0,0 @@ -from graphene import Schema - -from graphene_federation.utils import get_attributed_fields - - -def tag(field, name: str): - """ - Decorator to use to override a given type. - """ - field._tag = name - return field - - -def get_tagged_fields(schema: Schema) -> dict: - """ - Find all the extended types from the schema. - They can be easily distinguished from the other type as - the `@external` decorator adds a `_external` attribute to them. - """ - return get_attributed_fields(attribute="_tag", schema=schema) diff --git a/graphene_federation/tests/__init__.py b/graphene_federation/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/graphene_federation/utils.py b/graphene_federation/utils.py index f374dde..8d026d9 100644 --- a/graphene_federation/utils.py +++ b/graphene_federation/utils.py @@ -1,119 +1,6 @@ import re -from typing import Any, Callable, List, Tuple - -from graphene import ObjectType, Schema -from graphene.types.definitions import GrapheneObjectType -from graphene.types.enum import EnumOptions -from graphene.types.scalars import ScalarOptions -from graphene.types.union import UnionOptions -from graphene.utils.str_converters import to_camel_case -from graphql import GraphQLEnumType, GraphQLNonNull, GraphQLScalarType, parse - - -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 getattr(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 - - -def check_fields_exist_on_type(fields: set, type_: ObjectType): - return fields.issubset(set(type_._meta.fields)) - - -def is_valid_compound_key(type_name: str, key: str, schema: Schema): - key_document = parse(f"{{{key}}}") - - # List storing tuples of nodes in the key document with its parent types - key_nodes: List[Tuple[Any, GrapheneObjectType]] = [ - (key_document.definitions[0], schema.graphql_schema.type_map[type_name]) - ] - - while key_nodes: - selection_node, parent_object_type = key_nodes[0] - if isinstance(parent_object_type, GraphQLNonNull): - parent_type_fields = parent_object_type.of_type.fields - else: - parent_type_fields = parent_object_type.fields - for field in selection_node.selection_set.selections: - if schema.auto_camelcase: - field_name = to_camel_case(field.name.value) - else: - field_name = field.name.value - if field_name not in parent_type_fields: - # Field does not exist on parent - return False - - field_type = parent_type_fields[field_name].type - if field.selection_set: - # If the field has sub-selections, add it to node mappings to check for valid subfields - - if isinstance(field_type, GraphQLScalarType) or ( - isinstance(field_type, GraphQLNonNull) - and isinstance(field_type.of_type, GraphQLScalarType) - ): - # sub-selections are added to a scalar type, key is not valid - return False - - key_nodes.append((field, field_type)) - else: - # If there are no sub-selections for a field, it should be a scalar or enum - if not any( - [ - ( - isinstance(field_type, GraphQLScalarType) - or isinstance(field_type, GraphQLEnumType) - ), - ( - isinstance(field_type, GraphQLNonNull) - and ( - isinstance(field_type.of_type, GraphQLScalarType) - or isinstance(field_type.of_type, GraphQLEnumType) - ) - ), - ] - ): - return False - - key_nodes.pop(0) # Remove the current node as it is fully processed - - return True - - -def get_attributed_fields(attribute: str, schema: Schema): - fields = {} - for type_name, type_ in schema.graphql_schema.type_map.items(): - if ( - not hasattr(type_, "graphene_type") - or isinstance(type_.graphene_type._meta, UnionOptions) - or isinstance(type_.graphene_type._meta, ScalarOptions) - or isinstance(type_.graphene_type._meta, EnumOptions) - ): - continue - for field in list(type_.graphene_type._meta.fields): - if getattr(getattr(type_.graphene_type, field, None), attribute, False): - fields[type_name] = type_.graphene_type - continue - return fields def clean_schema(schema): - return re.sub(r"[ \n]+", " ", str(schema)).strip() + schema = re.sub(r"\s+", "", str(schema)) + return schema.strip() diff --git a/graphene_federation/validators/__init__.py b/graphene_federation/validators/__init__.py new file mode 100644 index 0000000..4dc7427 --- /dev/null +++ b/graphene_federation/validators/__init__.py @@ -0,0 +1,4 @@ +from .extends import validate_extends +from .key import validate_key +from .requires import validate_requires +from .utils import build_ast diff --git a/graphene_federation/validators/extends.py b/graphene_federation/validators/extends.py new file mode 100644 index 0000000..40a1b6f --- /dev/null +++ b/graphene_federation/validators/extends.py @@ -0,0 +1,26 @@ +from typing import Union + +from graphene import Field, Interface, ObjectType +from graphene_directives import Schema +from graphene_directives.utils import has_non_field_attribute + + +# todoo: remove +def validate_extends( + type_: Union[ObjectType, Interface, Field], _inputs: dict, _schema: Schema +) -> bool: + from ..appolo_versions import LATEST_VERSION, get_directive_from_name + + key = get_directive_from_name("key", LATEST_VERSION) + + assert not has_non_field_attribute( + type_=type_, target_directive=key + ), f"Can't extend type on {type_} which has @key" + + assert getattr(type_._meta, "description", None) is None, ( + f'Type "{type_.__name__}" has a non empty description and it is also marked with extend.' + "\nThey are mutually exclusive." + "\nSee https://github.com/graphql/graphql-js/issues/2385#issuecomment-577997521" + ) + + return True diff --git a/graphene_federation/validators/key.py b/graphene_federation/validators/key.py new file mode 100644 index 0000000..ff29ff1 --- /dev/null +++ b/graphene_federation/validators/key.py @@ -0,0 +1,27 @@ +from typing import Union + +from graphene import Field, Interface, ObjectType +from graphene_directives import Schema + +from .utils import build_ast, evaluate_ast, to_case + + +def validate_key( + type_: Union[ObjectType, Interface, Field], inputs: dict, schema: Schema +) -> bool: + ast_node = build_ast( + input_str=to_case(inputs.get("fields"), schema), valid_special_chars="_" + ) + errors = [] + evaluate_ast( + directive_name="key", + nodes=ast_node, + type_=type_, + ignore_fields=[], + errors=errors, + entity_types=schema.graphql_schema.type_map, + ) + if len(errors) != 0: + raise ValueError("\n".join(errors)) + + return True diff --git a/graphene_federation/validators/requires.py b/graphene_federation/validators/requires.py new file mode 100644 index 0000000..8c4eff9 --- /dev/null +++ b/graphene_federation/validators/requires.py @@ -0,0 +1,31 @@ +from typing import Union + +from graphene import Field, Interface, ObjectType +from graphene_directives import Schema + +from .utils import build_ast, evaluate_ast, to_case + + +def validate_requires( + parent_type: Union[ObjectType, Interface], + _field: Field, + inputs: dict, + schema: Schema, +) -> bool: + ast_node = build_ast( + input_str=to_case(inputs.get("fields"), schema), valid_special_chars='_()"' + ) + + errors = [] + evaluate_ast( + directive_name="requires", + nodes=ast_node, + type_=parent_type.graphene_type, + ignore_fields=["__typename", "_Typename"], + errors=errors, + entity_types=schema.graphql_schema.type_map, + ) + if len(errors) != 0: + raise ValueError("\n".join(errors)) + + return True diff --git a/graphene_federation/validators/utils.py b/graphene_federation/validators/utils.py new file mode 100644 index 0000000..03f02d0 --- /dev/null +++ b/graphene_federation/validators/utils.py @@ -0,0 +1,120 @@ +import re +from typing import Union + +from graphene import Field, Interface, NonNull, ObjectType +from graphene.types.definitions import GrapheneObjectType +from graphene.utils.str_converters import to_camel_case +from graphene_directives import Schema +from graphql import GraphQLField, GraphQLNonNull + + +def clean_combined_brackets(data, bracket_kind) -> list[str]: + cleaned = [] + + filtered = list(filter(lambda x: x != "", data.split(bracket_kind))) + + for index, field in enumerate(filtered): + if index % 2: + cleaned.extend([field, bracket_kind]) + else: + cleaned.append(field) + + if not len(filtered) % 2 == 0: + cleaned.append(bracket_kind) + + return cleaned + + +def build_ast(input_str: str, valid_special_chars: str) -> dict: + fields = input_str.split() + cleaned_fields = [] + pattern = rf"[^a-zA-Z{valid_special_chars}]+" + + for field in fields: + if "{" in field and field != "{": + cleaned_fields.extend(clean_combined_brackets(field, "{")) + elif "}" in field and field != "}": + cleaned_fields.extend(clean_combined_brackets(field, "}")) + elif field == "{" or field == "}": + cleaned_fields.append(field) + else: + cleaned_fields.append(re.sub(pattern, "", field)) + + parent = {} + field_stack = [] + field_level = [parent] + for index, field in enumerate(cleaned_fields): + if field == "{": + field_level.append(field_level[-1][field_stack[-1]]) + elif field == "}": + field_level.pop() + else: + field_stack.append(field) + field_level[-1][field] = {} + return parent + + +def check_fields_exist_on_type( + field: str, + type_: Union[ObjectType, Interface, Field, NonNull], + ignore_fields: list[str], + entity_types: dict[str, ObjectType], +) -> bool: + if field in ignore_fields: + return True + + if isinstance(type_, GraphQLField): + return check_fields_exist_on_type( + field, + type_.type, # noqa + ignore_fields, + entity_types, + ) + elif isinstance(type_, GraphQLNonNull): + return check_fields_exist_on_type( + field, type_.of_type, ignore_fields, entity_types + ) + elif isinstance(type_, GrapheneObjectType): + return field in type_.fields + elif issubclass(type_, ObjectType) or issubclass(type_, Interface): # noqa + return field in entity_types.get(type_._meta.name).fields # noqa + + return False + + +def evaluate_ast( + directive_name: str, + nodes: dict, + type_: ObjectType, + ignore_fields: list[str], + errors: list[str], + entity_types: dict[str, ObjectType], +) -> None: + for field, value in nodes.items(): + if not check_fields_exist_on_type( + field, + type_, + ignore_fields, + entity_types, + ): + errors.append( + f'@{directive_name}, field "{field}" does not exist on type "{type_}"' + ) # noqa + if len(value) != 0: + if hasattr(type_, "_meta"): + type_ = entity_types.get(type_._meta.name) # noqa + field_type = type_.fields[field] # noqa + else: + field_type = type_.fields[field] # noqa + evaluate_ast( + directive_name, + value, + field_type, + ignore_fields, + errors, + entity_types, + ) + + +def to_case(fields: str, schema: Schema) -> str: + return to_camel_case(fields) if schema.auto_camelcase else fields diff --git a/setup.py b/setup.py index 144e616..41555f6 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,5 @@ import os + from setuptools import find_packages, setup @@ -35,6 +36,7 @@ def read(*rnames): install_requires=[ "graphene>=3.1", "graphql-core>=3.1", + "graphene-directives>=0.4.0", ], classifiers=[ "Development Status :: 5 - Production/Stable",