Skip to content

Commit a15db4d

Browse files
authored
feat(validator): Add a GraphQL schema and resolvers validator + admin UI (drupal-graphql#1155)
1 parent c911a07 commit a15db4d

10 files changed

Lines changed: 807 additions & 2 deletions

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"license": "GPL-2.0+",
77
"require": {
88
"php": ">=7.2",
9-
"webonyx/graphql-php": "^14.3.0"
9+
"webonyx/graphql-php": "^14.5.0"
1010
},
1111
"minimum-stability": "dev"
1212
}

graphql.links.task.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ graphql.voyager:
2424
base_route: entity.graphql_server.edit_form
2525
title: Voyager
2626

27+
graphql.validate:
28+
route_name: graphql.validate
29+
base_route: entity.graphql_server.edit_form
30+
title: Validate
31+
2732
entity.graphql_server.collection:
2833
title: Servers
2934
route_name: entity.graphql_server.collection

graphql.routing.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,20 @@ graphql.voyager:
6464
graphql_server:
6565
type: entity:graphql_server
6666

67+
graphql.validate:
68+
path: '/admin/config/graphql/servers/manage/{graphql_server}/validate'
69+
defaults:
70+
_controller: '\Drupal\graphql\Controller\ValidationController::report'
71+
_title: 'Validate GraphQL Server'
72+
requirements:
73+
_permission: 'administer graphql configuration'
74+
options:
75+
_admin_route: TRUE
76+
parameters:
77+
graphql_server:
78+
type: entity:graphql_server
79+
80+
6781
entity.graphql_server.delete_form:
6882
path: '/admin/config/graphql/servers/manage/{graphql_server}/delete'
6983
defaults:

graphql.services.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,11 @@ services:
8989
graphql.introspection:
9090
class: Drupal\graphql\GraphQL\Utility\Introspection
9191

92+
# Validator service.
93+
graphql.validator:
94+
class: Drupal\graphql\GraphQL\Validator
95+
arguments: ['@plugin.manager.graphql.schema', '@logger.channel.graphql']
96+
9297
# Reset the current language during sub-requests.
9398
graphql.subrequest_subscriber:
9499
class: Drupal\graphql\EventSubscriber\SubrequestSubscriber
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
<?php
2+
3+
namespace Drupal\graphql\Controller;
4+
5+
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
6+
use Drupal\Core\StringTranslation\StringTranslationTrait;
7+
use Drupal\graphql\Entity\ServerInterface;
8+
use Drupal\graphql\GraphQL\ValidatorInterface;
9+
use Symfony\Component\DependencyInjection\ContainerInterface;
10+
11+
/**
12+
* Controller for the GraphiQL resolver validation.
13+
*/
14+
class ValidationController implements ContainerInjectionInterface {
15+
use StringTranslationTrait;
16+
17+
/**
18+
* The schema plugin manager.
19+
*
20+
* @var \Drupal\graphql\GraphQL\ValidatorInterface
21+
*/
22+
protected $validator;
23+
24+
/**
25+
* {@inheritdoc}
26+
*/
27+
public static function create(ContainerInterface $container) : self {
28+
return new static(
29+
$container->get('graphql.validator')
30+
);
31+
}
32+
33+
/**
34+
* ValidateResolverController constructor.
35+
*
36+
* @param \Drupal\graphql\GraphQL\ValidatorInterface $validator
37+
* The GraphQL validator.
38+
*/
39+
public function __construct(ValidatorInterface $validator) {
40+
$this->validator = $validator;
41+
}
42+
43+
/**
44+
* Controller for the GraphiQL query builder IDE.
45+
*
46+
* @param \Drupal\graphql\Entity\ServerInterface $graphql_server
47+
* The GraphQL server entity.
48+
*
49+
* @return array
50+
* The render array.
51+
*/
52+
public function report(ServerInterface $graphql_server) {
53+
$build = [
54+
'validation' => [
55+
'#type' => 'table',
56+
'#caption' => $this->t("Validation errors"),
57+
'#header' => [$this->t('Type'), $this->t('Field'), $this->t('Message')],
58+
'#empty' => $this->t("No validation errors."),
59+
],
60+
'orphaned' => [
61+
'#type' => 'table',
62+
'#caption' => $this->t("Resolvers without schema"),
63+
'#header' => [$this->t('Type'), $this->t('Fields')],
64+
'#empty' => $this->t("No orphaned resolvers."),
65+
],
66+
'missing' => [
67+
'#type' => 'table',
68+
'#caption' => $this->t("Fields without resolvers"),
69+
'#header' => [$this->t('Type'), $this->t('Fields')],
70+
'#empty' => $this->t("No missing resolvers."),
71+
],
72+
];
73+
74+
foreach ($this->validator->validateSchema($graphql_server) as $error) {
75+
$type = '';
76+
if (isset($error->nodes[1]) && property_exists($error->nodes[1], 'name')) {
77+
$type = $error->nodes[1]->name->value;
78+
}
79+
$field = '';
80+
if (isset($error->nodes[0]) && property_exists($error->nodes[0], 'name')) {
81+
$field = $error->nodes[0]->name->value;
82+
}
83+
84+
$build['validation'][] = [
85+
'type' => ['#plain_text' => $type],
86+
'field' => ['#plain_text' => $field],
87+
'message' => ['#plain_text' => $error->getMessage()],
88+
];
89+
}
90+
91+
// @todo Ability to configure ignores here.
92+
$metrics = [
93+
'orphaned' => $this->validator->getOrphanedResolvers($graphql_server),
94+
'missing' => $this->validator->getMissingResolvers($graphql_server),
95+
];
96+
97+
foreach ($metrics as $metric_type => $data) {
98+
foreach ($data as $type => $fields) {
99+
$build[$metric_type][$type] = [
100+
'type' => ['#plain_text' => $type],
101+
'fields' => [
102+
'#theme' => 'item_list',
103+
'#items' => $fields,
104+
],
105+
];
106+
}
107+
}
108+
109+
return $build;
110+
}
111+
112+
}

