8000 [RFC] SDL validation · graphql-python/graphql-core@06b0a26 · GitHub
[go: up one dir, main page]

Skip to content

Commit 06b0a26

Browse files
committed
[RFC] SDL validation
Replicates graphql/graphql-js@38760a9
1 parent fcd17e0 commit 06b0a26

16 files changed

+326
-85
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ a query language for APIs created by Facebook.
1313

1414
The current version 1.0.0rc2 of GraphQL-core-next is up-to-date with GraphQL.js
1515
version 14.0.0rc2. All parts of the API are covered by an extensive test
16-
suite of currently 1531 unit tests.
16+
suite of currently 1539 unit tests.
1717

1818

1919
## Documentation

graphql/utilities/build_ast_schema.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@
2727
'ASTDefinitionBuilder']
2828

2929

30-
def build_ast_schema(ast: DocumentNode, assume_valid: bool=False):
30+
def build_ast_schema(
31+
ast: DocumentNode, assume_valid: bool=False,
32+
assume_valid_sdl: bool=False) -> GraphQLSchema:
3133
"""Build a GraphQL Schema from a given AST.
3234
3335
This takes the ast of a schema document produced by the parse function in
@@ -41,11 +43,16 @@ def build_ast_schema(ast: DocumentNode, assume_valid: bool=False):
4143
4244
When building a schema from a GraphQL service's introspection result, it
4345
might be safe to assume the schema is valid. Set `assume_valid` to True
44-
to assume the produced schema is valid.
46+
to assume the produced schema is valid. Set `assume_valid_sdl` to True to
47+
assume it is already a valid SDL document.
4548
"""
4649
if not isinstance(ast, DocumentNode):
4750
raise TypeError('Must provide a Document AST.')
4851

52+
if not (assume_valid or assume_valid_sdl):
53+
from ..validation.validate import assert_valid_sdl
54+
assert_valid_sdl(ast)
55+
4956
schema_def: Optional[SchemaDefinitionNode] = None
5057
type_defs: List[TypeDefinitionNode] = []
5158
append_type_def = type_defs.append
@@ -61,8 +68,6 @@ def build_ast_schema(ast: DocumentNode, assume_valid: bool=False):
6168
InputObjectTypeDefinitionNode)
6269
for d in ast.definitions:
6370
if isinstance(d, SchemaDefinitionNode):
64-
if schema_def:
65-
raise TypeError('Must provide only one schema definition.')
6671
schema_def = d
6772
elif isinstance(d, type_definition_nodes):
6873
d = cast(TypeDefinitionNode, d)
@@ -372,10 +377,10 @@ def get_description(node: Node) -> Optional[str]:
372377

373378

374379
def build_schema(source: Union[str, Source],
375-
assume_valid=False, no_location=False,
380+
assume_valid=False, assume_valid_sdl=False, no_location=False,
376381
experimental_fragment_variables=False) -> GraphQLSchema:
377382
"""Build a GraphQLSchema directly from a source document."""
378383
return build_ast_schema(parse(
379384
source, no_location=no_location,
380385
experimental_fragment_variables=experimental_fragment_variables),
381-
assume_valid=assume_valid)
386+
assume_valid=assume_valid, assume_valid_sdl=assume_valid_sdl)

graphql/utilities/extend_schema.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,9 @@
3030
__all__ = ['extend_schema']
3131

3232

33-
def extend_schema(schema: GraphQLSchema, document_ast: DocumentNode,
34-
assume_valid=False) -> GraphQLSchema:
33+
def extend_schema(
34+
schema: GraphQLSchema, document_ast: DocumentNode,
35+
assume_valid=False, assume_valid_sdl=False) -> GraphQLSchema:
3536
"""Extend the schema with extensions from a given document.
3637
3738
Produces a new schema given an existing schema and a document which may
@@ -47,7 +48,8 @@ def extend_schema(schema: GraphQLSchema, document_ast: DocumentNode,
4748
4849
When extending a schema with a known valid extension, it might be safe to
4950
assume the schema is valid. Set `assume_valid` to true to assume the
50-
produced schema is valid.
51+
produced schema is valid. Set `assume_valid_sdl` to True to assume it is
52+
already a valid SDL document.
5153
"""
5254

