497 lines
17 KiB
PHP
497 lines
17 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace GraphQL\Utils;
|
|
|
|
use GraphQL\Error\InvariantViolation;
|
|
use GraphQL\Language\Parser;
|
|
use GraphQL\Type\Definition\CustomScalarType;
|
|
use GraphQL\Type\Definition\Directive;
|
|
use GraphQL\Type\Definition\EnumType;
|
|
use GraphQL\Type\Definition\InputObjectType;
|
|
use GraphQL\Type\Definition\InputType;
|
|
use GraphQL\Type\Definition\InterfaceType;
|
|
use GraphQL\Type\Definition\ListOfType;
|
|
use GraphQL\Type\Definition\NamedType;
|
|
use GraphQL\Type\Definition\NonNull;
|
|
use GraphQL\Type\Definition\NullableType;
|
|
use GraphQL\Type\Definition\ObjectType;
|
|
use GraphQL\Type\Definition\OutputType;
|
|
use GraphQL\Type\Definition\ScalarType;
|
|
use GraphQL\Type\Definition\Type;
|
|
use GraphQL\Type\Definition\UnionType;
|
|
use GraphQL\Type\Introspection;
|
|
use GraphQL\Type\Schema;
|
|
use GraphQL\Type\SchemaConfig;
|
|
use GraphQL\Type\TypeKind;
|
|
use function array_key_exists;
|
|
use function array_map;
|
|
use function array_merge;
|
|
use function json_encode;
|
|
|
|
class BuildClientSchema
|
|
{
|
|
/** @var array<string, mixed[]> */
|
|
private $introspection;
|
|
|
|
/** @var array<string, bool> */
|
|
private $options;
|
|
|
|
/** @var array<string, NamedType&Type> */
|
|
private $typeMap;
|
|
|
|
/**
|
|
* @param array<string, mixed[]> $introspectionQuery
|
|
* @param array<string, bool> $options
|
|
*/
|
|
public function __construct(array $introspectionQuery, array $options = [])
|
|
{
|
|
$this->introspection = $introspectionQuery;
|
|
$this->options = $options;
|
|
}
|
|
|
|
/**
|
|
* Build a schema for use by client tools.
|
|
*
|
|
* Given the result of a client running the introspection query, creates and
|
|
* returns a \GraphQL\Type\Schema instance which can be then used with all graphql-php
|
|
* tools, but cannot be used to execute a query, as introspection does not
|
|
* represent the "resolver", "parse" or "serialize" functions or any other
|
|
* server-internal mechanisms.
|
|
*
|
|
* This function expects a complete introspection result. Don't forget to check
|
|
* the "errors" field of a server response before calling this function.
|
|
*
|
|
* Accepts options as a third argument:
|
|
*
|
|
* - assumeValid:
|
|
* When building a schema from a GraphQL service's introspection result, it
|
|
* might be safe to assume the schema is valid. Set to true to assume the
|
|
* produced schema is valid.
|
|
*
|
|
* Default: false
|
|
*
|
|
* @param array<string, mixed[]> $introspectionQuery
|
|
* @param array<string, bool> $options
|
|
*
|
|
* @api
|
|
*/
|
|
public static function build(array $introspectionQuery, array $options = []) : Schema
|
|
{
|
|
$builder = new self($introspectionQuery, $options);
|
|
|
|
return $builder->buildSchema();
|
|
}
|
|
|
|
public function buildSchema() : Schema
|
|
{
|
|
if (! array_key_exists('__schema', $this->introspection)) {
|
|
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) . '.');
|
|
}
|
|
|
|
$schemaIntrospection = $this->introspection['__schema'];
|
|
|
|
$this->typeMap = Utils::keyValMap(
|
|
$schemaIntrospection['types'],
|
|
static function (array $typeIntrospection) {
|
|
return $typeIntrospection['name'];
|
|
},
|
|
function (array $typeIntrospection) : NamedType {
|
|
return $this->buildType($typeIntrospection);
|
|
}
|
|
);
|
|
|
|
$builtInTypes = array_merge(
|
|
Type::getStandardTypes(),
|
|
Introspection::getTypes()
|
|
);
|
|
foreach ($builtInTypes as $name => $type) {
|
|
if (! isset($this->typeMap[$name])) {
|
|
continue;
|
|
}
|
|
|
|
$this->typeMap[$name] = $type;
|
|
}
|
|
|
|
$queryType = isset($schemaIntrospection['queryType'])
|
|
? $this->getObjectType($schemaIntrospection['queryType'])
|
|
: null;
|
|
|
|
$mutationType = isset($schemaIntrospection['mutationType'])
|
|
? $this->getObjectType($schemaIntrospection['mutationType'])
|
|
: null;
|
|
|
|
$subscriptionType = isset($schemaIntrospection['subscriptionType'])
|
|
? $this->getObjectType($schemaIntrospection['subscriptionType'])
|
|
: null;
|
|
|
|
$directives = isset($schemaIntrospection['directives'])
|
|
? array_map(
|
|
[$this, 'buildDirective'],
|
|
$schemaIntrospection['directives']
|
|
)
|
|
: [];
|
|
|
|
$schemaConfig = new SchemaConfig();
|
|
$schemaConfig->setQuery($queryType)
|
|
->setMutation($mutationType)
|
|
->setSubscription($subscriptionType)
|
|
->setTypes($this->typeMap)
|
|
->setDirectives($directives)
|
|
->setAssumeValid(
|
|
isset($this->options)
|
|
&& isset($this->options['assumeValid'])
|
|
&& $this->options['assumeValid']
|
|
);
|
|
|
|
return new Schema($schemaConfig);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $typeRef
|
|
*/
|
|
private function getType(array $typeRef) : Type
|
|
{
|
|
if (isset($typeRef['kind'])) {
|
|
if ($typeRef['kind'] === TypeKind::LIST) {
|
|
if (! isset($typeRef['ofType'])) {
|
|
throw new InvariantViolation('Decorated type deeper than introspection query.');
|
|
}
|
|
|
|
return new ListOfType($this->getType($typeRef['ofType']));
|
|
}
|
|
|
|
if ($typeRef['kind'] === TypeKind::NON_NULL) {
|
|
if (! isset($typeRef['ofType'])) {
|
|
throw new InvariantViolation('Decorated type deeper than introspection query.');
|
|
}
|
|
/** @var NullableType $nullableType */
|
|
$nullableType = $this->getType($typeRef['ofType']);
|
|
|
|
return new NonNull($nullableType);
|
|
}
|
|
}
|
|
|
|
if (! isset($typeRef['name'])) {
|
|
throw new InvariantViolation('Unknown type reference: ' . json_encode($typeRef) . '.');
|
|
}
|
|
|
|
return $this->getNamedType($typeRef['name']);
|
|
}
|
|
|
|
/**
|
|
* @return NamedType&Type
|
|
*/
|
|
private function getNamedType(string $typeName) : NamedType
|
|
{
|
|
if (! isset($this->typeMap[$typeName])) {
|
|
throw new InvariantViolation(
|
|
"Invalid or incomplete schema, unknown type: ${typeName}. Ensure that a full introspection query is used in order to build a client schema."
|
|
);
|
|
}
|
|
|
|
return $this->typeMap[$typeName];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $typeRef
|
|
*/
|
|
private function getInputType(array $typeRef) : InputType
|
|
{
|
|
$type = $this->getType($typeRef);
|
|
|
|
if ($type instanceof InputType) {
|
|
return $type;
|
|
}
|
|
|
|
throw new InvariantViolation('Introspection must provide input type for arguments, but received: ' . json_encode($type) . '.');
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $typeRef
|
|
*/
|
|
private function getOutputType(array $typeRef) : OutputType
|
|
{
|
|
$type = $this->getType($typeRef);
|
|
|
|
if ($type instanceof OutputType) {
|
|
return $type;
|
|
}
|
|
|
|
throw new InvariantViolation('Introspection must provide output type for fields, but received: ' . json_encode($type) . '.');
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $typeRef
|
|
*/
|
|
private function getObjectType(array $typeRef) : ObjectType
|
|
{
|
|
$type = $this->getType($typeRef);
|
|
|
|
return ObjectType::assertObjectType($type);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $typeRef
|
|
*/
|
|
public function getInterfaceType(array $typeRef) : InterfaceType
|
|
{
|
|
$type = $this->getType($typeRef);
|
|
|
|
return InterfaceType::assertInterfaceType($type);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $type
|
|
*/
|
|
private function buildType(array $type) : NamedType
|
|
{
|
|
if (array_key_exists('name', $type) && array_key_exists('kind', $type)) {
|
|
switch ($type['kind']) {
|
|
case TypeKind::SCALAR:
|
|
return $this->buildScalarDef($type);
|
|
case TypeKind::OBJECT:
|
|
return $this->buildObjectDef($type);
|
|
case TypeKind::INTERFACE:
|
|
return $this->buildInterfaceDef($type);
|
|
case TypeKind::UNION:
|
|
return $this->buildUnionDef($type);
|
|
case TypeKind::ENUM:
|
|
return $this->buildEnumDef($type);
|
|
case TypeKind::INPUT_OBJECT:
|
|
return $this->buildInputObjectDef($type);
|
|
}
|
|
}
|
|
|
|
throw new InvariantViolation(
|
|
'Invalid or incomplete introspection result. Ensure that a full introspection query is used in order to build a client schema: ' . json_encode($type) . '.'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, string> $scalar
|
|
*/
|
|
private function buildScalarDef(array $scalar) : ScalarType
|
|
{
|
|
return new CustomScalarType([
|
|
'name' => $scalar['name'],
|
|
'description' => $scalar['description'],
|
|
'serialize' => static function ($value) : string {
|
|
return (string) $value;
|
|
},
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $implementingIntrospection
|
|
*
|
|
* @return array<int, InterfaceType>
|
|
*/
|
|
private function buildImplementationsList(array $implementingIntrospection) : array
|
|
{
|
|
// TODO: Temporary workaround until GraphQL ecosystem will fully support 'interfaces' on interface types.
|
|
if (array_key_exists('interfaces', $implementingIntrospection) &&
|
|
$implementingIntrospection['interfaces'] === null &&
|
|
$implementingIntrospection['kind'] === TypeKind::INTERFACE) {
|
|
return [];
|
|
}
|
|
|
|
if (! array_key_exists('interfaces', $implementingIntrospection)) {
|
|
throw new InvariantViolation('Introspection result missing interfaces: ' . json_encode($implementingIntrospection) . '.');
|
|
}
|
|
|
|
return array_map([$this, 'getInterfaceType'], $implementingIntrospection['interfaces']);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $object
|
|
*/
|
|
private function buildObjectDef(array $object) : ObjectType
|
|
{
|
|
return new ObjectType([
|
|
'name' => $object['name'],
|
|
'description' => $object['description'],
|
|
'interfaces' => function () use ($object) : array {
|
|
return $this->buildImplementationsList($object);
|
|
},
|
|
'fields' => function () use ($object) {
|
|
return $this->buildFieldDefMap($object);
|
|
},
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $interface
|
|
*/
|
|
private function buildInterfaceDef(array $interface) : InterfaceType
|
|
{
|
|
return new InterfaceType([
|
|
'name' => $interface['name'],
|
|
'description' => $interface['description'],
|
|
'fields' => function () use ($interface) {
|
|
return $this->buildFieldDefMap($interface);
|
|
},
|
|
'interfaces' => function () use ($interface) : array {
|
|
return $this->buildImplementationsList($interface);
|
|
},
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, string|array<string>> $union
|
|
*/
|
|
private function buildUnionDef(array $union) : UnionType
|
|
{
|
|
if (! array_key_exists('possibleTypes', $union)) {
|
|
throw new InvariantViolation('Introspection result missing possibleTypes: ' . json_encode($union) . '.');
|
|
}
|
|
|
|
return new UnionType([
|
|
'name' => $union['name'],
|
|
'description' => $union['description'],
|
|
'types' => function () use ($union) : array {
|
|
return array_map(
|
|
[$this, 'getObjectType'],
|
|
$union['possibleTypes']
|
|
);
|
|
},
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, string|array<string, string>> $enum
|
|
*/
|
|
private function buildEnumDef(array $enum) : EnumType
|
|
{
|
|
if (! array_key_exists('enumValues', $enum)) {
|
|
throw new InvariantViolation('Introspection result missing enumValues: ' . json_encode($enum) . '.');
|
|
}
|
|
|
|
return new EnumType([
|
|
'name' => $enum['name'],
|
|
'description' => $enum['description'],
|
|
'values' => Utils::keyValMap(
|
|
$enum['enumValues'],
|
|
static function (array $enumValue) : string {
|
|
return $enumValue['name'];
|
|
},
|
|
static function (array $enumValue) : array {
|
|
return [
|
|
'description' => $enumValue['description'],
|
|
'deprecationReason' => $enumValue['deprecationReason'],
|
|
];
|
|
}
|
|
),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $inputObject
|
|
*/
|
|
private function buildInputObjectDef(array $inputObject) : InputObjectType
|
|
{
|
|
if (! array_key_exists('inputFields', $inputObject)) {
|
|
throw new InvariantViolation('Introspection result missing inputFields: ' . json_encode($inputObject) . '.');
|
|
}
|
|
|
|
return new InputObjectType([
|
|
'name' => $inputObject['name'],
|
|
'description' => $inputObject['description'],
|
|
'fields' => function () use ($inputObject) : array {
|
|
return $this->buildInputValueDefMap($inputObject['inputFields']);
|
|
},
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $typeIntrospection
|
|
*/
|
|
private function buildFieldDefMap(array $typeIntrospection)
|
|
{
|
|
if (! array_key_exists('fields', $typeIntrospection)) {
|
|
throw new InvariantViolation('Introspection result missing fields: ' . json_encode($typeIntrospection) . '.');
|
|
}
|
|
|
|
return Utils::keyValMap(
|
|
$typeIntrospection['fields'],
|
|
static function (array $fieldIntrospection) : string {
|
|
return $fieldIntrospection['name'];
|
|
},
|
|
function (array $fieldIntrospection) : array {
|
|
if (! array_key_exists('args', $fieldIntrospection)) {
|
|
throw new InvariantViolation('Introspection result missing field args: ' . json_encode($fieldIntrospection) . '.');
|
|
}
|
|
|
|
return [
|
|
'description' => $fieldIntrospection['description'],
|
|
'deprecationReason' => $fieldIntrospection['deprecationReason'],
|
|
'type' => $this->getOutputType($fieldIntrospection['type']),
|
|
'args' => $this->buildInputValueDefMap($fieldIntrospection['args']),
|
|
];
|
|
}
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array<string, mixed>> $inputValueIntrospections
|
|
*
|
|
* @return array<string, array<string, mixed>>
|
|
*/
|
|
private function buildInputValueDefMap(array $inputValueIntrospections) : array
|
|
{
|
|
return Utils::keyValMap(
|
|
$inputValueIntrospections,
|
|
static function (array $inputValue) : string {
|
|
return $inputValue['name'];
|
|
},
|
|
[$this, 'buildInputValue']
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $inputValueIntrospection
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function buildInputValue(array $inputValueIntrospection) : array
|
|
{
|
|
$type = $this->getInputType($inputValueIntrospection['type']);
|
|
|
|
$inputValue = [
|
|
'description' => $inputValueIntrospection['description'],
|
|
'type' => $type,
|
|
];
|
|
|
|
if (isset($inputValueIntrospection['defaultValue'])) {
|
|
$inputValue['defaultValue'] = AST::valueFromAST(
|
|
Parser::parseValue($inputValueIntrospection['defaultValue']),
|
|
$type
|
|
);
|
|
}
|
|
|
|
return $inputValue;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $directive
|
|
*/
|
|
public function buildDirective(array $directive) : Directive
|
|
{
|
|
if (! array_key_exists('args', $directive)) {
|
|
throw new InvariantViolation('Introspection result missing directive args: ' . json_encode($directive) . '.');
|
|
}
|
|
if (! array_key_exists('locations', $directive)) {
|
|
throw new InvariantViolation('Introspection result missing directive locations: ' . json_encode($directive) . '.');
|
|
}
|
|
|
|
return new Directive([
|
|
'name' => $directive['name'],
|
|
'description' => $directive['description'],
|
|
'args' => $this->buildInputValueDefMap($directive['args']),
|
|
'isRepeatable' => $directive['isRepeatable'],
|
|
'locations' => $directive['locations'],
|
|
]);
|
|
}
|
|
}
|