src/GraphQL/ResolverRegistry.php

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use Drupal\graphql\GraphQL\Execution\FieldContext;
66
use Drupal\graphql\GraphQL\Execution\ResolveContext;
77
use GraphQL\Executor\Executor;
8+
use GraphQL\Type\Definition\ImplementingType;
89
use GraphQL\Type\Definition\ResolveInfo;
910
use GraphQL\Type\Definition\Type;
1011
use Drupal\graphql\GraphQL\Resolver\ResolverInterface;
@@ -116,6 +117,18 @@ public function getFieldResolver($type, $field) {
116117
return $this->fieldResolvers[$type][$field] ?? NULL;
117118
}
118119

120+
/**
121+
* Return all field resolvers in the registry.
122+
*
123+
* @return callable[]
124+
* A nested list of callables, keyed by type and field name.
125+
*
126+
* @todo This should be added to ResolverRegistryInterface in 5.0.0.
127+
*/
128+
public function getAllFieldResolvers() : array {
129+
return $this->fieldResolvers;
130+
}
131+
119132
/**
120133
* {@inheritdoc}
121134
*/
@@ -131,6 +144,46 @@ public function getTypeResolver($type) {
131144
return $this->typeResolvers[$type] ?? NULL;
132145
}
133146

147+
/**
148+
* Get a field resolver for the type or any of the interfaces it implements.
149+
*
150+
* This allows common functionality (such as for Edge's or Connections) to be
151+
* implemented for an interface and re-used on any concrete type that extends
152+
* it.
153+
*
154+
* This should be used instead of `getFieldResolver` unless you're certain you
155+
* want the resolver only for the specific type.
156+
*
157+
* @param \GraphQL\Type\Definition\Type $type
158+
* The type to find a resolver for.
159+
* @param string $fieldName
160+
* The name of the field to find a resolver for.
161+
*
162+
* @return \Drupal\graphql\GraphQL\Resolver\ResolverInterface|null
163+
* The defined resolver for the field or NULL if none exists.
164+
*
165+
* @todo This should be added to ResolverRegistryInterface in 5.0.0.
166+
*/
167+
public function getFieldResolverWithInheritance(Type $type, string $fieldName) : ?ResolverInterface {
168+
if ($resolver = $this->getFieldResolver($type->name, $fieldName)) {
169+
return $resolver;
170+
}
171+
172+
if (!$type instanceof ImplementingType) {
173+
return NULL;
174+
}
175+
176+
// Go through the interfaces implemented for the type on which this field is
177+
// resolved and check if they lead to a field resolution.
178+
foreach ($type->getInterfaces() as $interface) {
179+
if ($resolver = $this->getFieldResolverWithInheritance($interface, $fieldName)) {
180+
return $resolver;
181+
}
182+
}
183+
184+
return NULL;
185+
}
186+
134187
/**
135188
* Returns the field resolver that should be used at runtime.
136189
*
@@ -142,7 +195,7 @@ public function getTypeResolver($type) {
142195
* @return callable|null
143196
*/
144197
protected function getRuntimeFieldResolver($value, $args, ResolveContext $context, ResolveInfo $info) {
145-
return $this->getFieldResolver($info->parentType->name, $info->fieldName);
198+
return $this->getFieldResolverWithInheritance($info->parentType, $info->fieldName);
146199
}
147200

148201
/**

0 commit comments

Comments
 (0)