5355
if not is_schema(schema):
@@ -56,6 +58,10 @@ def extend_schema(schema: GraphQLSchema, document_ast: DocumentNode,
5658
if not isinstance(document_ast, DocumentNode):
5759
'Must provide valid Document AST'
5860

61+
if not (assume_valid or assume_valid_sdl):
62+
from ..validation.validate import assert_valid_sdl_extension
63+
assert_valid_sdl_extension(document_ast, schema)
64+
5965
# Collect the type definitions and extensions found in the document.
6066
type_definition_map: Dict[str, Any] = {}
6167
type_extensions_map: Dict[str, Any] = defaultdict(list)
@@ -70,12 +76,6 @@ def extend_schema(schema: GraphQLSchema, document_ast: DocumentNode,
7076

7177
for def_ in document_ast.definitions:
7278
if isinstance(def_, SchemaDefinitionNode):
73-
# Sanity check that a schema extension is not overriding the schema
74-
if (schema.ast_node or schema.query_type or
75-
schema.mutation_type or schema.subscription_type):
76-
raise GraphQLError(
77-
'Cannot define a new schema within a schema extension.',
78-
[def_])
7979
schema_def = def_
8080
elif isinstance(def_, SchemaExtensionNode):
8181
schema_extensions.append(def_)

graphql/validation/rules/__init__.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44

55
from ...error import GraphQLError
66
from ...language.visitor import Visitor
7-
from ..validation_context import ASTValidationContext, ValidationContext
7+
from ..validation_context import (
8+
ASTValidationContext, SDLValidationContext, ValidationContext)
89

9-
__all__ = ['ASTValidationRule', 'ValidationRule', 'RuleType']
10+
__all__ = [
11+
'ASTValidationRule', 'SDLValidationRule', 'ValidationRule', 'RuleType']
1012

1113

1214
class ASTValidationRule(Visitor):
@@ -20,6 +22,14 @@ def report_error(self, error: GraphQLError):
2022
self.context.report_error(error)
2123

2224

25+
class SDLValidationRule(ASTValidationRule):
26+
27+
context: ValidationContext
28+
29+
def __init__(self, context: SDLValidationContext) -> None:
30+
super().__init__(context)
31+
32+
2333
class ValidationRule(ASTValidationRule):
2434

2535
context: ValidationContext

graphql/validation/rules/known_directives.py

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
from typing import cast
1+
from typing import cast, Dict, List, Union
22

33
from ...error import GraphQLError
44
from ...language import (
5-
DirectiveLocation, DirectiveNode, Node, OperationDefinitionNode)
6-
from . import ValidationRule
5+
DirectiveLocation, DirectiveDefinitionNode, DirectiveNode, Node,
6+
OperationDefinitionNode)
7+
from ...type import specified_directives
8+
from . import ASTValidationRule, SDLValidationContext, ValidationContext
79

810
__all__ = [
911
'KnownDirectivesRule',
@@ -18,26 +20,40 @@ def misplaced_directive_message(directive_name: str, location: str) -> str:
1820
return f"Directive '{directive_name}' may not be used on {location}."
1921

2022

21-
class KnownDirectivesRule(ValidationRule):
23+
class KnownDirectivesRule(ASTValidationRule):
2224
"""Known directives
2325
2426
A GraphQL document is only valid if all `@directives` are known by the
2527
schema and legally positioned.
2628
"""
2729

30+
def __init__(self, context: Union[
31+
ValidationContext, SDLValidationContext]) -> None:
32+
super().__init__(context)
33+
schema = context.schema
34+
locations_map: Dict[str, List[DirectiveLocation]] = {}
35+
defined_directives = (
36+
schema.directives if schema else cast(List, specified_directives))
37+
for directive in defined_directives:
38+
locations_map[directive.name] = directive.locations
39+
ast_definitions = context.document.definitions
40+
for def_ in ast_definitions:
41+
if isinstance(def_, DirectiveDefinitionNode):
42+
locations_map[def_.name.value] = [
43+
DirectiveLocation[name.value] for name in def_.locations]
44+
self.locations_map = locations_map
45+
2846
def enter_directive(
2947
self, node: DirectiveNode, _key, _parent, _path, ancestors):
30-
for definition in self.context.schema.directives:
31-
if definition.name == node.name.value:
32-
candidate_location = get_directive_location_for_ast_path(
33-
ancestors)
34-
if (candidate_location
35-
and candidate_location not in definition.locations):
36-
self.report_error(GraphQLError(
37-
misplaced_directive_message(
38-
node.name.value, candidate_location.value),
39-
[node]))
40-
break
48+
name = node.name.value
49+
locations = self.locations_map.get(name)
50+
if locations:
51+
candidate_location = get_directive_location_for_ast_path(
52+
ancestors)
53+
if candidate_location and candidate_location not in locations:
54+
self.report_error(GraphQLError(
55+
misplaced_directive_message(
56+
node.name.value, candidate_location.value), [node]))
4157
else:
4258
self.report_error(GraphQLError(
4359
unknown_directive_message(node.name.value), [node]))

graphql/validation/rules/known_fragment_names.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
__all__ = ['KnownFragmentNamesRule', 'unknown_fragment_message']
66

77

8-
def unknown_fragment_message(fragment_name):
8+
def unknown_fragment_message(fragment_name: str) -> str:
99
return f"Unknown fragment '{fragment_name}'."
1010

1111

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from typing import List
2+
3+
from ...error import GraphQLError
4+
from ...language import SchemaDefinitionNode
5+
from . import SDLValidationRule, SDLValidationContext
6+
7+
__all__ = [
8+
'LoneSchemaDefinition',
9+
'schema_definition_alone_message', 'cannot_define_schema_within_extension']
10+
11+
12+
def schema_definition_alone_message():
13+
return 'Must provide only one schema definition.'
14+
15+
16+
def cannot_define_schema_within_extension():
17+
return 'Cannot define a new schema within a schema extension.'
18+
19+
20+
class LoneSchemaDefinition(SDLValidationRule):
21+
"""Lone Schema definition
22+
23+
A GraphQL document is only valid if it contains only one schema definition.
24+
"""
25+
26+
def __init__(self, context: SDLValidationContext) -> None:
27+
super().__init__(context)
28+
old_schema = context.schema
29+
self.already_defined = old_schema and (
30+
old_schema.ast_node or old_schema.query_type or
31+
old_schema.mutation_type or old_schema.subscription_type)
32+
self.schema_nodes: List[SchemaDefinitionNode] = []
33+
34+
def enter_schema_definition(self, node: SchemaDefinitionNode, *_args):
35+
if self.already_defined:
36+
self.report_error(GraphQLError(
37+
cannot_define_schema_within_extension(), [node]))
38+
else:
39+
self.schema_nodes.append(node)
40+
41+
def leave_document(self, *_args):
42+
if len(self.schema_nodes) > 1:
43+
self.report_error(GraphQLError(
44+
schema_definition_alone_message(), self.schema_nodes))

graphql/validation/specified_rules.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,10 @@
8282
# Spec Section: "Input Object Field Uniqueness"
8383
from .rules.unique_input_field_names import UniqueInputFieldNamesRule
8484

85-
__all__ = ['specified_rules']
85+
# Schema definition language:
86+
from .rules.lone_schema_definition import LoneSchemaDefinition
87+
88+
__all__ = ['specified_rules', 'specified_sdl_rules']
8689

8790

8891
# This list includes all validation rules defined by the GraphQL spec.
@@ -117,3 +120,10 @@
117120
VariablesInAllowedPositionRule,
118121
OverlappingFieldsCanBeMergedRule,
119122
UniqueInputFieldNamesRule]
123+
124+
specified_sdl_rules: List[RuleType] = [
125+
LoneSchemaDefinition,
126+
KnownDirectivesRule,
127+
UniqueDirectivesPerLocationRule,
128+
UniqueArgumentNamesRule,
129+
UniqueInputFieldNamesRule]

graphql/validation/validate.py

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@
55
from ..type import GraphQLSchema, assert_valid_schema
66
from ..utilities import TypeInfo
77
from .rules import RuleType
8-
from .specified_rules import specified_rules
9-
from .validation_context import ValidationContext
8+
from .specified_rules import specified_rules, specified_sdl_rules
9+
from .validation_context import SDLValidationContext, ValidationContext
1010

11-
__all__ = ['validate']
11+
__all__ = [
12+
'assert_valid_sdl', 'assert_valid_sdl_extension',
13+
'validate', 'validate_sdl']
1214

1315

1416
def validate(schema: GraphQLSchema, document_ast: DocumentNode,
@@ -49,3 +51,40 @@ def validate(schema: GraphQLSchema, document_ast: DocumentNode,
4951
# Visit the whole document with each instance of all provided rules.
5052
visit(document_ast, TypeInfoVisitor(type_info, ParallelVisitor(visitors)))
5153
return context.errors
54+
55+
56+
def validate_sdl(document_ast: DocumentNode,
57+
schema_to_extend: GraphQLSchema=None,
58+
rules: Sequence[RuleType]=None) -> List[GraphQLError]:
59+
"""Validate an SDL document."""
60+
context = SDLValidationContext(document_ast, schema_to_extend)
61+
if rules is None:
62+
rules = specified_sdl_rules
63+
visitors = [rule(context) for rule in rules]
64+
visit(document_ast, ParallelVisitor(visitors))
65+
return context.errors
66+
67+
68+
def assert_valid_sdl(document_ast: DocumentNode) -> None:
69+
"""Assert document is valid SDL.
70+
71+
Utility function which asserts a SDL document is valid by throwing an error
72+
if it is invalid.
73+
"""
74+
75+
errors = validate_sdl(document_ast)
76+
if errors:
77+
raise TypeError('\n\n'.join(error.message for error in errors))
78+
79+
80+
def assert_valid_sdl_extension(
81+
document_ast: DocumentNode, schema: GraphQLSchema) -> None:
82+
"""Assert document is a valid SDL extension.
83+
84+
Utility function which asserts a SDL document is valid by throwing an error
85+
if it is invalid.
86+
"""
87+
88+
errors = validate_sdl(document_ast, schema)
89+
if errors:
90+
raise TypeError('\n\n'.join(error.message for error in errors))

graphql/validation/validation_context.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from ..utilities import TypeInfo
1010

1111
__all__ = [
12-
'ASTValidationContext', 'ValidationContext',
12+
'ASTValidationContext', 'SDLValidationContext', 'ValidationContext',
1313
'VariableUsage', 'VariableUsageVisitor']
1414

1515
NodeWithSelectionSet = Union[OperationDefinitionNode, FragmentDefinitionNode]
@@ -60,6 +60,21 @@ def report_error(self, error: GraphQLError):
6060
self.errors.append(error)
6161

6262

63+
class SDLValidationContext(ASTValidationContext):
64+
"""Utility class providing a context for validation of an SDL ast.
65+
66+
An instance of this class is passed as the context attribute to all
67+
Validators, allowing access to commonly useful contextual information
68+
from within a validation rule.
69+
"""
70+
71+
schema: Optional[GraphQLSchema]
72+
73+
def __init__(self, ast: DocumentNode, schema: GraphQLSchema=None) -> None:
74+
super().__init__(ast)
75+
self.schema = schema
76+
77+
6378
class ValidationContext(ASTValidationContext):
6479
"""Utility class providing a context for validation using a GraphQL schema.
6580

tests/type/test_validation.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -518,10 +518,15 @@ def rejects_an_input_object_type_with_missing_fields():
518518
519519
input SomeInputObject
520520
""")
521+
schema = extend_schema(schema, parse("""
522+
directive @test on INPUT_OBJECT
523+
524+
extend input SomeInputObject @test
525+
"""))
521526
assert validate_schema(schema) == [{
522527
'message': 'Input Object type SomeInputObject'
523528
' must define one or more fields.',
524-
'locations': [(6, 13)]}]
529+
'locations': [(6, 13), (4, 13)]}]
525530

526531
def rejects_an_input_object_type_with_incorrectly_typed_fields():
527532
# invalid schema cannot be built with Python

0 commit comments

Comments
 (0)
0