Passed
Pull Request — master (#539)
by Benedikt
03:07
created

BuildClientSchema::build()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 2
c 1
b 0
f 0
dl 0
loc 5
rs 10
cc 1
nc 1
nop 2
1
<?php
2
3
declare(strict_types=1);
4
5
namespace GraphQL\Utils;
6
7
use GraphQL\Error\InvariantViolation;
8
use GraphQL\Language\Parser;
9
use GraphQL\Type\Definition\CustomScalarType;
10
use GraphQL\Type\Definition\Directive;
11
use GraphQL\Type\Definition\EnumType;
12
use GraphQL\Type\Definition\InputObjectType;
13
use GraphQL\Type\Definition\InputType;
14
use GraphQL\Type\Definition\InterfaceType;
15
use GraphQL\Type\Definition\ListOfType;
16
use GraphQL\Type\Definition\NamedType;
17
use GraphQL\Type\Definition\NonNull;
18
use GraphQL\Type\Definition\NullableType;
19
use GraphQL\Type\Definition\ObjectType;
20
use GraphQL\Type\Definition\OutputType;
21
use GraphQL\Type\Definition\ScalarType;
22
use GraphQL\Type\Definition\Type;
23
use GraphQL\Type\Definition\UnionType;
24
use GraphQL\Type\Introspection;
25
use GraphQL\Type\Schema;
26
use GraphQL\Type\SchemaConfig;
27
use GraphQL\Type\TypeKind;
28
use function array_key_exists;
29
use function array_map;
30
use function array_merge;
31
use function json_encode;
32
33
class BuildClientSchema
34
{
35
    /** @var array<string, mixed[]> */
36
    private $introspection;
37
38
    /** @var array<string, bool> */
39
    private $options;
40
41
    /** @var array<string, NamedType&Type> */
42
    private $typeMap;
43
44
    /**
45
     * @param array<string, mixed[]> $introspectionQuery
46
     * @param array<string, bool>    $options
47
     */
48
    public function __construct(array $introspectionQuery, array $options = [])
49
    {
50
        $this->introspection = $introspectionQuery;
51
        $this->options       = $options;
52
    }
53
54
    /**
55
     * Build a schema for use by client tools.
56
     *
57
     * Given the result of a client running the introspection query, creates and
58
     * returns a \GraphQL\Type\Schema instance which can be then used with all graphql-php
59
     * tools, but cannot be used to execute a query, as introspection does not
60
     * represent the "resolver", "parse" or "serialize" functions or any other
61
     * server-internal mechanisms.
62
     *
63
     * This function expects a complete introspection result. Don't forget to check
64
     * the "errors" field of a server response before calling this function.
65
     *
66
     * Accepts options as a third argument:
67
     *
68
     *    - assumeValid:
69
     *          When building a schema from a GraphQL service's introspection result, it
70
     *          might be safe to assume the schema is valid. Set to true to assume the
71
     *          produced schema is valid.
72
     *
73
     *          Default: false
74
     *
75
     * @param array<string, mixed[]> $introspectionQuery
76
     * @param array<string, bool>    $options
77
     *
78
     * @api
79
     */
80
    public static function build(array $introspectionQuery, array $options = []) : Schema
81
    {
82
        $builder = new self($introspectionQuery, $options);
83
84
        return $builder->buildSchema();
85
    }
86
87
    public function buildSchema() : Schema
88
    {
89
        if (! array_key_exists('__schema', $this->introspection)) {
90
            throw new InvariantViolation('Invalid or incomplete introspection result. Ensure that you are passing "data" property of introspection response and no "errors" was returned alongside: ' . json_encode($this->introspection) . '.');
91
        }
92
93
        $schemaIntrospection = $this->introspection['__schema'];
94
95
        $this->typeMap = Utils::keyValMap(
96
            $schemaIntrospection['types'],
97
            static function (array $typeIntrospection) {
98
                return $typeIntrospection['name'];
99
            },
100
            function (array $typeIntrospection) {
101
                return $this->buildType($typeIntrospection);
102
            }
103
        );
104
105
        $builtInTypes = array_merge(
106
            Type::getStandardTypes(),
107
            Introspection::getTypes()
108
        );
109
        foreach ($builtInTypes as $name => $type) {
110
            if (! isset($this->typeMap[$name])) {
111
                continue;
112
            }
113
114
            $this->typeMap[$name] = $type;
115
        }
116
117
        $queryType = isset($schemaIntrospection['queryType'])
118
            ? $this->getObjectType($schemaIntrospection['queryType'])
119
            : null;
120
121
        $mutationType = isset($schemaIntrospection['mutationType'])
122
            ? $this->getObjectType($schemaIntrospection['mutationType'])
123
            : null;
124
125
        $subscriptionType = isset($schemaIntrospection['subscriptionType'])
126
            ? $this->getObjectType($schemaIntrospection['subscriptionType'])
127
            : null;
128
129
        $directives = isset($schemaIntrospection['directives'])
130
            ? array_map(
131
                [$this, 'buildDirective'],
132
                $schemaIntrospection['directives']
133
            )
134
            : [];
135
136
        $schemaConfig = new SchemaConfig();
137
        $schemaConfig->setQuery($queryType)
138
            ->setMutation($mutationType)
139
            ->setSubscription($subscriptionType)
140
            ->setTypes($this->typeMap)
141
            ->setDirectives($directives)
142
            ->setAssumeValid(
143
                isset($this->options)
144
                && isset($this->options['assumeValid'])
145
                && $this->options['assumeValid']
146
            );
147
148
        return new Schema($schemaConfig);
149
    }
150
151
    /**
152
     * @param array<string, mixed> $typeRef
153
     */
154
    private function getType(array $typeRef) : Type
155
    {
156
        if (isset($typeRef['kind'])) {
157
            if ($typeRef['kind'] === TypeKind::LIST) {
158
                if (! isset($typeRef['ofType'])) {
159
                    throw new InvariantViolation('Decorated type deeper than introspection query.');
160
                }
161
162
                return new ListOfType($this->getType($typeRef['ofType']));
163
            }
164
165
            if ($typeRef['kind'] === TypeKind::NON_NULL) {
166
                if (! isset($typeRef['ofType'])) {
167
                    throw new InvariantViolation('Decorated type deeper than introspection query.');
168
                }
169
                /** @var NullableType $nullableType */
170
                $nullableType = $this->getType($typeRef['ofType']);
171
172
                return new NonNull($nullableType);
173
            }
174
        }
175
176
        if (! isset($typeRef['name'])) {
177
            throw new InvariantViolation('Unknown type reference: ' . json_encode($typeRef) . '.');
178
        }
179
180
        return $this->getNamedType($typeRef['name']);
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->getNamedType($typeRef['name']) returns the type GraphQL\Type\Definition\NamedType which is incompatible with the type-hinted return GraphQL\Type\Definition\Type.
Loading history...
181
    }
182
183
    /**
184
     * @return NamedType&Type
185
     */
186
    private function getNamedType(string $typeName) : NamedType
187
    {
188
        if (! isset($this->typeMap[$typeName])) {
189
            throw new InvariantViolation(
190
                "Invalid or incomplete schema, unknown type: ${typeName}. Ensure that a full introspection query is used in order to build a client schema."
191
            );
192
        }
193
194
        return $this->typeMap[$typeName];
195
    }
196
197
    /**
198
     * @param array<string, mixed> $typeRef
199
     */
200
    private function getInputType(array $typeRef) : InputType
201
    {
202
        $type = $this->getType($typeRef);
203
204
        if ($type instanceof InputType) {
0 ignored issues
show
introduced by
$type is always a sub-type of GraphQL\Type\Definition\InputType.
Loading history...
205
            return $type;
206
        }
207
208
        throw new InvariantViolation('Introspection must provide input type for arguments, but received: ' . json_encode($type) . '.');
209
    }
210
211
    /**
212
     * @param array<string, mixed> $typeRef
213
     */
214
    private function getOutputType(array $typeRef) : OutputType
215
    {
216
        $type = $this->getType($typeRef);
217
218
        if ($type instanceof OutputType) {
0 ignored issues
show
introduced by
$type is always a sub-type of GraphQL\Type\Definition\OutputType.
Loading history...
219
            return $type;
220
        }
221
222
        throw new InvariantViolation('Introspection must provide output type for fields, but received: ' . json_encode($type) . '.');
223
    }
224
225
    /**
226
     * @param array<string, mixed> $typeRef
227
     */
228
    private function getObjectType(array $typeRef) : ObjectType
229
    {
230
        $type = $this->getType($typeRef);
231
232
        return ObjectType::assertObjectType($type);
233
    }
234
235
    /**
236
     * @param array<string, mixed> $typeRef
237
     */
238
    public function getInterfaceType(array $typeRef) : InterfaceType
239
    {
240
        $type = $this->getType($typeRef);
241
242
        return InterfaceType::assertInterfaceType($type);
243
    }
244
245
    /**
246
     * @param array<string, mixed> $type
247
     */
248
    private function buildType(array $type) : NamedType
249
    {
250
        if (array_key_exists('name', $type) && array_key_exists('kind', $type)) {
251
            switch ($type['kind']) {
252
                case TypeKind::SCALAR:
253
                    return $this->buildScalarDef($type);
254
                case TypeKind::OBJECT:
255
                    return $this->buildObjectDef($type);
256
                case TypeKind::INTERFACE:
257
                    return $this->buildInterfaceDef($type);
258
                case TypeKind::UNION:
259
                    return $this->buildUnionDef($type);
260
                case TypeKind::ENUM:
261
                    return $this->buildEnumDef($type);
262
                case TypeKind::INPUT_OBJECT:
263
                    return $this->buildInputObjectDef($type);
264
            }
265
        }
266
267
        throw new InvariantViolation(
268
            'Invalid or incomplete introspection result. Ensure that a full introspection query is used in order to build a client schema: ' . json_encode($type) . '.'
269
        );
270
    }
271
272
    /**
273
     * @param array<string, string> $scalar
274
     */
275
    private function buildScalarDef(array $scalar) : ScalarType
276
    {
277
        return new CustomScalarType([
278
            'name' => $scalar['name'],
279
            'description' => $scalar['description'],
280
            'serialize' => static function ($value) : string {
281
                return (string) $value;
282
            },
283
        ]);
284
    }
285
286
    /**
287
     * @param array<string, mixed> $object
288
     */
289
    private function buildObjectDef(array $object) : ObjectType
290
    {
291
        if (! array_key_exists('interfaces', $object)) {
292
            throw new InvariantViolation('Introspection result missing interfaces: ' . json_encode($object) . '.');
293
        }
294
295
        return new ObjectType([
296
            'name' => $object['name'],
297
            'description' => $object['description'],
298
            'interfaces' => function () use ($object) {
299
                return array_map(
300
                    [$this, 'getInterfaceType'],
301
                    // Legacy support for interfaces with null as interfaces field
302
                    $object['interfaces'] ?? []
303
                );
304
            },
305
            'fields' => function () use ($object) {
306
                return $this->buildFieldDefMap($object);
307
            },
308
        ]);
309
    }
310
311
    /**
312
     * @param array<string, mixed> $interface
313
     */
314
    private function buildInterfaceDef(array $interface) : InterfaceType
315
    {
316
        return new InterfaceType([
317
            'name' => $interface['name'],
318
            'description' => $interface['description'],
319
            'fields' => function () use ($interface) {
320
                return $this->buildFieldDefMap($interface);
321
            },
322
        ]);
323
    }
324
325
    /**
326
     * @param array<string, string|array<string>> $union
327
     */
328
    private function buildUnionDef(array $union) : UnionType
329
    {
330
        if (! array_key_exists('possibleTypes', $union)) {
331
            throw new InvariantViolation('Introspection result missing possibleTypes: ' . json_encode($union) . '.');
332
        }
333
334
        return new UnionType([
335
            'name' => $union['name'],
336
            'description' => $union['description'],
337
            'types' => function () use ($union) {
338
                return array_map(
339
                    [$this, 'getObjectType'],
340
                    $union['possibleTypes']
0 ignored issues
show
Bug introduced by
It seems like $union['possibleTypes'] can also be of type string; however, parameter $arr1 of array_map() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

340
                    /** @scrutinizer ignore-type */ $union['possibleTypes']
Loading history...
341
                );
342
            },
343
        ]);
344
    }
345
346
    /**
347
     * @param array<string, string|array<string, string>> $enum
348
     */
349
    private function buildEnumDef(array $enum) : EnumType
350
    {
351
        if (! array_key_exists('enumValues', $enum)) {
352
            throw new InvariantViolation('Introspection result missing enumValues: ' . json_encode($enum) . '.');
353
        }
354
355
        return new EnumType([
356
            'name' => $enum['name'],
357
            'description' => $enum['description'],
358
            'values' => Utils::keyValMap(
359
                $enum['enumValues'],
0 ignored issues
show
Bug introduced by
It seems like $enum['enumValues'] can also be of type string; however, parameter $traversable of GraphQL\Utils\Utils::keyValMap() does only seem to accept Traversable|array<mixed,mixed>, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

359
                /** @scrutinizer ignore-type */ $enum['enumValues'],
Loading history...
360
                static function (array $enumValue) : string {
361
                    return $enumValue['name'];
362
                },
363
                static function (array $enumValue) {
364
                    return [
365
                        'description' => $enumValue['description'],
366
                        'deprecationReason' => $enumValue['deprecationReason'],
367
                    ];
368
                }
369
            ),
370
        ]);
371
    }
372
373
    /**
374
     * @param array<string, mixed> $inputObject
375
     */
376
    private function buildInputObjectDef(array $inputObject) : InputObjectType
377
    {
378
        if (! array_key_exists('inputFields', $inputObject)) {
379
            throw new InvariantViolation('Introspection result missing inputFields: ' . json_encode($inputObject) . '.');
380
        }
381
382
        return new InputObjectType([
383
            'name' => $inputObject['name'],
384
            'description' => $inputObject['description'],
385
            'fields' => function () use ($inputObject) {
386
                return $this->buildInputValueDefMap($inputObject['inputFields']);
387
            },
388
        ]);
389
    }
390
391
    /**
392
     * @param array<string, mixed> $typeIntrospection
393
     */
394
    private function buildFieldDefMap(array $typeIntrospection)
395
    {
396
        if (! array_key_exists('fields', $typeIntrospection)) {
397
            throw new InvariantViolation('Introspection result missing fields: ' . json_encode($typeIntrospection) . '.');
398
        }
399
400
        return Utils::keyValMap(
401
            $typeIntrospection['fields'],
402
            static function (array $fieldIntrospection) : string {
403
                return $fieldIntrospection['name'];
404
            },
405
            function (array $fieldIntrospection) {
406
                if (! array_key_exists('args', $fieldIntrospection)) {
407
                    throw new InvariantViolation('Introspection result missing field args: ' . json_encode($fieldIntrospection) . '.');
408
                }
409
410
                return [
411
                    'description' => $fieldIntrospection['description'],
412
                    'deprecationReason' => $fieldIntrospection['deprecationReason'],
413
                    'type' => $this->getOutputType($fieldIntrospection['type']),
414
                    'args' => $this->buildInputValueDefMap($fieldIntrospection['args']),
415
                ];
416
            }
417
        );
418
    }
419
420
    /**
421
     * @param array<int, array<string, mixed>> $inputValueIntrospections
422
     *
423
     * @return array<string, array<string, mixed>>
424
     */
425
    private function buildInputValueDefMap(array $inputValueIntrospections) : array
426
    {
427
        return Utils::keyValMap(
428
            $inputValueIntrospections,
429
            static function (array $inputValue) : string {
430
                return $inputValue['name'];
431
            },
432
            [$this, 'buildInputValue']
433
        );
434
    }
435
436
    /**
437
     * @param array<string, mixed> $inputValueIntrospection
438
     *
439
     * @return array<string, mixed>
440
     */
441
    public function buildInputValue(array $inputValueIntrospection) : array
442
    {
443
        $type = $this->getInputType($inputValueIntrospection['type']);
444
445
        $inputValue = [
446
            'description' => $inputValueIntrospection['description'],
447
            'type' => $type,
448
        ];
449
450
        if (isset($inputValueIntrospection['defaultValue'])) {
451
            $inputValue['defaultValue'] = AST::valueFromAST(
452
                Parser::parseValue($inputValueIntrospection['defaultValue']),
453
                $type
454
            );
455
        }
456
457
        return $inputValue;
458
    }
459
460
    /**
461
     * @param array<string, mixed> $directive
462
     */
463
    public function buildDirective(array $directive) : Directive
464
    {
465
        if (! array_key_exists('args', $directive)) {
466
            throw new InvariantViolation('Introspection result missing directive args: ' . json_encode($directive) . '.');
467
        }
468
        if (! array_key_exists('locations', $directive)) {
469
            throw new InvariantViolation('Introspection result missing directive locations: ' . json_encode($directive) . '.');
470
        }
471
472
        return new Directive([
473
            'name' => $directive['name'],
474
            'description' => $directive['description'],
475
            'locations' => $directive['locations'],
476
            'args' => $this->buildInputValueDefMap($directive['args']),
477
        ]);
478
    }
479
}
480