Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
22 changes: 22 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
release type: patch
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.

Existing Strawberry SDL output stays compatible, and a directive reused across
the schema is defined only once.
43 changes: 40 additions & 3 deletions strawberry/printer/printer.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,17 @@
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,
is_input_object_type,
is_input_type,
is_interface_type,
is_object_type,
Expand Down Expand Up @@ -49,6 +56,7 @@
GraphQLArgument,
GraphQLEnumType,
GraphQLEnumValue,
GraphQLInputObjectType,
GraphQLNamedType,
GraphQLScalarType,
GraphQLUnionType,
Expand Down Expand Up @@ -609,7 +617,8 @@ def print_schema(schema: BaseSchema) -> str:
types = [
type_
for type_name in sorted(type_map)
if is_defined_type(type_ := type_map[type_name])
if type_name not in schema._schema_directive_argument_types
and is_defined_type(type_ := type_map[type_name])
]

types_printed = [_print_type(type_, schema, extras=extras) for type_ in types]
Expand All @@ -619,6 +628,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 All @@ -638,6 +648,8 @@ def _name_getter(type_: Any) -> str:

def _print_extra_types() -> Iterable[str]:
# Make sure extra types are ordered for predictive printing
graphql_types: dict[str, GraphQLNamedType] = {}

for type_ in sorted(extras.types, key=_name_getter):
graphql_type = cast(
"GraphQLNamedType", schema.schema_converter.from_type(type_)
Expand All @@ -646,9 +658,34 @@ def _print_extra_types() -> Iterable[str]:
# 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.
if graphql_type.name in type_map:
if (
graphql_type.name in type_map
and graphql_type.name not in schema._schema_directive_argument_types
):
continue

graphql_types[graphql_type.name] = graphql_type

def add_referenced_types(graphql_type: GraphQLNamedType) -> None:
if not is_input_object_type(graphql_type):
return

input_type = cast("GraphQLInputObjectType", graphql_type)
for field in input_type.fields.values():
field_type = get_named_type(field.type)
if field_type.name in graphql_types or (
field_type.name in type_map
and field_type.name not in schema._schema_directive_argument_types
):
continue

graphql_types[field_type.name] = field_type
add_referenced_types(field_type)

for graphql_type in tuple(graphql_types.values()):
add_referenced_types(graphql_type)

for graphql_type in graphql_types.values():
yield _print_type(graphql_type, schema, extras=extras)

return "\n\n".join(
Expand Down
1 change: 1 addition & 0 deletions strawberry/schema/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class BaseSchema(Protocol):
mutation: type[WithStrawberryObjectDefinition] | None
subscription: type[WithStrawberryObjectDefinition] | None
schema_directives: list[object]
_schema_directive_argument_types: set[str]
exception_handlers: tuple[ExceptionHandler[Any], ...]

@abstractmethod
Expand Down
198 changes: 177 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,18 @@ 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)
self._schema_directive_argument_types: set[str] = set()

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 +443,158 @@ 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 (
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)

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)
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Outdated

self._schema_directive_types = tuple(schema_directive_types)

if self._schema_directive_types == self._explicit_schema_directive_types:
return

existing_type_names = set(self._schema.type_map)
self._schema = self._create_graphql_schema(self._schema_directive_types)
self._schema_directive_argument_types.update(
set(self._schema.type_map) - existing_type_names
Comment thread
patrick91 marked this conversation as resolved.
Outdated
)

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
9 changes: 5 additions & 4 deletions tests/schema/test_basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading