Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
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.

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.
3 changes: 3 additions & 0 deletions strawberry/federation/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,9 @@ def _warn_for_federation_directives(self) -> None:

pass

def _should_register_schema_directive(self, directive: object) -> bool:
return True


def _get_entity_type(
query: type[WithStrawberryObjectDefinition] | None,
Expand Down
8 changes: 7 additions & 1 deletion strawberry/printer/printer.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@
overload,
)

from graphql import GraphQLInputField, GraphQLObjectType, GraphQLSchema, is_union_type
from graphql import (
GraphQLInputField,
GraphQLObjectType,
GraphQLSchema,
is_union_type,
)
from graphql.language.printer import print_ast
from graphql.type import (
is_enum_type,
Expand Down Expand Up @@ -619,6 +624,7 @@ def print_schema(schema: BaseSchema) -> str:
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
]

if schema.config.enable_experimental_incremental_execution:
Expand Down
206 changes: 185 additions & 21 deletions strawberry/schema/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@
import asyncio
import warnings
from asyncio import ensure_future
from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable, Iterable
from collections.abc import (
AsyncGenerator,
AsyncIterator,
Awaitable,
Callable,
Iterable,
Iterator,
)
from functools import lru_cache
from inspect import isawaitable
from typing import (
Expand All @@ -21,6 +28,7 @@
FieldNode,
FragmentDefinitionNode,
GraphQLBoolean,
GraphQLDirective,
GraphQLError,
GraphQLField,
GraphQLNamedType,
Expand Down Expand Up @@ -346,7 +354,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(
Expand All @@ -373,16 +381,16 @@ class Query:
else None
)

graphql_directives = [
self.schema_converter.from_directive(directive) for directive in directives
self._operation_graphql_directives = [
self.schema_converter.from_directive(directive)
for directive in self.directives
]

graphql_types = []
explicit_schema_directive_types = []
for type_ in types:
if compat.is_schema_directive(type_):
graphql_directives.append(
self.schema_converter.from_schema_directive(type_)
)
explicit_schema_directive_types.append(type_)
else:
if (
has_object_definition(type_)
Expand All @@ -396,22 +404,17 @@ class Query:
raise TypeError(f"{graphql_type} is not a named GraphQL Type")
graphql_types.append(graphql_type)

self._graphql_query_type = query_type
self._graphql_mutation_type = mutation_type
self._graphql_subscription_type = subscription_type
self._graphql_types = graphql_types
self._explicit_schema_directive_types = tuple(explicit_schema_directive_types)

try:
directives = specified_directives + tuple(graphql_directives) # type: ignore

if self.config.enable_experimental_incremental_execution:
directives = tuple(directives) + tuple(incremental_execution_directives)

self._schema = GraphQLSchema(
query=query_type,
mutation=mutation_type,
subscription=subscription_type if subscription else None,
directives=directives, # type: ignore
types=graphql_types,
extensions={
GraphQLCoreConverter.DEFINITION_BACKREF: self,
},
self._schema = self._create_graphql_schema(
self._explicit_schema_directive_types
)
self._register_schema_directives()
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Outdated

except TypeError as error:
# GraphQL core throws a TypeError if there's any exception raised
Expand Down Expand Up @@ -439,6 +442,162 @@ 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 _create_graphql_schema(
self, schema_directive_types: Iterable[type]
) -> GraphQLSchema:
directives = list(specified_directives)
directive_definitions: dict[str, object] = {
directive.name: directive for directive in directives
}
directive_sources = {
directive.name: "the built-in GraphQL directive" for directive in directives
}

def add_directive(
directive: GraphQLDirective,
definition: object,
source: str,
) -> None:
name = directive.name
if name not in directive_definitions:
directive_definitions[name] = definition
directive_sources[name] = source
directives.append(directive)
return

if directive_definitions[name] is definition:
return

raise ValueError(
f"Schema directive '@{name}' is defined by both "
f"{directive_sources[name]} and {source}. Use a different "
"GraphQL name for one of them."
)

for directive in self._operation_graphql_directives:
strawberry_directive = directive.extensions[
GraphQLCoreConverter.DEFINITION_BACKREF
]
add_directive(
directive,
strawberry_directive,
f"operation directive '{strawberry_directive.python_name}'",
)

from strawberry.schema_directives import OneOf

for directive_type in schema_directive_types:
strawberry_directive = cast("Any", directive_type).__strawberry_directive__
directive_name = self.config.name_converter.from_directive(
strawberry_directive
)

# Strawberry's OneOf schema directive predates graphql-core exposing
# the specified @oneOf directive. Keep its SDL behavior while using
# the canonical runtime directive.
if (
directive_type is OneOf
and directive_name == "oneOf"
and directive_name in directive_definitions
):
continue

add_directive(
self.schema_converter.from_schema_directive(directive_type),
directive_type,
(
"schema directive "
f"'{directive_type.__module__}.{directive_type.__qualname__}'"
),
)

if self.config.enable_experimental_incremental_execution:
for directive in incremental_execution_directives:
add_directive(
directive,
directive,
f"the experimental GraphQL directive '@{directive.name}'",
)

return GraphQLSchema(
query=self._graphql_query_type,
mutation=self._graphql_mutation_type,
subscription=self._graphql_subscription_type,
directives=tuple(directives),
types=self._graphql_types,
extensions={
GraphQLCoreConverter.DEFINITION_BACKREF: self,
},
)

def _register_schema_directives(self) -> None:
schema_directive_types = list(self._explicit_schema_directive_types)
seen_directive_types = set(schema_directive_types)

def add_directive(directive: object) -> None:
directive_type = directive.__class__
if (
self._should_register_schema_directive(directive)
and compat.is_schema_directive(directive_type)
and directive_type not in seen_directive_types
):
seen_directive_types.add(directive_type)
schema_directive_types.append(directive_type)

def add_directives(owner: object) -> None:
attached_directives = getattr(owner, "directives", ()) or ()
if isinstance(attached_directives, Iterator):
attached_directives = list(attached_directives)
owner.directives = attached_directives # type: ignore[attr-defined]

for directive in attached_directives:
add_directive(directive)

for directive in self.schema_directives:
add_directive(directive)

registered_directive_types = self._explicit_schema_directive_types

# Directive arguments can add types that have their own attached directives.
# Rebuild and rescan until every reachable directive has been registered.
while True:
for graphql_type in self._schema.type_map.values():
type_definition = (getattr(graphql_type, "extensions", None) or {}).get(
GraphQLCoreConverter.DEFINITION_BACKREF
)
if type_definition is not None:
add_directives(type_definition)

for field in getattr(graphql_type, "fields", {}).values():
field_definition = (field.extensions or {}).get(
GraphQLCoreConverter.DEFINITION_BACKREF
)
if field_definition is not None:
add_directives(field_definition)

for argument in getattr(field, "args", {}).values():
argument_definition = (argument.extensions or {}).get(
GraphQLCoreConverter.DEFINITION_BACKREF
)
if argument_definition is not None:
add_directives(argument_definition)

for value in getattr(graphql_type, "values", {}).values():
value_definition = (value.extensions or {}).get(
GraphQLCoreConverter.DEFINITION_BACKREF
)
if value_definition is not None:
add_directives(value_definition)

discovered_directive_types = tuple(schema_directive_types)
if discovered_directive_types == registered_directive_types:
break

self._schema = self._create_graphql_schema(discovered_directive_types)
registered_directive_types = discovered_directive_types

self._schema_directive_types = registered_directive_types

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
Expand Down Expand Up @@ -1201,6 +1360,11 @@ def _resolve_node_ids(self) -> None:
if not has_custom_resolve_id:
origin.resolve_id_attr()

def _should_register_schema_directive(self, directive: object) -> bool:
from strawberry.federation.schema_directives import FederationDirective

return not isinstance(directive, FederationDirective)
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Outdated

def _warn_for_federation_directives(self) -> None:
"""Raises a warning if the schema has any federation directives."""
from strawberry.federation.schema_directives import FederationDirective
Expand Down
4 changes: 4 additions & 0 deletions tests/federation/printer/test_additional_directives.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ class Query:

union _Entity = FederatedType

scalar _FieldSet

type _Service {
sdl: String!
}
Expand Down Expand Up @@ -99,6 +101,8 @@ class Query:

union _Entity = FederatedType

scalar _FieldSet

type _Service {
sdl: String!
}
Expand Down
4 changes: 4 additions & 0 deletions tests/federation/printer/test_compose_directive.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ class Query:

union _Entity = FederatedType

scalar _FieldSet

type _Service {
sdl: String!
}
Expand Down Expand Up @@ -119,6 +121,8 @@ class Query:

union _Entity = FederatedType

scalar _FieldSet

type _Service {
sdl: String!
}
Expand Down
2 changes: 2 additions & 0 deletions tests/federation/printer/test_entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ def top_products(self, first: int) -> list[Product]: # pragma: no cover

union _Entity = Product

scalar _FieldSet

type _Service {
sdl: String!
}
Expand Down
2 changes: 2 additions & 0 deletions tests/federation/printer/test_inaccessible.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ def top_products(

union _Entity = Product

scalar _FieldSet

type _Service {
sdl: String!
}
Expand Down
2 changes: 2 additions & 0 deletions tests/federation/printer/test_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ def top_products(self, first: int) -> list[Product]: # pragma: no cover

union _Entity = Product

scalar _FieldSet

type _Service {
sdl: String!
}
Expand Down
2 changes: 2 additions & 0 deletions tests/federation/printer/test_interface_object.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ class SomeInterface:

union _Entity = SomeInterface

scalar _FieldSet

type _Service {
sdl: String!
}
Expand Down
2 changes: 2 additions & 0 deletions tests/federation/printer/test_keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ def top_products(self, first: int) -> list[Product]: # pragma: no cover

union _Entity = Product | Review

scalar _FieldSet

type _Service {
sdl: String!
}
Expand Down
Loading
Loading