diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000000..09cd4408f5 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,38 @@ +--- +release type: minor +social_messages: + x: >- + {project_name} {version} is out! Custom schema directives attached to types and + fields now appear in GraphQL introspection. 🍓 + https://strawberry.rocks/release/{version} + linkedin: >- + {project_name} {version} is out. GraphQL tools can now discover custom schema + directives attached throughout a Strawberry schema using standard + introspection. 🍓 +--- + +This release fixes introspection for custom schema directives. + +Schema directives attached to types, fields, arguments, and other schema elements +now appear in standard GraphQL introspection. Schema explorers, IDEs, code +generators, and other tools can discover each directive's description, arguments, +allowed locations, repeatability, and any input types it uses. Federation directives, +including generated `@link` and `@composeDirective` applications, are discoverable +in the same way. + +Federation directives and custom composed directives used on field arguments are +also included in the generated subgraph metadata, so routers can recognize those +argument annotations without additional schema configuration. + +A directive reused across the schema is defined only once. Input, enum, and scalar +types referenced by directive arguments are now part of the schema and may appear +in generated SDL even when they are not used by fields. + +Because these directives and argument types are now part of the runtime schema, +their GraphQL names must be unique. Schema construction reports a clear error when +different directive definitions share a name, a custom directive replaces a +built-in directive such as `@skip`, or a directive argument type conflicts with +another schema type. Compatible custom `@oneOf` definitions continue to use +GraphQL's built-in directive. Strawberry now also resolves attached directive +argument annotations during schema construction, so unresolved forward references +are reported when the schema is created instead of later when its SDL is printed. diff --git a/docs/types/schema-directives.md b/docs/types/schema-directives.md index d8ec5c971f..3aeda2cec9 100644 --- a/docs/types/schema-directives.md +++ b/docs/types/schema-directives.md @@ -48,6 +48,40 @@ type User @keys(fields: "id") { } ``` +## Introspection + +Directives attached to a Strawberry schema are included in standard GraphQL +introspection. Tools can discover their descriptions, arguments, default values, +locations, and repeatability with a query such as: + +```graphql +{ + __schema { + directives { + name + description + locations + isRepeatable + args { + name + defaultValue + } + } + } +} +``` + +Input objects, enums, and scalars used only by directive arguments are also part +of the runtime schema. Their GraphQL names must therefore be distinct from other +types in the schema. Directive names must likewise be unique and cannot replace +built-in directives such as `@skip` or `@deprecated`. Compatible legacy `@oneOf` +definitions use GraphQL's built-in directive automatically. + +Directive argument annotations are resolved when the schema is built, in the +same way as field and argument annotations elsewhere in the schema. Types +imported only under `TYPE_CHECKING` should use +[`strawberry.lazy`](/docs/types/lazy) so Strawberry can resolve them at runtime. + ## Overriding field names You can use `strawberry.directive_field` to override the name of a field: diff --git a/strawberry/exceptions/unresolved_field_type.py b/strawberry/exceptions/unresolved_field_type.py index 8468031492..6f62013572 100644 --- a/strawberry/exceptions/unresolved_field_type.py +++ b/strawberry/exceptions/unresolved_field_type.py @@ -8,6 +8,7 @@ from .exception import StrawberryException if TYPE_CHECKING: + from strawberry.schema_directive import StrawberrySchemaDirective from strawberry.types.field import StrawberryField from strawberry.types.object_type import StrawberryObjectDefinition @@ -17,7 +18,7 @@ class UnresolvedFieldTypeError(StrawberryException): def __init__( self, - type_definition: StrawberryObjectDefinition, + type_definition: StrawberryObjectDefinition | StrawberrySchemaDirective, field: StrawberryField, ) -> None: self.type_definition = type_definition @@ -44,10 +45,11 @@ def exception_source(self) -> ExceptionSource | None: source_finder = SourceFinder() # field could be attached to the class or not + origin = self.type_definition.origin + if origin is None: + return None - source = source_finder.find_class_attribute_from_object( - self.type_definition.origin, self.field.name - ) + source = source_finder.find_class_attribute_from_object(origin, self.field.name) if source is not None: return source diff --git a/strawberry/federation/schema.py b/strawberry/federation/schema.py index 4a6d935c42..b2426d611e 100644 --- a/strawberry/federation/schema.py +++ b/strawberry/federation/schema.py @@ -1,7 +1,5 @@ from collections import defaultdict from collections.abc import Callable, Iterable, Mapping -from functools import cached_property -from itertools import chain from typing import ( TYPE_CHECKING, Any, @@ -27,7 +25,6 @@ from strawberry.utils.inspect import get_func_args from .schema_directive import StrawberryFederationSchemaDirective -from .types import FieldSet, LinkImport from .versions import format_version, parse_version if TYPE_CHECKING: @@ -86,17 +83,16 @@ def __init__( # noqa: PLR0917 # Add FederationAny to types so it appears in the schema types = [*types, FederationAny] - # Add federation scalars to scalar_overrides so they can be recognized + # _Any belongs to Federation's entity resolver rather than a directive, + # so this schema installs its scalar mapping. Directive support types + # such as _FieldSet carry their definitions on the annotations themselves; + # that lets both Schema classes register attached Federation directives. federation_scalar_overrides: dict[ object, type | ScalarDefinition | ScalarWrapper ] = { FederationAny: scalar( name="_Any", serialize=lambda v: v, parse_value=lambda v: v ), - FieldSet: scalar(name="_FieldSet", serialize=lambda v: v, parse_value=str), - LinkImport: scalar( - name="link__Import", serialize=lambda v: v, parse_value=lambda v: v - ), } if scalar_overrides: federation_scalar_overrides.update(scalar_overrides) @@ -115,14 +111,6 @@ def __init__( # noqa: PLR0917 exception_handlers=exception_handlers, ) - self.schema_directives = list(schema_directives) - - # Validate directive compatibility with federation version - self._validate_directive_compatibility() - - composed_directives = self._add_compose_directives() - self._add_link_directives(composed_directives) # type: ignore - def _get_federation_query_type( self, query: type[WithStrawberryObjectDefinition] | None, @@ -234,27 +222,14 @@ def entities_resolver( return results - @cached_property + @property def schema_directives_in_use(self) -> list[object]: - all_graphql_types = self._schema.type_map.values() - - directives: list[object] = [] - - for type_ in all_graphql_types: - strawberry_definition = type_.extensions.get("strawberry-definition") - - if not strawberry_definition: - continue - - directives.extend(strawberry_definition.directives) - - fields = getattr(strawberry_definition, "fields", []) - values = getattr(strawberry_definition, "values", []) - - for field in chain(fields, values): - directives.extend(field.directives) - - return directives + # Federation needs this list to generate @link and @composeDirective + # applications before the GraphQLSchema exists. The base collector tracks + # it while traversing the same definitions used for schema construction, + # including argument and nested input definitions that the old post-build + # GraphQL type-map scan could not see. + return self._schema_directives_in_use def _add_link_for_composed_directive( self, @@ -294,13 +269,13 @@ def _add_link_for_federation_directive( directive_by_url[url].add(f"@{name}") def _add_link_directives( - self, additional_directives: list[object] | None = None + self, additional_directives: Iterable[object] | None = None ) -> None: from .schema_directives import Link directive_by_url: defaultdict[str, set[str]] = defaultdict(set) - additional_directives = additional_directives or [] + additional_directives = list(additional_directives or ()) for directive in self.schema_directives_in_use + additional_directives: definition = directive.__strawberry_directive__ # type: ignore @@ -367,6 +342,14 @@ def _warn_for_federation_directives(self) -> None: pass + def _prepare_schema_directives(self) -> None: + # This hook runs inside the base collector, before GraphQLSchema is built. + # Generated directives are therefore included in that single schema + # construction and in the validation of the schema that will be served. + self._validate_directive_compatibility() + composed_directives = self._add_compose_directives() + self._add_link_directives(composed_directives) + def _get_entity_type( query: type[WithStrawberryObjectDefinition] | None, diff --git a/strawberry/federation/types.py b/strawberry/federation/types.py index 2389dc7c98..b465c3e0b0 100644 --- a/strawberry/federation/types.py +++ b/strawberry/federation/types.py @@ -2,15 +2,28 @@ from typing import NewType from strawberry.types.enum import enum +from strawberry.types.scalar import scalar FieldSet = NewType("FieldSet", str) """Represents a selection set for federation @requires, @provides, @key directives.""" +FieldSet._scalar_definition = scalar( # type: ignore[attr-defined] + name="_FieldSet", + serialize=lambda value: value, + parse_value=str, + print_definition=False, +) LinkImport = NewType("LinkImport", object) """Represents an import for the @link directive.""" +LinkImport._scalar_definition = scalar( # type: ignore[attr-defined] + name="link__Import", + serialize=lambda value: value, + parse_value=lambda value: value, + print_definition=False, +) -@enum(name="link__Purpose") +@enum(name="link__Purpose", print_definition=False) class LinkPurpose(Enum): SECURITY = "SECURITY" EXECUTION = "EXECUTION" diff --git a/strawberry/permission.py b/strawberry/permission.py index 7d10a023f5..5136873993 100644 --- a/strawberry/permission.py +++ b/strawberry/permission.py @@ -7,6 +7,7 @@ from typing import ( TYPE_CHECKING, Any, + ClassVar, ) from strawberry.exceptions import StrawberryGraphQLError @@ -54,7 +55,34 @@ def has_permission(self, source, info, **kwargs): error_class: type[GraphQLError] = StrawberryGraphQLError - _schema_directive: object | None = None + _schema_directive: ClassVar[object | None] = None + _auto_schema_directive: ClassVar[object] + + def __init_subclass__(cls, **kwargs: Any) -> None: + super().__init_subclass__(**kwargs) + + # Every instance of one permission class must use the same directive + # definition; otherwise two uses look like conflicting definitions with + # the same GraphQL name during schema collection. Build it when the + # permission subclass is created rather than lazily, which also avoids + # concurrent schema construction racing to create two definitions. Keep + # it separate from `_schema_directive` so instance, class, and inherited + # overrides work. + class AutoDirective: + __strawberry_directive__ = StrawberrySchemaDirective( + cls.__name__, + cls.__name__, + [Location.FIELD_DEFINITION], + [], + ) + + # Without this metadata a collision would report the local + # `AutoDirective` class twice. Make diagnostics point to the permission + # classes that users can actually rename. + AutoDirective.__name__ = cls.__name__ + AutoDirective.__qualname__ = cls.__qualname__ + AutoDirective.__module__ = cls.__module__ + cls._auto_schema_directive = AutoDirective() @abc.abstractmethod def has_permission( @@ -106,19 +134,7 @@ def on_unauthorized(self) -> None: @property def schema_directive(self) -> object: - if not self._schema_directive: - - class AutoDirective: - __strawberry_directive__ = StrawberrySchemaDirective( - self.__class__.__name__, - self.__class__.__name__, - [Location.FIELD_DEFINITION], - [], - ) - - self._schema_directive = AutoDirective() - - return self._schema_directive + return self._schema_directive or self._auto_schema_directive class PermissionExtension(FieldExtension): diff --git a/strawberry/printer/printer.py b/strawberry/printer/printer.py index a349cfe00b..32facafe7c 100644 --- a/strawberry/printer/printer.py +++ b/strawberry/printer/printer.py @@ -10,7 +10,13 @@ overload, ) -from graphql import GraphQLInputField, GraphQLObjectType, GraphQLSchema, is_union_type +from graphql import ( + GraphQLInputField, + GraphQLObjectType, + GraphQLSchema, + get_named_type, + is_union_type, +) from graphql.language.printer import print_ast from graphql.type import ( is_enum_type, @@ -28,6 +34,7 @@ print_implemented_interfaces, print_specified_by_url, ) +from graphql.utilities.print_schema import print_directive as original_print_directive from graphql.utilities.print_schema import print_type as original_print_type from strawberry.schema_directive import Location, StrawberrySchemaDirective @@ -49,8 +56,10 @@ GraphQLArgument, GraphQLEnumType, GraphQLEnumValue, + GraphQLInterfaceType, GraphQLNamedType, GraphQLScalarType, + GraphQLType, GraphQLUnionType, ) from graphql.type.directives import GraphQLDirective @@ -60,6 +69,7 @@ _T = TypeVar("_T") +_SCHEMA_TYPE_NAMES_CACHE_KEY = "strawberry-schema-type-names" @dataclasses.dataclass @@ -154,7 +164,15 @@ def print_schema_directive( "StrawberrySchemaDirective", directive.__class__.__strawberry_directive__ ) schema_converter = schema.schema_converter - gql_directive = schema_converter.from_schema_directive(directive.__class__) + # Registered directives were already converted while constructing the + # GraphQLSchema. Reuse that object so argument types and defaults match + # introspection exactly. The fallback is for definitions deliberately kept + # out of the runtime schema but still supported by Strawberry's SDL printer. + gql_directive = getattr(schema, "_schema_graphql_directives", {}).get( + directive.__class__ + ) + if gql_directive is None: + gql_directive = schema_converter.from_schema_directive(directive.__class__) params = print_schema_directive_params( gql_directive, { @@ -563,7 +581,10 @@ def print_schema_definition(schema: BaseSchema, *, extras: PrintExtras) -> str | def print_directive(directive: GraphQLDirective, *, schema: BaseSchema) -> str | None: strawberry_directive = directive.extensions.get("strawberry-definition") - if strawberry_directive is None or ( + if strawberry_directive is None: + return original_print_directive(directive) + + if ( isinstance(strawberry_directive, StrawberrySchemaDirective) and not strawberry_directive.print_definition ): @@ -592,6 +613,94 @@ def is_builtin_directive(directive: GraphQLDirective) -> bool: return False +def _should_print_type(type_: GraphQLNamedType, schema_type_names: set[str]) -> bool: + strawberry_definition = type_.extensions.get("strawberry-definition") + + # `print_definition=False` omits otherwise-private support types from SDL. + # If a visible field or printed directive definition reaches one, its + # definition is restored so SDL never references an undefined type. + return ( + getattr(strawberry_definition, "print_definition", True) + or type_.name in schema_type_names + ) + + +def _get_schema_type_names(schema: BaseSchema) -> set[str]: + graphql_schema = cast("GraphQLSchema", schema._schema) # type: ignore[attr-defined] + + # A schema is finalized before it can be printed, and Federation's _service + # resolver can print the same instance repeatedly. Cache this traversal on + # the GraphQLSchema instead of walking every field for each request. + if ( + cached_type_names := graphql_schema.extensions.get(_SCHEMA_TYPE_NAMES_CACHE_KEY) + ) is not None: + return cast("set[str]", cached_type_names) + + # graphql-core includes every named type referenced by a registered + # directive argument. Strawberry sometimes omits a directive definition from + # SDL (notably Federation directives imported with @link), and should omit + # private types used only by that hidden definition as well. Start from the + # roots, visible types, and visible directive arguments to determine which + # definitions the printed document really needs. A hidden support type + # remains visible when an ordinary field makes it reachable. + stack: list[GraphQLType | None] = [ + graphql_schema.query_type, + graphql_schema.mutation_type, + graphql_schema.subscription_type, + ] + + for schema_type in graphql_schema.type_map.values(): + strawberry_definition = schema_type.extensions.get("strawberry-definition") + if is_defined_type(schema_type) and getattr( + strawberry_definition, "print_definition", True + ): + stack.append(schema_type) + + for directive in graphql_schema.directives: + if is_builtin_directive(directive): + continue + + strawberry_definition = directive.extensions.get("strawberry-definition") + if ( + isinstance(strawberry_definition, StrawberrySchemaDirective) + and not strawberry_definition.print_definition + ): + continue + + stack.extend(argument.type for argument in directive.args.values()) + + type_names: set[str] = set() + + while stack: + graphql_type = stack.pop() + if graphql_type is None: + continue + + named_type = get_named_type(graphql_type) + if named_type.name in type_names: + continue + + type_names.add(named_type.name) + stack.extend(getattr(named_type, "interfaces", ())) + stack.extend(getattr(named_type, "types", ())) + if is_interface_type(named_type): + stack.extend( + graphql_schema.get_possible_types( + cast("GraphQLInterfaceType", named_type) + ) + ) + + for field in getattr(named_type, "fields", {}).values(): + stack.append(field.type) + stack.extend( + argument.type for argument in getattr(field, "args", {}).values() + ) + + graphql_schema.extensions[_SCHEMA_TYPE_NAMES_CACHE_KEY] = type_names + + return type_names + + def print_schema(schema: BaseSchema) -> str: graphql_core_schema = cast( "GraphQLSchema", @@ -606,29 +715,17 @@ def print_schema(schema: BaseSchema) -> str: ] type_map = graphql_core_schema.type_map + schema_type_names = _get_schema_type_names(schema) types = [ type_ for type_name in sorted(type_map) if is_defined_type(type_ := type_map[type_name]) + and _should_print_type(type_, schema_type_names) ] types_printed = [_print_type(type_, schema, extras=extras) for type_ in types] schema_definition = print_schema_definition(schema, extras=extras) - directives = [ - printed_directive - for directive in filtered_directives - if (printed_directive := print_directive(directive, schema=schema)) is not None - ] - - if schema.config.enable_experimental_incremental_execution: - directives.append( - "directive @defer(if: Boolean, label: String) on FRAGMENT_SPREAD | INLINE_FRAGMENT" - ) - directives.append( - "directive @stream(if: Boolean, label: String, initialCount: Int = 0) on FIELD" - ) - def _name_getter(type_: Any) -> str: if hasattr(type_, "name"): return type_.name @@ -643,6 +740,9 @@ def _print_extra_types() -> Iterable[str]: "GraphQLNamedType", schema.schema_converter.from_type(type_) ) + if not _should_print_type(graphql_type, schema_type_names): + continue + # Skip types that are already part of the schema's type map, otherwise # they'd be printed twice (e.g. an enum used both as a regular type and # as a schema directive field), producing invalid SDL. @@ -651,13 +751,25 @@ def _print_extra_types() -> Iterable[str]: yield _print_type(graphql_type, schema, extras=extras) + # Printing an extra directive argument type can discover directive + # applications attached to that type. Materialize extras before filtering + # the runtime directive list so each definition is emitted exactly once; + # relying on generator evaluation order would make this dedup accidental. + extra_types_printed = list(_print_extra_types()) + directives = [ + printed_directive + for directive in filtered_directives + if (printed_directive := print_directive(directive, schema=schema)) is not None + and printed_directive not in extras.directives + ] + return "\n\n".join( chain( sorted(extras.directives), filter(None, [schema_definition]), directives, types_printed, - _print_extra_types(), + extra_types_printed, ) ) diff --git a/strawberry/schema/directive_collector.py b/strawberry/schema/directive_collector.py new file mode 100644 index 0000000000..c536a35647 --- /dev/null +++ b/strawberry/schema/directive_collector.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast + +from graphql import get_named_type + +from .compat import is_schema_directive +from .schema_converter import GraphQLCoreConverter + +if TYPE_CHECKING: + from collections.abc import Iterable + + from graphql import GraphQLDirective, GraphQLNamedType, GraphQLType + + +class SchemaDirectiveCollector: + """Discovers the schema directives attached to Strawberry definitions. + + graphql-core adds the argument types of every directive passed to + ``GraphQLSchema`` to its type map, but it cannot discover directives that + are stored only on Strawberry definitions. This collector walks the same + GraphQL types graphql-core will reach, records every attached directive, + converts each directive class once, and queues the converted directive's + argument types so directives attached inside nested directive-only input + types are found as well. + """ + + def __init__(self, converter: GraphQLCoreConverter) -> None: + self._converter = converter + + # Directive classes in discovery order. A plain subclass inherits its + # parent's definition, so it is another Python spelling of the same + # GraphQL directive and is recorded as an alias instead. + self.directive_types: list[type] = [] + self.graphql_directives: dict[type, GraphQLDirective] = {} + self._seen_directive_types: set[type] = set() + self._types_by_definition: dict[int, type] = {} + self._aliases: dict[type, type] = {} + + # Every application found on a type, field, argument or enum value, in + # traversal order. Federation derives @link and @composeDirective from + # these, so the order is kept independent of the definitions above. + self.directives_in_use: list[object] = [] + + self._graphql_types: list[GraphQLNamedType] = [] + self._seen_graphql_types: set[int] = set() + self._type_cursor = 0 + self._directive_cursor = 0 + + def add_directive_type(self, directive_type: type) -> None: + if directive_type in self._seen_directive_types: + return + + self._seen_directive_types.add(directive_type) + definition = cast("Any", directive_type).__strawberry_directive__ + canonical_type = self._types_by_definition.setdefault( + id(definition), directive_type + ) + if canonical_type is not directive_type: + self._aliases[directive_type] = canonical_type + return + + self.directive_types.append(directive_type) + + def add_schema_directives(self, directives: Iterable[object]) -> None: + """Register directives applied to the schema definition itself.""" + for directive in directives: + self._add_directive_type_of(directive) + + def add_graphql_types(self, graphql_types: Iterable[GraphQLType | None]) -> None: + for graphql_type in graphql_types: + if graphql_type is not None: + self._queue_graphql_type(graphql_type) + + def collect(self) -> None: + """Process queued types and directives until nothing new is found.""" + while self._type_cursor < len(self._graphql_types) or ( + self._directive_cursor < len(self.directive_types) + ): + while self._type_cursor < len(self._graphql_types): + graphql_type = self._graphql_types[self._type_cursor] + self._type_cursor += 1 + self._visit_graphql_type(graphql_type) + + # Conversion exposes argument input types that graphql-core can only + # contribute once the directive is registered. Queue them so any + # directives attached inside that input graph are discovered too. + while self._directive_cursor < len(self.directive_types): + directive_type = self.directive_types[self._directive_cursor] + self._directive_cursor += 1 + graphql_directive = self._converter.from_schema_directive( + directive_type + ) + self.graphql_directives[directive_type] = graphql_directive + for argument in graphql_directive.args.values(): + self._queue_graphql_type(argument.type) + + for alias, canonical_type in self._aliases.items(): + self.graphql_directives[alias] = self.graphql_directives[canonical_type] + + def _add_directive_type_of(self, directive: object) -> None: + directive_type = directive.__class__ + if is_schema_directive(directive_type): + self.add_directive_type(directive_type) + + def _record_applied_directives(self, owner: object) -> None: + for directive in getattr(owner, "directives", None) or (): + self.directives_in_use.append(directive) + self._add_directive_type_of(directive) + + def _queue_graphql_type(self, graphql_type: GraphQLType) -> None: + named_type = get_named_type(graphql_type) + if id(named_type) in self._seen_graphql_types: + return + + self._seen_graphql_types.add(id(named_type)) + self._graphql_types.append(named_type) + + def _visit_graphql_type(self, graphql_type: GraphQLNamedType) -> None: + # Resolve graphql-core's lazy thunks before reading the Strawberry + # definition. Field extensions can attach schema directives while fields + # are converted (permissions do this), so inspecting the definition + # first would miss them. + graphql_fields = getattr(graphql_type, "fields", {}) + interfaces = getattr(graphql_type, "interfaces", ()) + member_types = getattr(graphql_type, "types", ()) + + definition = graphql_type.extensions.get( + GraphQLCoreConverter.DEFINITION_BACKREF + ) + if definition is not None: + self._record_applied_directives(definition) + for field in getattr(definition, "fields", ()): + self._record_applied_directives(field) + for argument in getattr(field, "arguments", ()): + self._record_applied_directives(argument) + for value in getattr(definition, "values", ()): + self._record_applied_directives(value) + + for field in graphql_fields.values(): + self._queue_graphql_type(field.type) + for argument in getattr(field, "args", {}).values(): + self._queue_graphql_type(argument.type) + for interface in interfaces: + self._queue_graphql_type(interface) + for member_type in member_types: + self._queue_graphql_type(member_type) + + +__all__ = ["SchemaDirectiveCollector"] diff --git a/strawberry/schema/schema.py b/strawberry/schema/schema.py index c3d8de122e..7b7cc37c61 100644 --- a/strawberry/schema/schema.py +++ b/strawberry/schema/schema.py @@ -21,6 +21,7 @@ FieldNode, FragmentDefinitionNode, GraphQLBoolean, + GraphQLDirective, GraphQLError, GraphQLField, GraphQLNamedType, @@ -80,6 +81,7 @@ ) from .base import BaseSchema from .config import StrawberryConfig +from .directive_collector import SchemaDirectiveCollector from .exceptions import CannotGetOperationTypeError, InvalidOperationTypeError if TYPE_CHECKING: @@ -125,6 +127,58 @@ ProcessErrors: TypeAlias = ( "Callable[[list[GraphQLError], ExecutionContext | None], None]" ) +_DirectiveEntry: TypeAlias = tuple[GraphQLDirective, object, str] + + +def _register_graphql_directive( + registry: dict[str, _DirectiveEntry], + directive: GraphQLDirective, + definition: object, + source: str, +) -> None: + """Add ``directive`` to ``registry`` unless its name is already taken. + + Registering the same Strawberry definition twice is harmless; two distinct + definitions sharing a GraphQL name raise an error naming both sources. + """ + if (existing := registry.get(directive.name)) is None: + registry[directive.name] = (directive, definition, source) + return + + _, existing_definition, existing_source = existing + if existing_definition is definition: + return + + raise ValueError( + f"Schema directive '@{directive.name}' is defined by both " + f"{existing_source} and {source}. Use a different " + "GraphQL name for one of them." + ) + + +def _is_specified_one_of_directive( + directive: GraphQLDirective, registry: dict[str, _DirectiveEntry] +) -> bool: + """Whether ``directive`` is a compatible spelling of graphql-core's @oneOf. + + Strawberry's OneOf schema directive (and user-defined ones) predate + graphql-core exposing the specified @oneOf directive. A compatible + definition stands in for the built-in regardless of the Python class used. + Legacy definitions normally have no description; a supplied description is + only accepted when it matches the specified one so custom metadata is not + silently discarded. + """ + if directive.name != "oneOf" or (existing := registry.get("oneOf")) is None: + return False + + specified, definition, _ = existing + return ( + specified is definition + and directive.locations == specified.locations + and directive.args.keys() == specified.args.keys() + and directive.is_repeatable == specified.is_repeatable + and directive.description in (None, specified.description) + ) # TODO: merge with below @@ -346,7 +400,7 @@ class Query: exception_handlers=self.exception_handlers, ) - self.directives = directives + self.directives = tuple(directives) self.schema_directives = list(schema_directives) query_type = self.schema_converter.from_object( @@ -373,16 +427,11 @@ class Query: else None ) - graphql_directives = [ - self.schema_converter.from_directive(directive) for directive in directives - ] - - graphql_types = [] + graphql_types: list[GraphQLNamedType] = [] + explicit_directive_types: list[type] = [] for type_ in types: if compat.is_schema_directive(type_): - graphql_directives.append( - self.schema_converter.from_schema_directive(type_) - ) + explicit_directive_types.append(type_) else: if ( has_object_definition(type_) @@ -397,16 +446,20 @@ class Query: graphql_types.append(graphql_type) try: - directives = specified_directives + tuple(graphql_directives) # type: ignore - - if self.config.enable_experimental_incremental_execution: - directives = tuple(directives) + tuple(incremental_execution_directives) - + # graphql-core adds the argument types of every directive passed to + # GraphQLSchema to its type map. It cannot, however, discover + # directives stored only in Strawberry definitions. Collect and + # convert those directives first so the one GraphQLSchema we build + # receives the complete directive and type graph. + schema_directive_types = self._collect_schema_directives( + explicit_directive_types, + [query_type, mutation_type, subscription_type, *graphql_types], + ) self._schema = GraphQLSchema( query=query_type, mutation=mutation_type, - subscription=subscription_type if subscription else None, - directives=directives, # type: ignore + subscription=subscription_type, + directives=self._collect_graphql_directives(schema_directive_types), types=graphql_types, extensions={ GraphQLCoreConverter.DEFINITION_BACKREF: self, @@ -439,6 +492,77 @@ class Query: formatted_errors = "\n\n".join(f"❌ {error.message}" for error in errors) raise ValueError(f"Invalid Schema. Errors:\n\n{formatted_errors}") + def _collect_schema_directives( + self, + explicit_directive_types: Iterable[type], + graphql_types: Iterable[GraphQLNamedType | None], + ) -> list[type]: + collector = SchemaDirectiveCollector(self.schema_converter) + for directive_type in explicit_directive_types: + collector.add_directive_type(directive_type) + collector.add_schema_directives(self.schema_directives) + collector.add_graphql_types(graphql_types) + collector.collect() + + # Federation reads the applications found so far to generate @link and + # @composeDirective, appending them to ``schema_directives``. Fold what + # it added into the same collection so the single GraphQLSchema built + # from it is validated with every generated directive and support type. + self._schema_directives_in_use = collector.directives_in_use + prepared_count = len(self.schema_directives) + self._prepare_schema_directives() + collector.add_schema_directives(self.schema_directives[prepared_count:]) + collector.collect() + + self._schema_graphql_directives = collector.graphql_directives + return collector.directive_types + + def _collect_graphql_directives( + self, schema_directive_types: Iterable[type] + ) -> tuple[GraphQLDirective, ...]: + # GraphQLSchema expects its directive collection to be canonical. Seed + # the registry with the specified directives so a custom definition + # cannot silently replace a built-in, then keep one insertion-ordered + # entry per GraphQL name. + registry: dict[str, _DirectiveEntry] = { + directive.name: (directive, directive, "the built-in GraphQL directive") + for directive in specified_directives + } + + for directive in self.directives: + _register_graphql_directive( + registry, + self.schema_converter.from_directive(directive), + directive, + f"operation directive '{directive.python_name}'", + ) + + for directive_type in schema_directive_types: + graphql_directive = self._schema_graphql_directives[directive_type] + if _is_specified_one_of_directive(graphql_directive, registry): + continue + + _register_graphql_directive( + registry, + graphql_directive, + cast("Any", directive_type).__strawberry_directive__, + ( + "schema directive " + f"'{directive_type.__module__}.{directive_type.__qualname__}'" + ), + ) + + if self.config.enable_experimental_incremental_execution: + for directive in incremental_execution_directives: + _register_graphql_directive( + registry, + directive, + directive, + f"the experimental GraphQL directive '@{directive.name}'", + ) + + return tuple(directive for directive, _, _ in registry.values()) + def get_extensions(self, sync: bool = False) -> list[SchemaExtension]: # Deprecated instances are passed through as-is. The DeprecationWarning # is emitted once at ``Schema.__init__``; users are expected to migrate @@ -1201,6 +1325,14 @@ def _resolve_node_ids(self) -> None: if not has_custom_resolve_id: origin.resolve_id_attr() + def _prepare_schema_directives(self) -> None: + """Hook for subclasses to append generated ``schema_directives``. + + Runs after the attached directives have been collected (so + ``_schema_directives_in_use`` is populated) and before the GraphQLSchema + is built, so anything appended here is part of the served schema. + """ + def _warn_for_federation_directives(self) -> None: """Raises a warning if the schema has any federation directives.""" from strawberry.federation.schema_directives import FederationDirective diff --git a/strawberry/schema/schema_converter.py b/strawberry/schema/schema_converter.py index b40801077f..ba5f7097c6 100644 --- a/strawberry/schema/schema_converter.py +++ b/strawberry/schema/schema_converter.py @@ -2,7 +2,6 @@ import dataclasses import inspect -import sys import typing from functools import partial, reduce from typing import ( @@ -531,7 +530,6 @@ def from_schema_directive(self, cls: type) -> GraphQLDirective: "StrawberrySchemaDirective", cls.__strawberry_directive__, # type: ignore[attr-defined] ) - module = sys.modules[cls.__module__] args: dict[str, GraphQLArgument] = {} for field in strawberry_directive.fields: @@ -539,14 +537,22 @@ def from_schema_directive(self, cls: type) -> GraphQLDirective: if default == dataclasses.MISSING: default = UNSET + # Attached schema directives are now converted during schema + # construction instead of only when SDL happens to be printed. + # Resolve their annotations at the Strawberry boundary so unresolved + # forward references raise the usual actionable Strawberry error + # instead of surfacing later as a graphql-core type error. + field_type = field.resolve_type() + if field_type is UNRESOLVED: + raise UnresolvedFieldTypeError(strawberry_directive, field) + name = self.config.name_converter.get_graphql_name(field) args[name] = self.from_argument( StrawberryArgument( python_name=field.python_name or field.name, graphql_name=None, type_annotation=StrawberryAnnotation( - annotation=field.type, - namespace=module.__dict__, + annotation=field_type, ), default=default, ) diff --git a/strawberry/types/arguments.py b/strawberry/types/arguments.py index aaa3d7755f..f6ed6951e0 100644 --- a/strawberry/types/arguments.py +++ b/strawberry/types/arguments.py @@ -76,7 +76,7 @@ def __init__( # noqa: PLR0917 self.description = description self.type_annotation = type_annotation self.deprecation_reason = deprecation_reason - self.directives = directives + self.directives = tuple(directives) self.metadata = metadata or {} # TODO: Consider moving this logic to a function @@ -108,7 +108,7 @@ def __init__( # noqa: PLR0917 self.description = arg.description self.graphql_name = arg.name self.deprecation_reason = arg.deprecation_reason - self.directives = arg.directives + self.directives = tuple(arg.directives) self.metadata = arg.metadata if arg.graphql_type is not None: self.type_annotation = StrawberryAnnotation( diff --git a/strawberry/types/enum.py b/strawberry/types/enum.py index d3178b8d8f..00a06d88e6 100644 --- a/strawberry/types/enum.py +++ b/strawberry/types/enum.py @@ -19,6 +19,11 @@ class EnumValue: directives: Iterable[object] = () description: str | None = None + def __post_init__(self) -> None: + # Directives are read by several consumers (schema construction, + # federation, the SDL printer), so one-shot iterables are materialized. + self.directives = tuple(self.directives) + @dataclasses.dataclass class StrawberryEnumDefinition(StrawberryType): @@ -27,6 +32,10 @@ class StrawberryEnumDefinition(StrawberryType): values: list[EnumValue] description: str | None directives: Iterable[object] = () + print_definition: bool = True + + def __post_init__(self) -> None: + self.directives = tuple(self.directives) def __hash__(self) -> int: # TODO: Is this enough for unique-ness? @@ -111,6 +120,7 @@ class EnumAnnotation: description: str | None = None directives: Iterable[object] = () graphql_name_from: GraphqlEnumNameFrom = "key" + print_definition: bool = True def __call__(self, cls: EnumType) -> EnumType: return _process_enum( @@ -119,6 +129,7 @@ def __call__(self, cls: EnumType) -> EnumType: self.description, directives=self.directives, graphql_name_from=self.graphql_name_from, + print_definition=self.print_definition, ) @@ -128,6 +139,8 @@ def _process_enum( description: str | None = None, directives: Iterable[object] = (), graphql_name_from: GraphqlEnumNameFrom = "key", + *, + print_definition: bool = True, ) -> EnumType: if not isinstance(cls, EnumMeta): raise ObjectIsNotAnEnumError(cls) @@ -180,6 +193,7 @@ def _process_enum( values=values, description=description, directives=directives, + print_definition=print_definition, ) return cls @@ -193,6 +207,7 @@ def enum( description: str | None = None, directives: Iterable[object] = (), graphql_name_from: GraphqlEnumNameFrom = "key", + print_definition: bool = True, ) -> EnumType: ... @@ -204,16 +219,18 @@ def enum( description: str | None = None, directives: Iterable[object] = (), graphql_name_from: GraphqlEnumNameFrom = "key", + print_definition: bool = True, ) -> Callable[[EnumType], EnumType]: ... -def enum( +def enum( # noqa: D417 cls: EnumType | None = None, *, name: str | None = None, description: str | None = None, directives: Iterable[object] = (), graphql_name_from: GraphqlEnumNameFrom = "key", + print_definition: bool = True, ) -> EnumType | Callable[[EnumType], EnumType]: """Annotates an Enum class a GraphQL enum. @@ -260,6 +277,7 @@ class MyEnum(Enum): description=description, directives=directives, graphql_name_from=graphql_name_from, + print_definition=print_definition, ) if not cls: diff --git a/strawberry/types/scalar.py b/strawberry/types/scalar.py index 9365648fea..6da773d73d 100644 --- a/strawberry/types/scalar.py +++ b/strawberry/types/scalar.py @@ -51,6 +51,7 @@ class ScalarDefinition(StrawberryType): # used for better error messages _source_file: str | None = None _source_line: int | None = None + print_definition: bool = True def copy_with( self, type_var_map: Mapping[str, StrawberryType | type] @@ -92,6 +93,7 @@ def _process_scalar( parse_value: GraphQLScalarValueParser | None = None, parse_literal: GraphQLScalarLiteralParser | None = None, directives: Iterable[object] = (), + print_definition: bool = True, ) -> ScalarWrapper: from strawberry.exceptions.handler import should_use_rich_exceptions @@ -114,7 +116,8 @@ def _process_scalar( serialize=serialize, parse_literal=parse_literal, parse_value=parse_value, - directives=directives, + directives=tuple(directives), + print_definition=print_definition, origin=cls, # type: ignore[arg-type] _source_file=_source_file, _source_line=_source_line, @@ -134,6 +137,7 @@ def scalar( parse_value: GraphQLScalarValueParser | None = None, parse_literal: GraphQLScalarLiteralParser | None = None, directives: Iterable[object] = (), + print_definition: bool = True, ) -> ScalarDefinition: ... @@ -148,6 +152,7 @@ def scalar( parse_value: GraphQLScalarValueParser | None = None, parse_literal: GraphQLScalarLiteralParser | None = None, directives: Iterable[object] = (), + print_definition: bool = True, ) -> Callable[[_T], _T]: ... @@ -162,13 +167,14 @@ def scalar( parse_value: GraphQLScalarValueParser | None = None, parse_literal: GraphQLScalarLiteralParser | None = None, directives: Iterable[object] = (), + print_definition: bool = True, ) -> _T: ... # TODO: We are tricking pyright into thinking that we are returning the given type # here or else it won't let us use any custom scalar to annotate attributes in # dataclasses/types. This should be properly solved when implementing StrawberryScalar -def scalar( +def scalar( # noqa: D417 cls: _T | None = None, *, name: str | None = None, @@ -178,6 +184,7 @@ def scalar( parse_value: GraphQLScalarValueParser | None = None, parse_literal: GraphQLScalarLiteralParser | None = None, directives: Iterable[object] = (), + print_definition: bool = True, ) -> Any: """Annotates a class or type as a GraphQL custom scalar. @@ -265,6 +272,7 @@ def scalar( parse_literal=parse_literal, parse_value=parse_value, directives=directives, + print_definition=print_definition, origin=None, _source_file=_source_file, _source_line=_source_line, @@ -292,6 +300,7 @@ def wrap(cls: _T) -> ScalarWrapper: parse_value=parse_value, parse_literal=parse_literal, directives=directives, + print_definition=print_definition, ) if cls is None: diff --git a/strawberry/types/union.py b/strawberry/types/union.py index c37cd54da6..d9e5d04989 100644 --- a/strawberry/types/union.py +++ b/strawberry/types/union.py @@ -57,7 +57,7 @@ def __init__( self.graphql_name = name self.type_annotations = type_annotations self.description = description - self.directives = directives + self.directives = tuple(directives) self._source_file = None self._source_line = None self.concrete_of: StrawberryUnion | None = None diff --git a/tests/federation/printer/test_compose_directive.py b/tests/federation/printer/test_compose_directive.py index 780cccb559..aaf10a8158 100644 --- a/tests/federation/printer/test_compose_directive.py +++ b/tests/federation/printer/test_compose_directive.py @@ -1,10 +1,12 @@ import textwrap +from typing import Annotated import strawberry +import strawberry.schema.schema as schema_module from strawberry.schema_directive import Location -def test_schema_directives_and_compose_schema(): +def test_schema_directives_and_compose_schema(monkeypatch): @strawberry.federation.schema_directive( locations=[Location.OBJECT], name="cacheControl", @@ -60,12 +62,56 @@ class Query: } """ + graphql_schema_calls = 0 + graphql_schema = schema_module.GraphQLSchema + validated_schemas: list[object] = [] + validate_schema = schema_module.validate_schema + + def counting_graphql_schema(*args: object, **kwargs: object): + nonlocal graphql_schema_calls + graphql_schema_calls += 1 + return graphql_schema(*args, **kwargs) + + def tracking_validate_schema(schema: object): + validated_schemas.append(schema) + return validate_schema(schema) + + monkeypatch.setattr(schema_module, "GraphQLSchema", counting_graphql_schema) + monkeypatch.setattr(schema_module, "validate_schema", tracking_validate_schema) + schema = strawberry.federation.Schema( query=Query, ) + assert graphql_schema_calls == 1 + assert validated_schemas == [schema._schema] assert schema.as_str() == textwrap.dedent(expected_type).strip() + result = schema.execute_sync( + """ + { + __schema { + directives { + name + } + } + } + """ + ) + + assert result.errors is None + directive_names = { + directive["name"] for directive in result.data["__schema"]["directives"] + } + assert { + "cacheControl", + "sensitive", + "key", + "shareable", + "composeDirective", + "link", + } <= directive_names + def test_schema_directives_and_compose_schema_custom_import_url(): @strawberry.federation.schema_directive( @@ -129,3 +175,51 @@ class Query: ) assert schema.as_str() == textwrap.dedent(expected_type).strip() + + +def test_composes_directives_attached_to_arguments(): + @strawberry.federation.schema_directive( + locations=[Location.ARGUMENT_DEFINITION], + name="validate", + compose=True, + import_url="https://example.com/validate/v1.0", + ) + class Validate: + pattern: str + + @strawberry.type + class Query: + @strawberry.field + def search( + self, + term: Annotated[ + str, + strawberry.federation.argument( + directives=[Validate(pattern="letters-only")] + ), + ], + ) -> str: + return term + + schema = strawberry.federation.Schema(query=Query) + + expected_type = """ + directive @validate(pattern: String!) on ARGUMENT_DEFINITION + + schema @composeDirective(name: "@validate") @link(url: "https://example.com/validate/v1.0", import: ["@validate"]) @link(url: "https://specs.apollo.dev/federation/v2.11", import: ["@composeDirective"]) { + query: Query + } + + type Query { + _service: _Service! + search(term: String! @validate(pattern: "letters-only")): String! + } + + scalar _Any + + type _Service { + sdl: String! + } + """ + + assert schema.as_str() == textwrap.dedent(expected_type).strip() diff --git a/tests/federation/printer/test_link.py b/tests/federation/printer/test_link.py index 6151a133be..95680df720 100644 --- a/tests/federation/printer/test_link.py +++ b/tests/federation/printer/test_link.py @@ -38,6 +38,27 @@ class Query: assert schema.as_str() == textwrap.dedent(expected).strip() + result = schema.execute_sync( + """ + { + importType: __type(name: "link__Import") { + kind + name + } + purposeType: __type(name: "link__Purpose") { + kind + name + } + } + """ + ) + + assert result.errors is None + assert result.data == { + "importType": {"kind": "SCALAR", "name": "link__Import"}, + "purposeType": {"kind": "ENUM", "name": "link__Purpose"}, + } + @skip_if_gql_32("formatting is different in gql 3.2") def test_link_directive_imports(): diff --git a/tests/federation/test_schema.py b/tests/federation/test_schema.py index ab596c3513..ff7bb351f5 100644 --- a/tests/federation/test_schema.py +++ b/tests/federation/test_schema.py @@ -1,10 +1,13 @@ import textwrap import warnings -from typing import Generic, TypeVar +from typing import Generic, NewType, TypeVar import pytest +from graphql import build_schema import strawberry +from strawberry.federation.types import FieldSet, LinkImport, LinkPurpose +from strawberry.schema_directive import Location def test_entities_type_when_no_type_has_keys(): @@ -116,6 +119,10 @@ def top_products(self, first: int) -> list[Product]: # pragma: no cover name } } + fieldSet: __type(name: "_FieldSet") { + kind + name + } } """ @@ -124,10 +131,59 @@ def top_products(self, first: int) -> list[Product]: # pragma: no cover assert not result.errors assert result.data == { - "__type": {"kind": "UNION", "possibleTypes": [{"name": "Product"}]} + "__type": {"kind": "UNION", "possibleTypes": [{"name": "Product"}]}, + "fieldSet": {"kind": "SCALAR", "name": "_FieldSet"}, } +def test_registers_custom_and_federation_directives(): + @strawberry.schema_directive(locations=[Location.OBJECT]) + class Custom: ... + + @strawberry.federation.type(keys=["upc"], directives=[Custom()]) + class Product: + upc: str + + @strawberry.type + class Query: + product: Product + + schema = strawberry.federation.Schema(query=Query) + + assert schema._schema.get_directive("custom") is not None + assert schema._schema.get_directive("key") is not None + assert schema._schema.get_directive("link") is not None + + expected_sdl = textwrap.dedent(""" + directive @custom on OBJECT + + schema @link(url: "https://specs.apollo.dev/federation/v2.11", import: ["@key"]) { + query: Query + } + + type Product @custom @key(fields: "upc") { + upc: String! + } + + type Query { + _entities(representations: [_Any!]!): [_Entity]! + _service: _Service! + product: Product! + } + + scalar _Any + + union _Entity = Product + + type _Service { + sdl: String! + } + """).strip() + + sdl = schema.as_str() + assert sdl == expected_sdl + + def test_additional_scalars(): @strawberry.federation.type(keys=["upc"]) class Example: @@ -156,6 +212,80 @@ def top_products(self, first: int) -> list[Example]: # pragma: no cover assert result.data == {"__type": {"kind": "SCALAR"}} +def test_user_type_named_like_private_federation_type_is_printed(): + UserFieldSet = NewType("UserFieldSet", str) + + @strawberry.type + class Query: + field_set: UserFieldSet + + schema = strawberry.federation.Schema( + query=Query, + scalar_overrides={ + UserFieldSet: strawberry.scalar( + name="_FieldSet", + serialize=lambda value: value, + parse_value=str, + ) + }, + ) + + expected_sdl = textwrap.dedent(""" + type Query { + _service: _Service! + fieldSet: _FieldSet! + } + + scalar _Any + + scalar _FieldSet + + type _Service { + sdl: String! + } + """).strip() + + assert schema.as_str() == expected_sdl + + +def test_private_federation_types_are_printed_when_used_by_fields(): + @strawberry.type + class Query: + field_set: FieldSet + link_import: LinkImport + link_purpose: LinkPurpose + + schema = strawberry.federation.Schema(query=Query) + + expected_sdl = textwrap.dedent(""" + type Query { + _service: _Service! + fieldSet: _FieldSet! + linkImport: link__Import! + linkPurpose: link__Purpose! + } + + scalar _Any + + scalar _FieldSet + + type _Service { + sdl: String! + } + + scalar link__Import + + enum link__Purpose { + SECURITY + EXECUTION + } + """).strip() + + sdl = schema.as_str() + assert sdl == expected_sdl + build_schema(sdl) + + def test_service(): @strawberry.federation.type class Product: @@ -330,7 +460,7 @@ class ProductFed: weight: int | None with pytest.warns(UserWarning) as record: # noqa: PT030 - strawberry.Schema( + schema = strawberry.Schema( query=ProductFed, ) @@ -339,6 +469,8 @@ class ProductFed: "Use `strawberry.federation.Schema` instead of `strawberry.Schema`." in [str(r.message) for r in record] ) + assert schema._schema.get_directive("key") is not None + assert schema._schema.get_type("_FieldSet") is not None def test_does_not_warn_when_using_federation_schema(): diff --git a/tests/schema/test_basic.py b/tests/schema/test_basic.py index cd36de2724..4dfbf8f0cb 100644 --- a/tests/schema/test_basic.py +++ b/tests/schema/test_basic.py @@ -512,15 +512,16 @@ class Input: class Query: foo: int - @strawberry.schema_directive(locations=[Location.SCALAR], name="specifiedBy") - class SpecifiedBy: + @strawberry.schema_directive(locations=[Location.SCALAR], name="customDirective") + class CustomDirective: name: str schema = strawberry.Schema( - query=Query, types=[Type, Interface, Input, Base64, ID, str, int, SpecifiedBy] + query=Query, + types=[Type, Interface, Input, Base64, ID, str, int, CustomDirective], ) expected = ''' - directive @specifiedBy(name: String!) on SCALAR + directive @customDirective(name: String!) on SCALAR """ Represents binary data as Base64-encoded strings, using the standard alphabet. diff --git a/tests/schema/test_extensions.py b/tests/schema/test_extensions.py index d5ef1d2783..cdb50880be 100644 --- a/tests/schema/test_extensions.py +++ b/tests/schema/test_extensions.py @@ -14,7 +14,6 @@ from strawberry.scalars import JSON from strawberry.schema.schema_converter import GraphQLCoreConverter from strawberry.schema_directive import Location -from strawberry.types.base import get_object_definition DEFINITION_BACKREF = GraphQLCoreConverter.DEFINITION_BACKREF @@ -34,16 +33,8 @@ class Query: # Schema assert graphql_schema.extensions[DEFINITION_BACKREF] is schema - # TODO: Apparently I stumbled on a bug: - # SchemaDirective are used on schema.__str__(), - # but aren't added to graphql_schema.directives - # maybe graphql_schema_directive = graphql_schema.get_directive("schemaDirective") - - directives = get_object_definition(Query, strict=True).directives - assert directives is not None - graphql_schema_directive = schema.schema_converter.from_schema_directive( - directives[0] - ) + graphql_schema_directive = graphql_schema.get_directive("schemaDirective") + assert graphql_schema_directive is not None assert ( graphql_schema_directive.extensions[DEFINITION_BACKREF] is SchemaDirective.__strawberry_directive__ diff --git a/tests/schema/test_permission.py b/tests/schema/test_permission.py index 144d549f9b..d7d944577d 100644 --- a/tests/schema/test_permission.py +++ b/tests/schema/test_permission.py @@ -11,6 +11,7 @@ ) from strawberry.permission import BasePermission, PermissionExtension from strawberry.printer import print_schema +from strawberry.schema_directive import Location from strawberry.utils.aio import aclosing @@ -531,6 +532,127 @@ def name(self) -> str: # pragma: no cover assert print_schema(schema) == textwrap.dedent(expected_output).strip() +def test_permission_directives_reused_across_fields(): + class IsAuthorized(BasePermission): + def has_permission(self, source, info, **kwargs: typing.Any) -> bool: + return True + + @strawberry.type + class Query: + first: str = strawberry.field( + extensions=[PermissionExtension([IsAuthorized()])] + ) + second: str = strawberry.field( + extensions=[PermissionExtension([IsAuthorized()])] + ) + + schema = strawberry.Schema(query=Query) + + expected_output = """ + directive @isAuthorized on FIELD_DEFINITION + + type Query { + first: String! @isAuthorized + second: String! @isAuthorized + } + """ + assert print_schema(schema) == textwrap.dedent(expected_output).strip() + + +def test_permission_directive_can_be_overridden_on_an_instance(): + @strawberry.schema_directive( + name="requiresRole", locations=[Location.FIELD_DEFINITION] + ) + class RequiresRole: + role: str + + class IsAuthorized(BasePermission): + def has_permission(self, source, info, **kwargs: typing.Any) -> bool: + return True + + permission = IsAuthorized() + permission._schema_directive = RequiresRole(role="member") + + @strawberry.type + class Query: + name: str = strawberry.field(extensions=[PermissionExtension([permission])]) + + schema = strawberry.Schema(query=Query) + + expected_output = """ + directive @requiresRole(role: String!) on FIELD_DEFINITION + + type Query { + name: String! @requiresRole(role: "member") + } + """ + assert print_schema(schema) == textwrap.dedent(expected_output).strip() + + +def test_permission_directive_can_be_inherited(): + @strawberry.schema_directive( + name="requiresRole", locations=[Location.FIELD_DEFINITION] + ) + class RequiresRole: + role: str + + class IsAuthorized(BasePermission): + _schema_directive = RequiresRole(role="member") + + def has_permission(self, source, info, **kwargs: typing.Any) -> bool: + return True + + class IsMember(IsAuthorized): ... + + @strawberry.type + class Query: + name: str = strawberry.field(extensions=[PermissionExtension([IsMember()])]) + + schema = strawberry.Schema(query=Query) + + expected_output = """ + directive @requiresRole(role: String!) on FIELD_DEFINITION + + type Query { + name: String! @requiresRole(role: "member") + } + """ + assert print_schema(schema) == textwrap.dedent(expected_output).strip() + + +def test_permission_directive_collision_names_permission_classes(): + def has_permission( + self: BasePermission, source, info, **kwargs: typing.Any + ) -> bool: + return True + + First = type( + "CanAccess", + (BasePermission,), + {"__module__": "permissions.first", "has_permission": has_permission}, + ) + Second = type( + "CanAccess", + (BasePermission,), + {"__module__": "permissions.second", "has_permission": has_permission}, + ) + + @strawberry.type + class Query: + first: str = strawberry.field(extensions=[PermissionExtension([First()])]) + second: str = strawberry.field(extensions=[PermissionExtension([Second()])]) + + with pytest.raises( + ValueError, + match=( + r"Schema directive '@canAccess' is defined by both schema directive " + r"'permissions\.first\.CanAccess' and schema directive " + r"'permissions\.second\.CanAccess'" + ), + ): + strawberry.Schema(query=Query) + + def test_permission_directives_not_added_on_field(): class IsAuthorized(BasePermission): message = "User is not authorized" diff --git a/tests/schema/test_schema_directives.py b/tests/schema/test_schema_directives.py new file mode 100644 index 0000000000..60ce8ef1c2 --- /dev/null +++ b/tests/schema/test_schema_directives.py @@ -0,0 +1,677 @@ +import textwrap +from enum import Enum +from typing import Annotated + +import pytest +from graphql import ( + GraphQLEnumType, + GraphQLInputObjectType, + GraphQLScalarType, + build_schema, + get_named_type, + specified_directives, +) + +import strawberry +import strawberry.schema.schema as schema_module +from strawberry.exceptions import DuplicatedTypeName, UnresolvedFieldTypeError +from strawberry.schema.schema_converter import GraphQLCoreConverter +from strawberry.schema_directive import Location + + +def test_registers_reused_directives_and_preserves_application_order(): + @strawberry.schema_directive( + locations=[Location.SCHEMA, Location.OBJECT, Location.FIELD_DEFINITION], + repeatable=True, + ) + class Marker: + name: str + + @strawberry.type(directives=[Marker(name="object-1"), Marker(name="object-2")]) + class Query: + name: str = strawberry.field( + default="Patrick", directives=[Marker(name="field")] + ) + + schema = strawberry.Schema(query=Query, schema_directives=[Marker(name="schema")]) + + directives = [ + directive + for directive in schema._schema.directives + if directive.name == "marker" + ] + assert len(directives) == 1 + + expected = """ + directive @marker(name: String!) repeatable on SCHEMA | OBJECT | FIELD_DEFINITION + + schema @marker(name: "schema") { + query: Query + } + + type Query @marker(name: "object-1") @marker(name: "object-2") { + name: String! @marker(name: "field") + } + """ + + sdl = schema.as_str() + assert sdl == textwrap.dedent(expected).strip() + build_schema(sdl) + + +def test_introspection_exposes_schema_directive_metadata(): + @strawberry.schema_directive( + name="accessPolicy", + description="Controls access to a schema element.", + locations=[Location.OBJECT, Location.FIELD_DEFINITION], + repeatable=True, + ) + class AccessPolicy: + role: str + level: int = 1 + + @strawberry.type(directives=[AccessPolicy(role="admin")]) + class Query: + name: str + + schema = strawberry.Schema(query=Query) + result = schema.execute_sync( + """ + { + __schema { + directives { + name + description + isRepeatable + locations + args { + name + defaultValue + type { + kind + name + ofType { + kind + name + } + } + } + } + } + } + """ + ) + + assert result.errors is None + directive = next( + directive + for directive in result.data["__schema"]["directives"] + if directive["name"] == "accessPolicy" + ) + assert directive == { + "name": "accessPolicy", + "description": "Controls access to a schema element.", + "isRepeatable": True, + "locations": ["OBJECT", "FIELD_DEFINITION"], + "args": [ + { + "name": "role", + "defaultValue": None, + "type": { + "kind": "NON_NULL", + "name": None, + "ofType": {"kind": "SCALAR", "name": "String"}, + }, + }, + { + "name": "level", + "defaultValue": "1", + "type": { + "kind": "NON_NULL", + "name": None, + "ofType": {"kind": "SCALAR", "name": "Int"}, + }, + }, + ], + } + + +def test_explicit_directive_type_is_reconciled_with_attached_uses(): + @strawberry.schema_directive(locations=[Location.OBJECT]) + class Marker: ... + + @strawberry.type(directives=[Marker()]) + class Query: + name: str + + schema = strawberry.Schema(query=Query, types=[Marker, Marker]) + + assert ( + sum(directive.name == "marker" for directive in schema._schema.directives) == 1 + ) + + expected = """ + directive @marker on OBJECT + + type Query @marker { + name: String! + } + """ + + sdl = schema.as_str() + assert sdl == textwrap.dedent(expected).strip() + build_schema(sdl) + + +def test_plain_subclass_reuses_inherited_directive_definition(): + @strawberry.schema_directive(locations=[Location.OBJECT, Location.FIELD_DEFINITION]) + class Marker: ... + + class FieldMarker(Marker): ... + + @strawberry.type(directives=[Marker()]) + class Query: + name: str = strawberry.field(default="Patrick", directives=[FieldMarker()]) + + schema = strawberry.Schema(query=Query) + + assert ( + sum(directive.name == "marker" for directive in schema._schema.directives) == 1 + ) + + expected = """ + directive @marker on OBJECT | FIELD_DEFINITION + + type Query @marker { + name: String! @marker + } + """ + + sdl = schema.as_str() + assert sdl == textwrap.dedent(expected).strip() + build_schema(sdl) + + +def test_registers_nested_directive_argument_input_types(): + @strawberry.schema_directive(locations=[Location.INPUT_OBJECT]) + class OnNestedInput: ... + + @strawberry.schema_directive(locations=[Location.INPUT_FIELD_DEFINITION]) + class OnNestedInputField: ... + + @strawberry.enum + class Mode(Enum): + PRIVATE = "private" + + Secret = strawberry.scalar(str, name="Secret") + + @strawberry.input(directives=[OnNestedInput()]) + class Rule: + value: str = strawberry.field(directives=[OnNestedInputField()]) + + @strawberry.input + class Policy: + rule: Rule + mode: Mode + secret: Secret + + @strawberry.schema_directive(locations=[Location.FIELD_DEFINITION]) + class Protected: + policy: Policy | None = strawberry.UNSET + + @strawberry.type + class Query: + name: str = strawberry.field( + default="Patrick", + directives=[Protected()], + ) + + schema = strawberry.Schema(query=Query) + + policy_type = schema._schema.get_type("Policy") + rule_type = schema._schema.get_type("Rule") + assert isinstance(policy_type, GraphQLInputObjectType) + assert isinstance(rule_type, GraphQLInputObjectType) + assert isinstance(schema._schema.get_type("Mode"), GraphQLEnumType) + assert isinstance(schema._schema.get_type("Secret"), GraphQLScalarType) + assert get_named_type(policy_type.fields["rule"].type) is rule_type + assert schema._schema.get_directive("onNestedInput") is not None + assert schema._schema.get_directive("onNestedInputField") is not None + + expected = """ + directive @onNestedInput on INPUT_OBJECT + + directive @onNestedInputField on INPUT_FIELD_DEFINITION + + directive @protected(policy: Policy) on FIELD_DEFINITION + + enum Mode { + PRIVATE + } + + input Policy { + rule: Rule! + mode: Mode! + secret: Secret! + } + + type Query { + name: String! @protected + } + + input Rule @onNestedInput { + value: String! @onNestedInputField + } + + scalar Secret + """ + + sdl = schema.as_str() + assert sdl == textwrap.dedent(expected).strip() + build_schema(sdl) + + +def test_print_definition_false_remains_available_to_introspection(): + @strawberry.input + class HiddenConfig: + reason: str + + @strawberry.schema_directive( + locations=[Location.FIELD_DEFINITION], print_definition=False + ) + class Hidden: + config: HiddenConfig | None = strawberry.UNSET + + @strawberry.type + class Query: + name: str = strawberry.field( + default="Patrick", + directives=[Hidden()], + ) + + schema = strawberry.Schema(query=Query) + + assert schema._schema.get_directive("hidden") is not None + assert schema._schema.get_type("HiddenConfig") is not None + + expected = """ + input HiddenConfig { + reason: String! + } + + type Query { + name: String! @hidden + } + """ + + assert schema.as_str() == textwrap.dedent(expected).strip() + + +def test_hidden_argument_types_are_printed_with_visible_directive_definitions(): + HiddenValue = strawberry.scalar(str, name="HiddenValue", print_definition=False) + + @strawberry.schema_directive(locations=[Location.FIELD_DEFINITION]) + class Marker: + value: HiddenValue + + @strawberry.type + class Query: + name: str = strawberry.field( + default="Patrick", + directives=[Marker(value="example")], + ) + + schema = strawberry.Schema(query=Query) + + expected = """ + directive @marker(value: HiddenValue!) on FIELD_DEFINITION + + scalar HiddenValue + + type Query { + name: String! @marker(value: "example") + } + """ + + sdl = schema.as_str() + assert sdl == textwrap.dedent(expected).strip() + build_schema(sdl) + + +def test_registers_directives_from_all_type_system_attachment_points(): + def directive(name: str, location: Location) -> type: + @strawberry.schema_directive(name=name, locations=[location]) + class Directive: ... + + return Directive + + InterfaceDirective = directive("onInterface", Location.INTERFACE) + UnionDirective = directive("onUnion", Location.UNION) + EnumDirective = directive("onEnum", Location.ENUM) + EnumValueDirective = directive("onEnumValue", Location.ENUM_VALUE) + ScalarDirective = directive("onScalar", Location.SCALAR) + InputDirective = directive("onInput", Location.INPUT_OBJECT) + InputFieldDirective = directive("onInputField", Location.INPUT_FIELD_DEFINITION) + ArgumentDirective = directive("onArgument", Location.ARGUMENT_DEFINITION) + + @strawberry.interface(directives=[InterfaceDirective()]) + class Node: + id: strawberry.ID + + @strawberry.type + class Item(Node): + name: str + + @strawberry.type + class Other: + value: str + + Result = Annotated[ + Item | Other, + strawberry.union("Result", directives=[UnionDirective()]), + ] + + @strawberry.enum(directives=[EnumDirective()]) + class Choice(Enum): + FIRST = strawberry.enum_value("first", directives=[EnumValueDirective()]) + + CustomScalar = strawberry.scalar( + str, name="CustomScalar", directives=[ScalarDirective()] + ) + + @strawberry.input(directives=[InputDirective()]) + class Filter: + term: str = strawberry.field(directives=[InputFieldDirective()]) + + @strawberry.type + class Query: + node: Node + result: Result + choice: Choice + custom_scalar: CustomScalar + + @strawberry.field + def search( + self, + filter: Filter, + term: Annotated[str, strawberry.argument(directives=[ArgumentDirective()])], + ) -> str: + return filter.term + term + + schema = strawberry.Schema(query=Query, types=[Item]) + directive_names = {item.name for item in schema._schema.directives} + + assert { + "onInterface", + "onUnion", + "onEnum", + "onEnumValue", + "onScalar", + "onInput", + "onInputField", + "onArgument", + } <= directive_names + + +def test_directives_passed_as_one_shot_iterables_are_kept(): + @strawberry.schema_directive( + locations=[ + Location.ARGUMENT_DEFINITION, + Location.ENUM, + Location.ENUM_VALUE, + Location.SCALAR, + Location.UNION, + ] + ) + class Marker: ... + + def markers(): + yield Marker() + + @strawberry.enum(directives=markers()) + class Choice(Enum): + FIRST = strawberry.enum_value("first", directives=markers()) + + CustomScalar = strawberry.scalar(str, name="CustomScalar", directives=markers()) + + @strawberry.type + class Item: + name: str + + @strawberry.type + class Other: + value: str + + Result = Annotated[Item | Other, strawberry.union("Result", directives=markers())] + + @strawberry.type + class Query: + result: Result + custom_scalar: CustomScalar + + @strawberry.field + def choice( + self, + choice: Annotated[Choice, strawberry.argument(directives=markers())], + ) -> Choice: + return choice + + schema = strawberry.Schema(query=Query) + + expected = """ + directive @marker on ARGUMENT_DEFINITION | ENUM | ENUM_VALUE | SCALAR | UNION + + enum Choice @marker { + FIRST @marker + } + + scalar CustomScalar @marker + + type Item { + name: String! + } + + type Other { + value: String! + } + + type Query { + result: Result! + customScalar: CustomScalar! + choice(choice: Choice! @marker): Choice! + } + + union Result @marker = Item | Other + """ + + assert schema.as_str() == textwrap.dedent(expected).strip() + assert schema._schema.get_directive("marker") is not None + + +def test_rejects_conflicting_schema_directive_names(): + @strawberry.schema_directive(name="conflict", locations=[Location.OBJECT]) + class First: ... + + @strawberry.schema_directive(name="conflict", locations=[Location.FIELD_DEFINITION]) + class Second: ... + + @strawberry.type(directives=[First()]) + class Query: + name: str = strawberry.field(default="Patrick", directives=[Second()]) + + with pytest.raises( + ValueError, + match=( + r"Schema directive '@conflict' is defined by both .*First.* and .*Second" + ), + ): + strawberry.Schema(query=Query) + + +def test_rejects_schema_directive_collisions_with_specified_directives(): + @strawberry.schema_directive(name="skip", locations=[Location.OBJECT]) + class CustomSkip: ... + + @strawberry.type(directives=[CustomSkip()]) + class Query: + name: str + + with pytest.raises( + ValueError, + match=( + r"Schema directive '@skip' is defined by both the built-in GraphQL " + "directive and schema directive .*CustomSkip" + ), + ): + strawberry.Schema(query=Query) + + +def test_compatible_custom_one_of_uses_specified_directive(): + @strawberry.schema_directive(name="oneOf", locations=[Location.INPUT_OBJECT]) + class LegacyOneOf: ... + + @strawberry.input(directives=[LegacyOneOf()]) + class Choice: + value: str | None + + @strawberry.type + class Query: + @strawberry.field + def choose(self, choice: Choice) -> str: + return choice.value or "" + + schema = strawberry.Schema(query=Query) + + expected = """ + directive @oneOf on INPUT_OBJECT + + input Choice @oneOf { + value: String + } + + type Query { + choose(choice: Choice!): String! + } + """ + + assert schema.as_str() == textwrap.dedent(expected).strip() + assert schema._schema.get_directive("oneOf") is not None + + +@pytest.mark.skipif( + not any(directive.name == "oneOf" for directive in specified_directives), + reason="graphql-core does not define the specified @oneOf directive", +) +def test_rejects_custom_one_of_with_a_different_description(): + @strawberry.schema_directive( + name="oneOf", + description="A custom oneOf definition.", + locations=[Location.INPUT_OBJECT], + ) + class CustomOneOf: ... + + @strawberry.input(directives=[CustomOneOf()]) + class Choice: + value: str | None + + @strawberry.type + class Query: + @strawberry.field + def choose(self, choice: Choice) -> str: + return choice.value or "" + + with pytest.raises( + ValueError, + match=( + r"Schema directive '@oneOf' is defined by both the built-in GraphQL " + r"directive and schema directive .*CustomOneOf" + ), + ): + strawberry.Schema(query=Query) + + +def test_rejects_directive_argument_type_name_conflicts(): + @strawberry.input(name="Conflict") + class DirectiveInput: + value: str + + @strawberry.type(name="Conflict") + class RegularType: + value: str + + @strawberry.schema_directive(locations=[Location.OBJECT]) + class Marker: + config: DirectiveInput + + @strawberry.type(directives=[Marker(config=DirectiveInput(value="directive"))]) + class Query: + value: RegularType + + with pytest.raises( + DuplicatedTypeName, + match="Type Conflict is defined multiple times in the schema", + ): + strawberry.Schema(query=Query) + + +def test_constructs_graphql_schema_and_directive_definitions_once(monkeypatch): + @strawberry.schema_directive(locations=[Location.INPUT_OBJECT]) + class OnConfig: ... + + @strawberry.input(directives=[OnConfig()]) + class Config: + value: str + + @strawberry.schema_directive(locations=[Location.OBJECT]) + class Marker: + config: Config + + @strawberry.type(directives=[Marker(config=Config(value="example"))]) + class Query: + value: str + + graphql_schema_calls = 0 + converted_directives: list[type] = [] + graphql_schema = schema_module.GraphQLSchema + from_schema_directive = GraphQLCoreConverter.from_schema_directive + + def counting_graphql_schema(*args: object, **kwargs: object): + nonlocal graphql_schema_calls + graphql_schema_calls += 1 + return graphql_schema(*args, **kwargs) + + def counting_from_schema_directive(self, directive_type): + converted_directives.append(directive_type) + return from_schema_directive(self, directive_type) + + monkeypatch.setattr(schema_module, "GraphQLSchema", counting_graphql_schema) + monkeypatch.setattr( + GraphQLCoreConverter, + "from_schema_directive", + counting_from_schema_directive, + ) + + schema = strawberry.Schema(query=Query) + schema.as_str() + + assert graphql_schema_calls == 1 + assert converted_directives == [Marker, OnConfig] + + +def test_reports_unresolved_directive_argument_types(): + @strawberry.schema_directive(locations=[Location.OBJECT]) + class Marker: + config: "MissingConfig" # noqa: F821 + + @strawberry.type(directives=[Marker(config=None)]) # type: ignore[arg-type] + class Query: + name: str + + with pytest.raises( + UnresolvedFieldTypeError, + match=( + r"Could not resolve the type of 'config'\. Check that the class is " + r"accessible from the global module scope\." + ), + ): + strawberry.Schema(query=Query) diff --git a/tests/test_printer/test_basic.py b/tests/test_printer/test_basic.py index 6101b04356..b0de2528d6 100644 --- a/tests/test_printer/test_basic.py +++ b/tests/test_printer/test_basic.py @@ -40,6 +40,73 @@ class Query: assert print_schema(schema) == textwrap.dedent(expected_type).strip() +def test_does_not_print_unreferenced_hidden_explicit_type(): + Hidden = strawberry.scalar(str, name="Hidden", print_definition=False) + + @strawberry.type + class Query: + name: str + + schema = strawberry.Schema(query=Query, types=[Hidden]) + + expected_type = """ + type Query { + name: String! + } + """ + + assert schema._schema.get_type("Hidden") is not None + assert print_schema(schema) == textwrap.dedent(expected_type).strip() + + +def test_caches_schema_type_reachability(monkeypatch): + @strawberry.interface + class Node: + id: strawberry.ID + + @strawberry.type + class User(Node): + name: str + + @strawberry.type + class Query: + node: Node + + schema = strawberry.Schema(query=Query, types=[User]) + get_possible_types = schema._schema.get_possible_types + calls = 0 + + def counting_get_possible_types(type_): + nonlocal calls + calls += 1 + return get_possible_types(type_) + + monkeypatch.setattr( + schema._schema, "get_possible_types", counting_get_possible_types + ) + + expected_type = """ + interface Node { + id: ID! + } + + type Query { + node: Node! + } + + type User implements Node { + id: ID! + name: String! + } + """ + expected_type = textwrap.dedent(expected_type).strip() + + assert print_schema(schema) == expected_type + assert calls == 1 + assert print_schema(schema) == expected_type + assert calls == 1 + + def test_printer_with_camel_case_on(): @strawberry.type class Query: diff --git a/tests/test_printer/test_defer_stream.py b/tests/test_printer/test_defer_stream.py index 41022799eb..089b975751 100644 --- a/tests/test_printer/test_defer_stream.py +++ b/tests/test_printer/test_defer_stream.py @@ -35,14 +35,35 @@ def test_prints_defer_and_stream_directives_when_experimental_execution_is_enabl config=StrawberryConfig(enable_experimental_incremental_execution=True), ) - expected_type = """ - directive @defer(if: Boolean, label: String) on FRAGMENT_SPREAD | INLINE_FRAGMENT + expected_type = ''' + """ + Directs the executor to defer this fragment when the `if` argument is true or undefined. + """ + directive @defer( + """Deferred when true or undefined.""" + if: Boolean! = true - directive @stream(if: Boolean, label: String, initialCount: Int = 0) on FIELD + """Unique name""" + label: String + ) on FRAGMENT_SPREAD | INLINE_FRAGMENT + + """ + Directs the executor to stream plural fields when the `if` argument is true or undefined. + """ + directive @stream( + """Stream when true or undefined.""" + if: Boolean! = true + + """Unique name""" + label: String + + """Number of items to return immediately""" + initialCount: Int = 0 + ) on FIELD type Query { hello: String! } - """ + ''' assert str(schema) == textwrap.dedent(expected_type).strip() diff --git a/tests/test_printer/test_schema_directives.py b/tests/test_printer/test_schema_directives.py index 055d6e57cd..2a84479af6 100644 --- a/tests/test_printer/test_schema_directives.py +++ b/tests/test_printer/test_schema_directives.py @@ -132,17 +132,17 @@ def user(self, input: Input) -> User: user(input: Input!): User! } + input SensitiveValue { + key: String! + value: String! + } + type User @sensitiveData(reason: "GDPR") { firstName: String! age: Int! phone: String! @sensitiveData(reason: "PRIVATE", meta: [{ key: "can_share_field", value: "phone_share_accepted" }]) phoneShareAccepted: Boolean! } - - input SensitiveValue { - key: String! - value: String! - } """ schema = strawberry.Schema(query=Query) @@ -514,6 +514,43 @@ def run(self, config: Config) -> bool: assert print_schema(schema) == textwrap.dedent(expected_output).strip() +def test_deduplicates_directives_discovered_in_directive_argument_types(): + @strawberry.schema_directive(locations=[Location.INPUT_OBJECT]) + class OnConfig: ... + + @strawberry.input(directives=[OnConfig()]) + class Config: + value: str + + @strawberry.schema_directive(locations=[Location.OBJECT]) + class WithConfig: + config: Config | None = strawberry.UNSET + + @strawberry.type(directives=[WithConfig()]) + class Query: + name: str + + schema = strawberry.Schema(query=Query) + + expected_output = """ + directive @onConfig on INPUT_OBJECT + + directive @withConfig(config: Config) on OBJECT + + input Config @onConfig { + value: String! + } + + type Query @withConfig { + name: String! + } + """ + expected_output = textwrap.dedent(expected_output).strip() + + assert print_schema(schema) == expected_output + assert print_schema(schema) == expected_output + + def test_does_not_print_definition(): @strawberry.schema_directive( locations=[Location.FIELD_DEFINITION], print_definition=False @@ -831,14 +868,14 @@ def foo(self, info: strawberry.Info) -> str: ... expected_output = """ directive @fooDirective(input: FooInput!, optionalInput: FooInput) on FIELD_DEFINITION - type Query { - foo: String! @fooDirective(input: { a: "something" }) - } - input FooInput { a: String b: String } + + type Query { + foo: String! @fooDirective(input: { a: "something" }) + } """ schema = strawberry.Schema(query=Query) @@ -872,14 +909,14 @@ def foo(self, info: strawberry.Info) -> str: ... expected_output = """ directive @fooDirective(input: FooInput!, optionalInput: FooInput) on FIELD_DEFINITION - type Query { - foo: String! @fooDirective(input: { hello: "hello", helloWorld: "hello world" }) - } - input FooInput { hello: String! helloWorld: String! } + + type Query { + foo: String! @fooDirective(input: { hello: "hello", helloWorld: "hello world" }) + } """ schema = strawberry.Schema(query=Query)