diff --git a/src/Error/FormattedError.php b/src/Error/FormattedError.php index 3fa4168..6664c20 100644 --- a/src/Error/FormattedError.php +++ b/src/Error/FormattedError.php @@ -8,53 +8,49 @@ use GraphQL\Utils\Utils; /** * Class FormattedError - * @todo move this class to Utils/ErrorUtils + * * @package GraphQL\Error */ class FormattedError { /** - * @deprecated as of 8.0 - * @param $error - * @param SourceLocation[] $locations + * @param \Throwable $e + * @param $debug + * * @return array */ - public static function create($error, array $locations = []) + public static function createFromException($e, $debug = false) { - $formatted = [ - 'message' => $error - ]; - - if (!empty($locations)) { - $formatted['locations'] = array_map(function($loc) { return $loc->toArray();}, $locations); + if ($e instanceof Error) { + $result = $e->toSerializableArray(); + } else if ($e instanceof \ErrorException) { + $result = [ + 'message' => $e->getMessage(), + ]; + if ($debug) { + $result += [ + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'severity' => $e->getSeverity() + ]; + } + } else { + Utils::invariant( + $e instanceof \Exception || $e instanceof \Throwable, + "Expected exception, got %s", + Utils::getVariableType($e) + ); + $result = [ + 'message' => $e->getMessage() + ]; } - return $formatted; - } + if ($debug) { + $debugging = $e->getPrevious() ?: $e; + $result['trace'] = static::toSafeTrace($debugging->getTrace()); + } - /** - * @param \ErrorException $e - * @return array - */ - public static function createFromPHPError(\ErrorException $e) - { - return [ - 'message' => $e->getMessage(), - 'severity' => $e->getSeverity(), - 'trace' => self::toSafeTrace($e->getTrace()) - ]; - } - - /** - * @param \Throwable $e - * @return array - */ - public static function createFromException($e) - { - return [ - 'message' => $e->getMessage(), - 'trace' => self::toSafeTrace($e->getTrace()) - ]; + return $result; } /** @@ -133,4 +129,37 @@ class FormattedError } return gettype($var); } + + /** + * @deprecated as of v0.8.0 + * @param $error + * @param SourceLocation[] $locations + * @return array + */ + public static function create($error, array $locations = []) + { + $formatted = [ + 'message' => $error + ]; + + if (!empty($locations)) { + $formatted['locations'] = array_map(function($loc) { return $loc->toArray();}, $locations); + } + + return $formatted; + } + + /** + * @param \ErrorException $e + * @deprecated as of v0.10.0, use general purpose method createFromException() instead + * @return array + */ + public static function createFromPHPError(\ErrorException $e) + { + return [ + 'message' => $e->getMessage(), + 'severity' => $e->getSeverity(), + 'trace' => self::toSafeTrace($e->getTrace()) + ]; + } } diff --git a/src/Server.php b/src/Server.php index 22edbfe..3350b1f 100644 --- a/src/Server.php +++ b/src/Server.php @@ -15,6 +15,11 @@ use GraphQL\Type\Resolution; use GraphQL\Validator\DocumentValidator; use GraphQL\Utils\Utils; +trigger_error( + 'GraphQL\Server is deprecated in favor of new implementation: GraphQL\Server\StandardServer and will be removed in next version', + E_USER_DEPRECATED +); + class Server { const DEBUG_PHP_ERRORS = 1; diff --git a/src/Server/Helper.php b/src/Server/Helper.php new file mode 100644 index 0000000..f863908 --- /dev/null +++ b/src/Server/Helper.php @@ -0,0 +1,191 @@ +queryId ? static::loadPersistedQuery($config, $op) : $op->query; + + if (!$doc instanceof DocumentNode) { + $doc = Parser::parse($doc); + } + if (!$op->allowsMutation() && AST::isMutation($op->operation, $doc)) { + throw new UserError("Cannot execute mutation in read-only context"); + } + + return GraphQL::executeAndReturnResult( + $config->getSchema(), + $doc, + $config->getRootValue(), + $config->getContext(), + $op->variables, + $op->operation, + $config->getDefaultFieldResolver(), + static::resolveValidationRules($config, $op), + $config->getPromiseAdapter() + ); + }; + if ($config->getDebug()) { + $execute = Utils::withErrorHandling($execute, $phpErrors); + } + $result = $execute(); + + $applyErrorFormatting = function(ExecutionResult $result) use ($config, $phpErrors) { + if ($config->getDebug()) { + $errorFormatter = function($e) { + return FormattedError::createFromException($e, true); + }; + } else { + $errorFormatter = $config->getErrorFormatter(); + } + if (!empty($phpErrors)) { + $result->extensions['phpErrors'] = array_map($errorFormatter, $phpErrors); + } + $result->setErrorFormatter($errorFormatter); + return $result; + }; + + return $result instanceof Promise ? + $result->then($applyErrorFormatting) : + $applyErrorFormatting($result); + } + + /** + * @param ServerConfig $config + * @param OperationParams $op + * @return string|DocumentNode + */ + private static function loadPersistedQuery(ServerConfig $config, OperationParams $op) + { + // Load query if we got persisted query id: + $loader = $config->getPersistentQueryLoader(); + + if (!$loader) { + throw new UserError("Persisted queries are not supported by this server"); + } + + $source = $loader($op->queryId, $op); + + if (!is_string($source) && !$source instanceof DocumentNode) { + throw new InvariantViolation(sprintf( + "Persistent query loader must return query string or instance of %s but got: %s", + DocumentNode::class, + Utils::printSafe($source) + )); + } + + return $source; + } + + /** + * @param ServerConfig $config + * @param OperationParams $params + * @return array + */ + private static function resolveValidationRules(ServerConfig $config, OperationParams $params) + { + // Allow customizing validation rules per operation: + $validationRules = $config->getValidationRules(); + + if (is_callable($validationRules)) { + $validationRules = $validationRules($params); + + if (!is_array($validationRules)) { + throw new InvariantViolation( + "Validation rules callable must return array of rules, but got: %s" . + Utils::printSafe($validationRules) + ); + } + } + + return $validationRules; + } + + /** + * Parses HTTP request and returns GraphQL QueryParams contained in this request. + * For batched requests it returns an array of QueryParams. + * + * @return OperationParams|OperationParams[] + */ + public static function parseHttpRequest() + { + $contentType = isset($_SERVER['CONTENT_TYPE']) ? $_SERVER['CONTENT_TYPE'] : null; + + $assertValid = function (OperationParams $opParams, $queryNum = null) { + $errors = $opParams->validate(); + if (!empty($errors[0])) { + $err = $queryNum ? "Error in query #$queryNum: {$errors[0]}" : $errors[0]; + throw new UserError($err); + } + }; + + if (stripos($contentType, 'application/graphql' !== false)) { + $body = file_get_contents('php://input') ?: ''; + $op = OperationParams::create(['query' => $body]); + $assertValid($op); + } else if (stripos($contentType, 'application/json') !== false || stripos($contentType, 'text/json') !== false) { + $body = file_get_contents('php://input') ?: ''; + $data = json_decode($body, true); + + if (json_last_error()) { + throw new UserError("Could not parse JSON: " . json_last_error_msg()); + } + if (!is_array($data)) { + throw new UserError( + "GraphQL Server expects JSON object or array, but got %s" . + Utils::printSafe($data) + ); + } + if (isset($data[0])) { + $op = []; + foreach ($data as $index => $entry) { + $params = OperationParams::create($entry); + $assertValid($params, $index); + $op[] = $params; + } + } else { + $op = OperationParams::create($data); + $assertValid($op); + } + } else if (stripos($contentType, 'application/x-www-form-urlencoded') !== false) { + if ($_SERVER['REQUEST_METHOD'] === 'GET') { + $op = OperationParams::create($_GET, false); + } else { + $op = OperationParams::create($_POST); + } + $assertValid($op); + } else { + throw new UserError("Bad request: unexpected content type: " . Utils::printSafe($contentType)); + } + + return $op; + } +} diff --git a/src/Server/OperationParams.php b/src/Server/OperationParams.php new file mode 100644 index 0000000..2a187ca --- /dev/null +++ b/src/Server/OperationParams.php @@ -0,0 +1,120 @@ +originalInput = $params; + + $params = array_change_key_case($params, CASE_LOWER); + + $params += [ + 'query' => null, + 'queryid' => null, + 'documentid' => null, // alias to queryid + 'operation' => null, + 'variables' => null + ]; + + $instance->query = $params['query']; + $instance->queryId = $params['queryid'] ?: $params['documentid']; + $instance->operation = $params['operation']; + $instance->variables = $params['variables']; + $instance->allowsMutations = (bool) $allowsMutations; + + return $instance; + } + + /** + * @return array + */ + public function validate() + { + $errors = []; + if (!$this->query && !$this->queryId) { + $errors[] = 'GraphQL Request must include at least one of those two parameters: "query" or "queryId"'; + } + if ($this->query && $this->queryId) { + $errors[] = 'GraphQL Request parameters: "query" and "queryId" are mutually exclusive'; + } + + if ($this->query !== null && (!is_string($this->query) || empty($this->query))) { + $errors[] = 'GraphQL Request parameter "query" must be string, but got: ' . + Utils::printSafe($this->query); + } + if ($this->queryId !== null && (!is_string($this->query) || empty($this->query))) { + $errors[] = 'GraphQL Request parameter "queryId" must be string, but got: ' . + Utils::printSafe($this->query); + } + + if ($this->operation !== null && (!is_string($this->operation) || empty($this->operation))) { + $errors[] = 'GraphQL Request parameter "operation" must be string, but got: ' . + Utils::printSafe($this->operation); + } + if ($this->variables !== null && (!is_array($this->variables) || isset($this->variables[0]))) { + $errors[] = 'GraphQL Request parameter "variables" must be associative array, but got: ' . + Utils::printSafe($this->variables); + } + return $errors; + } + + public function getOriginalInput() + { + return $this->originalInput; + } + + /** + * @return bool + */ + public function allowsMutation() + { + return $this->allowsMutations; + } +} diff --git a/src/Server/ServerConfig.php b/src/Server/ServerConfig.php new file mode 100644 index 0000000..e17546f --- /dev/null +++ b/src/Server/ServerConfig.php @@ -0,0 +1,257 @@ +context; + } + + /** + * @param mixed $context + * @return $this + */ + public function setContext($context) + { + $this->context = $context; + return $this; + } + + /** + * @param mixed $rootValue + * @return $this + */ + public function setRootValue($rootValue) + { + $this->rootValue = $rootValue; + return $this; + } + + /** + * @return mixed + */ + public function getRootValue() + { + return $this->rootValue; + } + + /** + * Set schema instance + * + * @param Schema $schema + * @return $this + */ + public function setSchema(Schema $schema) + { + $this->schema = $schema; + return $this; + } + + /** + * @return Schema + */ + public function getSchema() + { + return $this->schema; + } + + /** + * @return callable + */ + public function getErrorFormatter() + { + return $this->errorFormatter; + } + + /** + * Expects function(Throwable $e) : array + * + * @param callable $errorFormatter + * @return $this + */ + public function setErrorFormatter(callable $errorFormatter) + { + $this->errorFormatter = $errorFormatter; + return $this; + } + + /** + * @return PromiseAdapter + */ + public function getPromiseAdapter() + { + return $this->promiseAdapter; + } + + /** + * @param PromiseAdapter $promiseAdapter + * @return $this + */ + public function setPromiseAdapter(PromiseAdapter $promiseAdapter) + { + $this->promiseAdapter = $promiseAdapter; + return $this; + } + + /** + * @return array|callable + */ + public function getValidationRules() + { + return $this->validationRules; + } + + /** + * Set validation rules for this server. + * + * @param array|callable + * @return $this + */ + public function setValidationRules($validationRules) + { + if (!is_callable($validationRules) && !is_array($validationRules) && $validationRules !== null) { + throw new InvariantViolation( + __METHOD__ . ' expects array of validation rules or callable returning such array, but got: %s' . + Utils::printSafe($validationRules) + ); + } + + $this->validationRules = $validationRules; + return $this; + } + + /** + * @return callable + */ + public function getDefaultFieldResolver() + { + return $this->defaultFieldResolver; + } + + /** + * @param callable $defaultFieldResolver + * @return $this + */ + public function setDefaultFieldResolver(callable $defaultFieldResolver) + { + $this->defaultFieldResolver = $defaultFieldResolver; + return $this; + } + + /** + * @return callable + */ + public function getPersistentQueryLoader() + { + return $this->persistentQueryLoader; + } + + /** + * A function that takes an input id and returns a valid Document. + * If provided, this will allow your GraphQL endpoint to execute a document specified via `queryId`. + * + * @param callable $persistentQueryLoader + * @return ServerConfig + */ + public function setPersistentQueryLoader(callable $persistentQueryLoader) + { + $this->persistentQueryLoader = $persistentQueryLoader; + return $this; + } + + /** + * @return bool + */ + public function getDebug() + { + return $this->debug; + } + + /** + * Settings this option has two effects: + * + * 1. Replaces current error formatter with the one for debugging (has precedence over `setErrorFormatter()`). + * This error formatter adds `trace` entry for all errors in ExecutionResult when it is converted to array. + * + * 2. All PHP errors are intercepted during query execution (including warnings, notices and deprecations). + * + * These PHP errors are converted to arrays with `message`, `file`, `line`, `trace` keys and then added to + * `extensions` section of ExecutionResult under key `phpErrors`. + * + * After query execution error handler will be removed from stack, + * so any errors occurring after execution will not be caught. + * + * Use this feature for development and debugging only. + * + * @param bool $set + * @return $this + */ + public function setDebug($set = true) + { + $this->debug = (bool) $set; + return $this; + } +} diff --git a/src/Server/StandardServer.php b/src/Server/StandardServer.php new file mode 100644 index 0000000..d8aebff --- /dev/null +++ b/src/Server/StandardServer.php @@ -0,0 +1,106 @@ +config = $config; + } + + /** + * @param OperationParams|OperationParams[] $parsedBody + * @return ExecutionResult|ExecutionResult[]|Promise + */ + public function executeRequest($parsedBody = null) + { + if (null !== $parsedBody) { + $this->assertBodyIsParsedProperly(__METHOD__, $parsedBody); + } else { + $parsedBody = Helper::parseHttpRequest(); + } + + $batched = is_array($parsedBody); + + $result = []; + foreach ((array) $parsedBody as $index => $operationParams) { + $result[] = Helper::executeOperation($this->config, $operationParams); + } + + return $batched ? $result : $result[0]; + } + + /** + * @param $method + * @param $parsedBody + */ + private function assertBodyIsParsedProperly($method, $parsedBody) + { + if (is_array($parsedBody)) { + foreach ($parsedBody as $index => $entry) { + if (!$entry instanceof OperationParams) { + throw new InvariantViolation(sprintf( + '%s expects instance of %s or array of instances. Got invalid array where entry at position %d is %s', + $method, + OperationParams::class, + $index, + Utils::printSafe($entry) + )); + } + $errors = $entry->validate(); + + if (!empty($errors[0])) { + $err = $index ? "Error in query #$index: {$errors[0]}" : $errors[0]; + throw new InvariantViolation($err); + } + } + } + + if ($parsedBody instanceof OperationParams) { + $errors = $parsedBody->validate(); + if (!empty($errors[0])) { + throw new InvariantViolation($errors[0]); + } + } + + throw new InvariantViolation(sprintf( + '%s expects instance of %s or array of instances, but got %s', + $method, + OperationParams::class, + Utils::printSafe($parsedBody) + )); + } +} diff --git a/src/Utils/AST.php b/src/Utils/AST.php index 27cc776..806162a 100644 --- a/src/Utils/AST.php +++ b/src/Utils/AST.php @@ -3,6 +3,7 @@ namespace GraphQL\Utils; use GraphQL\Error\InvariantViolation; use GraphQL\Language\AST\BooleanValueNode; +use GraphQL\Language\AST\DocumentNode; use GraphQL\Language\AST\EnumValueNode; use GraphQL\Language\AST\FieldNode; use GraphQL\Language\AST\FloatValueNode; @@ -12,6 +13,7 @@ use GraphQL\Language\AST\NameNode; use GraphQL\Language\AST\NullValueNode; use GraphQL\Language\AST\ObjectFieldNode; use GraphQL\Language\AST\ObjectValueNode; +use GraphQL\Language\AST\OperationDefinitionNode; use GraphQL\Language\AST\StringValueNode; use GraphQL\Language\AST\ValueNode; use GraphQL\Language\AST\VariableNode; @@ -332,4 +334,23 @@ class AST return $valueNode instanceof VariableNode && (!$variables || !array_key_exists($valueNode->name->value, $variables)); } + + /** + * @param string $operation + * @param DocumentNode $document + * @return bool + */ + public static function isMutation($operation, DocumentNode $document) + { + if (is_array($document->definitions)) { + foreach ($document->definitions as $def) { + if ($def instanceof OperationDefinitionNode) { + if ($def->operation === 'mutation' && $def->name->value === $operation) { + return true; + } + } + } + } + return false; + } } diff --git a/src/Utils/Utils.php b/src/Utils/Utils.php index cb6242b..6de5f05 100644 --- a/src/Utils/Utils.php +++ b/src/Utils/Utils.php @@ -385,4 +385,28 @@ class Utils ); } } + + /** + * Wraps original closure with PHP error handling (using set_error_handler). + * Resulting closure will collect all PHP errors that occur during the call in $errors array. + * + * @param callable $fn + * @param \ErrorException[] $errors + * @return \Closure + */ + public static function withErrorHandling(callable $fn, array &$errors) + { + return function() use ($fn, &$errors) { + // Catch custom errors (to report them in query results) + set_error_handler(function ($severity, $message, $file, $line) use (&$errors) { + $errors[] = new \ErrorException($message, 0, $severity, $file, $line); + }); + + try { + return $fn(); + } finally { + restore_error_handler(); + } + }; + } } diff --git a/tests/Server/QueryExecutionTest.php b/tests/Server/QueryExecutionTest.php new file mode 100644 index 0000000..092a97c --- /dev/null +++ b/tests/Server/QueryExecutionTest.php @@ -0,0 +1,379 @@ + new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'f1' => [ + 'type' => Type::string(), + 'resolve' => function($root, $args, $context, $info) { + return $info->fieldName; + } + ], + 'fieldWithPhpError' => [ + 'type' => Type::string(), + 'resolve' => function($root, $args, $context, $info) { + trigger_error('deprecated', E_USER_DEPRECATED); + trigger_error('notice', E_USER_NOTICE); + trigger_error('warning', E_USER_WARNING); + $a = []; + $a['test']; // should produce PHP notice + return $info->fieldName; + } + ], + 'fieldWithException' => [ + 'type' => Type::string(), + 'resolve' => function($root, $args, $context, $info) { + throw new \Exception("This is the exception we want"); + } + ], + 'testContextAndRootValue' => [ + 'type' => Type::string(), + 'resolve' => function($root, $args, $context, $info) { + $context->testedRootValue = $root; + return $info->fieldName; + } + ], + 'fieldWithArg' => [ + 'type' => Type::string(), + 'args' => [ + 'arg' => [ + 'type' => Type::nonNull(Type::string()) + ], + ], + 'resolve' => function($root, $args) { + return $args['arg']; + } + ] + ] + ]) + ]); + + $this->config = ServerConfig::create()->setSchema($schema); + } + + public function testSimpleQueryExecution() + { + $query = '{f1}'; + + $expected = [ + 'data' => [ + 'f1' => 'f1' + ] + ]; + + $this->assertQueryResultEquals($expected, $query); + } + + public function testDebugPhpErrors() + { + $this->config->setDebug(true); + + $query = ' + { + fieldWithPhpError + f1 + } + '; + + $expected = [ + 'data' => [ + 'fieldWithPhpError' => 'fieldWithPhpError', + 'f1' => 'f1' + ], + 'extensions' => [ + 'phpErrors' => [ + ['message' => 'deprecated', 'severity' => 16384], + ['message' => 'notice', 'severity' => 1024], + ['message' => 'warning', 'severity' => 512], + ['message' => 'Undefined index: test', 'severity' => 8], + ] + ] + ]; + + $result = $this->assertQueryResultEquals($expected, $query); + + // Assert php errors contain trace: + $this->assertArrayHasKey('trace', $result->extensions['phpErrors'][0]); + $this->assertArrayHasKey('trace', $result->extensions['phpErrors'][1]); + $this->assertArrayHasKey('trace', $result->extensions['phpErrors'][2]); + $this->assertArrayHasKey('trace', $result->extensions['phpErrors'][3]); + } + + public function testDebugExceptions() + { + $this->config->setDebug(true); + + $query = ' + { + fieldWithException + f1 + } + '; + + $expected = [ + 'data' => [ + 'fieldWithException' => null, + 'f1' => 'f1' + ], + 'errors' => [ + [ + 'message' => 'This is the exception we want', + 'path' => ['fieldWithException'], + 'trace' => [] + ] + ] + ]; + + $result = $this->executeQuery($query)->toArray(); + $this->assertArraySubset($expected, $result); + } + + public function testPassesRootValueAndContext() + { + $rootValue = 'myRootValue'; + $context = new \stdClass(); + + $this->config + ->setContext($context) + ->setRootValue($rootValue); + + $query = ' + { + testContextAndRootValue + } + '; + + $this->assertTrue(!isset($context->testedRootValue)); + $this->executeQuery($query); + $this->assertSame($rootValue, $context->testedRootValue); + } + + public function testPassesVariables() + { + $variables = ['a' => 'a', 'b' => 'b']; + $query = ' + query ($a: String!, $b: String!) { + a: fieldWithArg(arg: $a) + b: fieldWithArg(arg: $b) + } + '; + $expected = [ + 'data' => [ + 'a' => 'a', + 'b' => 'b' + ] + ]; + $this->assertQueryResultEquals($expected, $query, $variables); + } + + public function testPassesCustomValidationRules() + { + $query = ' + {nonExistentField} + '; + $expected = [ + 'errors' => [ + ['message' => 'Cannot query field "nonExistentField" on type "Query".'] + ] + ]; + + $this->assertQueryResultEquals($expected, $query); + + $called = false; + + $rules = [ + function() use (&$called) { + $called = true; + return []; + } + ]; + + $this->config->setValidationRules($rules); + $expected = [ + 'data' => [] + ]; + $this->assertQueryResultEquals($expected, $query); + $this->assertTrue($called); + } + + public function testAllowsDifferentValidationRulesDependingOnOperation() + { + $q1 = '{f1}'; + $q2 = '{invalid}'; + $called1 = false; + $called2 = false; + + $this->config->setValidationRules(function(OperationParams $params) use ($q1, $q2, &$called1, &$called2) { + if ($params->query === $q1) { + $called1 = true; + return DocumentValidator::allRules(); + } else { + $called2 = true; + return [ + function(ValidationContext $context) { + $context->reportError(new Error("This is the error we are looking for!")); + } + ]; + } + }); + + $expected = ['data' => ['f1' => 'f1']]; + $this->assertQueryResultEquals($expected, $q1); + $this->assertTrue($called1); + $this->assertFalse($called2); + + $called1 = false; + $called2 = false; + $expected = ['errors' => [['message' => 'This is the error we are looking for!']]]; + $this->assertQueryResultEquals($expected, $q2); + $this->assertFalse($called1); + $this->assertTrue($called2); + } + + public function testAllowsSkippingValidation() + { + $this->config->setValidationRules([]); + $query = '{nonExistentField}'; + $expected = ['data' => []]; + $this->assertQueryResultEquals($expected, $query); + } + + public function testPersistedQueriesAreDisabledByDefault() + { + $this->setExpectedException(UserError::class, 'Persisted queries are not supported by this server'); + $this->executePersistedQuery('some-id'); + } + + public function testAllowsPersistentQueries() + { + $called = false; + $this->config->setPersistentQueryLoader(function($queryId, OperationParams $params) use (&$called) { + $called = true; + $this->assertEquals('some-id', $queryId); + return '{f1}'; + }); + + $result = $this->executePersistedQuery('some-id'); + $this->assertTrue($called); + + $expected = [ + 'data' => [ + 'f1' => 'f1' + ] + ]; + $this->assertEquals($expected, $result->toArray()); + + // Make sure it allows returning document node: + $called = false; + $this->config->setPersistentQueryLoader(function($queryId, OperationParams $params) use (&$called) { + $called = true; + $this->assertEquals('some-id', $queryId); + return Parser::parse('{f1}'); + }); + $result = $this->executePersistedQuery('some-id'); + $this->assertTrue($called); + $this->assertEquals($expected, $result->toArray()); + } + + public function testPersistedQueriesAreStillValidatedByDefault() + { + $this->config->setPersistentQueryLoader(function() { + return '{invalid}'; + }); + $result = $this->executePersistedQuery('some-id'); + $expected = [ + 'errors' => [ + [ + 'message' => 'Cannot query field "invalid" on type "Query".', + 'locations' => [ ['line' => 1, 'column' => 2] ] + ] + ] + ]; + $this->assertEquals($expected, $result->toArray()); + + } + + public function testAllowSkippingValidationForPersistedQueries() + { + $this->config + ->setPersistentQueryLoader(function($queryId) { + if ($queryId === 'some-id') { + return '{invalid}'; + } else { + return '{invalid2}'; + } + }) + ->setValidationRules(function(OperationParams $params) { + if ($params->queryId === 'some-id') { + return []; + } else { + return DocumentValidator::allRules(); + } + }); + + $result = $this->executePersistedQuery('some-id'); + $expected = [ + 'data' => [] + ]; + $this->assertEquals($expected, $result->toArray()); + + $result = $this->executePersistedQuery('some-other-id'); + $expected = [ + 'errors' => [ + [ + 'message' => 'Cannot query field "invalid2" on type "Query".', + 'locations' => [ ['line' => 1, 'column' => 2] ] + ] + ] + ]; + $this->assertEquals($expected, $result->toArray()); + } + + private function executePersistedQuery($queryId, $variables = null) + { + $op = OperationParams::create(['queryId' => $queryId, 'variables' => $variables]); + $result = Helper::executeOperation($this->config, $op); + $this->assertInstanceOf(ExecutionResult::class, $result); + return $result; + } + + private function executeQuery($query, $variables = null) + { + $op = OperationParams::create(['query' => $query, 'variables' => $variables]); + + $result = Helper::executeOperation($this->config, $op); + $this->assertInstanceOf(ExecutionResult::class, $result); + return $result; + } + + private function assertQueryResultEquals($expected, $query, $variables = null) + { + $result = $this->executeQuery($query, $variables); + $this->assertArraySubset($expected, $result->toArray()); + return $result; + } +} diff --git a/tests/Server/ServerConfigTest.php b/tests/Server/ServerConfigTest.php new file mode 100644 index 0000000..c7f4c24 --- /dev/null +++ b/tests/Server/ServerConfigTest.php @@ -0,0 +1,144 @@ +assertEquals(null, $config->getSchema()); + $this->assertEquals(null, $config->getContext()); + $this->assertEquals(null, $config->getRootValue()); + $this->assertEquals([FormattedError::class, 'createFromException'], $config->getErrorFormatter()); + $this->assertEquals(null, $config->getPromiseAdapter()); + $this->assertEquals(null, $config->getValidationRules()); + $this->assertEquals(null, $config->getDefaultFieldResolver()); + $this->assertEquals(null, $config->getPersistentQueryLoader()); + $this->assertEquals(false, $config->getDebug()); + } + + public function testAllowsSettingSchema() + { + $schema = new Schema(['query' => new ObjectType(['name' => 'a', 'fields' => []])]); + $config = ServerConfig::create() + ->setSchema($schema); + + $this->assertSame($schema, $config->getSchema()); + + $schema2 = new Schema(['query' => new ObjectType(['name' => 'a', 'fields' => []])]); + $config->setSchema($schema2); + $this->assertSame($schema2, $config->getSchema()); + } + + public function testAllowsSettingContext() + { + $config = ServerConfig::create(); + + $context = []; + $config->setContext($context); + $this->assertSame($context, $config->getContext()); + + $context2 = new \stdClass(); + $config->setContext($context2); + $this->assertSame($context2, $config->getContext()); + } + + public function testAllowsSettingRootValue() + { + $config = ServerConfig::create(); + + $rootValue = []; + $config->setRootValue($rootValue); + $this->assertSame($rootValue, $config->getRootValue()); + + $context2 = new \stdClass(); + $config->setRootValue($context2); + $this->assertSame($context2, $config->getRootValue()); + } + + public function testAllowsSettingErrorFormatter() + { + $config = ServerConfig::create(); + + $formatter = function() {}; + $config->setErrorFormatter($formatter); + $this->assertSame($formatter, $config->getErrorFormatter()); + + $formatter = 'date'; // test for callable + $config->setErrorFormatter($formatter); + $this->assertSame($formatter, $config->getErrorFormatter()); + } + + public function testAllowsSettingPromiseAdapter() + { + $config = ServerConfig::create(); + + $adapter1 = new SyncPromiseAdapter(); + $config->setPromiseAdapter($adapter1); + $this->assertSame($adapter1, $config->getPromiseAdapter()); + + $adapter2 = new SyncPromiseAdapter(); + $config->setPromiseAdapter($adapter2); + $this->assertSame($adapter2, $config->getPromiseAdapter()); + } + + public function testAllowsSettingValidationRules() + { + $config = ServerConfig::create(); + + $rules = []; + $config->setValidationRules($rules); + $this->assertSame($rules, $config->getValidationRules()); + + $rules = [function() {}]; + $config->setValidationRules($rules); + $this->assertSame($rules, $config->getValidationRules()); + + $rules = function() {return [function() {}];}; + $config->setValidationRules($rules); + $this->assertSame($rules, $config->getValidationRules()); + } + + public function testAllowsSettingDefaultFieldResolver() + { + $config = ServerConfig::create(); + + $resolver = function() {}; + $config->setDefaultFieldResolver($resolver); + $this->assertSame($resolver, $config->getDefaultFieldResolver()); + + $resolver = 'date'; // test for callable + $config->setDefaultFieldResolver($resolver); + $this->assertSame($resolver, $config->getDefaultFieldResolver()); + } + + public function testAllowsSettingPersistedQueryLoader() + { + $config = ServerConfig::create(); + + $loader = function() {}; + $config->setPersistentQueryLoader($loader); + $this->assertSame($loader, $config->getPersistentQueryLoader()); + + $loader = 'date'; // test for callable + $config->setPersistentQueryLoader($loader); + $this->assertSame($loader, $config->getPersistentQueryLoader()); + } + + public function testAllowsSettingCatchPhpErrors() + { + $config = ServerConfig::create(); + + $config->setDebug(true); + $this->assertSame(true, $config->getDebug()); + + $config->setDebug(false); + $this->assertSame(false, $config->getDebug()); + } +} diff --git a/tests/ServerTest.php b/tests/ServerTest.php deleted file mode 100644 index 27d4753..0000000 --- a/tests/ServerTest.php +++ /dev/null @@ -1,580 +0,0 @@ -assertEquals(null, $server->getQueryType()); - $this->assertEquals(null, $server->getMutationType()); - $this->assertEquals(null, $server->getSubscriptionType()); - $this->assertEquals(Directive::getInternalDirectives(), $server->getDirectives()); - $this->assertEquals([], $server->getTypes()); - $this->assertEquals(null, $server->getTypeResolutionStrategy()); - - $this->assertEquals(null, $server->getContext()); - $this->assertEquals(null, $server->getRootValue()); - $this->assertEquals(0, $server->getDebug()); - - $this->assertEquals(['GraphQL\Error\FormattedError', 'createFromException'], $server->getExceptionFormatter()); - $this->assertEquals(['GraphQL\Error\FormattedError', 'createFromPHPError'], $server->getPhpErrorFormatter()); - $this->assertEquals(null, $server->getPromiseAdapter()); - $this->assertEquals('Unexpected Error', $server->getUnexpectedErrorMessage()); - $this->assertEquals(500, $server->getUnexpectedErrorStatus()); - $this->assertEquals(DocumentValidator::allRules(), $server->getValidationRules()); - - try { - $server->getSchema(); - $this->fail('Expected exception not thrown'); - } catch (InvariantViolation $e) { - $this->assertEquals('Schema query must be Object Type but got: NULL', $e->getMessage()); - } - } - - public function testSchemaDefinition() - { - $mutationType = $queryType = $subscriptionType = new ObjectType(['name' => 'A', 'fields' => []]); - - $schema = new Schema([ - 'query' => $queryType - ]); - - try { - Server::create() - ->setQueryType($queryType) - ->setSchema($schema); - $this->fail('Expected exception not thrown'); - } catch (InvariantViolation $e) { - $this->assertEquals( - 'Cannot set Schema on Server: Query Type is already set '. - '(GraphQL\Server::setQueryType is mutually exclusive with GraphQL\Server::setSchema)', - $e->getMessage() - ); - } - - try { - Server::create() - ->setMutationType($mutationType) - ->setSchema($schema); - $this->fail('Expected exception not thrown'); - } catch (InvariantViolation $e) { - $this->assertEquals( - 'Cannot set Schema on Server: Mutation Type is already set '. - '(GraphQL\Server::setMutationType is mutually exclusive with GraphQL\Server::setSchema)', - $e->getMessage() - ); - } - - try { - Server::create() - ->setSubscriptionType($subscriptionType) - ->setSchema($schema); - $this->fail('Expected exception not thrown'); - } catch (InvariantViolation $e) { - $this->assertEquals( - 'Cannot set Schema on Server: Subscription Type is already set '. - '(GraphQL\Server::setSubscriptionType is mutually exclusive with GraphQL\Server::setSchema)', - $e->getMessage() - ); - } - - try { - Server::create() - ->setDirectives(Directive::getInternalDirectives()) - ->setSchema($schema); - $this->fail('Expected exception not thrown'); - } catch (InvariantViolation $e) { - $this->assertEquals( - 'Cannot set Schema on Server: Directives are already set '. - '(GraphQL\Server::setDirectives is mutually exclusive with GraphQL\Server::setSchema)', - $e->getMessage() - ); - } - - try { - Server::create() - ->addTypes([$queryType, $mutationType]) - ->setSchema($schema); - $this->fail('Expected exception not thrown'); - } catch (InvariantViolation $e) { - $this->assertEquals( - 'Cannot set Schema on Server: Additional types are already set '. - '(GraphQL\Server::addTypes is mutually exclusive with GraphQL\Server::setSchema)', - $e->getMessage() - ); - } - - - try { - Server::create() - ->setTypeResolutionStrategy(new EagerResolution([$queryType, $mutationType])) - ->setSchema($schema); - $this->fail('Expected exception not thrown'); - } catch (InvariantViolation $e) { - $this->assertEquals( - 'Cannot set Schema on Server: Type Resolution Strategy is already set '. - '(GraphQL\Server::setTypeResolutionStrategy is mutually exclusive with GraphQL\Server::setSchema)', - $e->getMessage() - ); - } - - try { - Server::create() - ->setSchema($schema) - ->setQueryType($queryType); - $this->fail('Expected exception not thrown'); - } catch (InvariantViolation $e) { - $this->assertEquals( - 'Cannot set Query Type on Server: Schema is already set '. - '(GraphQL\Server::setQueryType is mutually exclusive with GraphQL\Server::setSchema)', - $e->getMessage() - ); - } - - try { - Server::create() - ->setSchema($schema) - ->setMutationType($mutationType); - $this->fail('Expected exception not thrown'); - } catch (InvariantViolation $e) { - $this->assertEquals( - 'Cannot set Mutation Type on Server: Schema is already set '. - '(GraphQL\Server::setMutationType is mutually exclusive with GraphQL\Server::setSchema)', - $e->getMessage() - ); - } - - try { - Server::create() - ->setSchema($schema) - ->setSubscriptionType($subscriptionType); - $this->fail('Expected exception not thrown'); - } catch (InvariantViolation $e) { - $this->assertEquals( - 'Cannot set Subscription Type on Server: Schema is already set '. - '(GraphQL\Server::setSubscriptionType is mutually exclusive with GraphQL\Server::setSchema)', - $e->getMessage() - ); - } - - try { - Server::create() - ->setSchema($schema) - ->setDirectives([]); - $this->fail('Expected exception not thrown'); - } catch (InvariantViolation $e) { - $this->assertEquals( - 'Cannot set Directives on Server: Schema is already set '. - '(GraphQL\Server::setDirectives is mutually exclusive with GraphQL\Server::setSchema)', - $e->getMessage() - ); - } - - try { - Server::create() - ->setSchema($schema) - ->addTypes([$queryType, $mutationType]); - $this->fail('Expected exception not thrown'); - } catch (InvariantViolation $e) { - $this->assertEquals( - 'Cannot set Types on Server: Schema is already set '. - '(GraphQL\Server::addTypes is mutually exclusive with GraphQL\Server::setSchema)', - $e->getMessage() - ); - } - // But empty types should work (as they don't change anything): - Server::create() - ->setSchema($schema) - ->addTypes([]); - - try { - Server::create() - ->setSchema($schema) - ->setTypeResolutionStrategy(new EagerResolution([$queryType, $mutationType])); - $this->fail('Expected exception not thrown'); - } catch (InvariantViolation $e) { - $this->assertEquals( - 'Cannot set Type Resolution Strategy on Server: Schema is already set '. - '(GraphQL\Server::setTypeResolutionStrategy is mutually exclusive with GraphQL\Server::setSchema)', - $e->getMessage() - ); - } - - try { - Server::create() - ->setSchema($schema) - ->setSchema(new Schema(['query' => $queryType])); - $this->fail('Expected exception not thrown'); - } catch (InvariantViolation $e) { - $this->assertEquals( - 'Cannot set Schema on Server: Different schema is already set', - $e->getMessage() - ); - } - - - // This should not throw: - $server = Server::create() - ->setSchema($schema); - - $this->assertSame($schema, $server->getSchema()); - - $server = Server::create() - ->setQueryType($queryType); - $this->assertSame($queryType, $server->getQueryType()); - $this->assertSame($queryType, $server->getSchema()->getQueryType()); - - $server = Server::create() - ->setQueryType($queryType) - ->setMutationType($mutationType); - - $this->assertSame($mutationType, $server->getMutationType()); - $this->assertSame($mutationType, $server->getSchema()->getMutationType()); - - $server = Server::create() - ->setQueryType($queryType) - ->setSubscriptionType($subscriptionType); - - $this->assertSame($subscriptionType, $server->getSubscriptionType()); - $this->assertSame($subscriptionType, $server->getSchema()->getSubscriptionType()); - - $server = Server::create() - ->setQueryType($queryType) - ->addTypes($types = [$queryType, $subscriptionType]); - - $this->assertSame($types, $server->getTypes()); - $server->addTypes([$mutationType]); - $this->assertSame(array_merge($types, [$mutationType]), $server->getTypes()); - - $server = Server::create() - ->setDirectives($directives = []); - - $this->assertSame($directives, $server->getDirectives()); - } - - public function testParse() - { - $server = Server::create(); - $ast = $server->parse('{q}'); - $this->assertInstanceOf('GraphQL\Language\AST\DocumentNode', $ast); - - try { - $server->parse('{q'); - $this->fail('Expected exception not thrown'); - } catch (\GraphQL\Error\SyntaxError $e) { - $this->assertContains('{q', $e->getMessage()); - } - } - - public function testValidate() - { - $server = Server::create() - ->setQueryType(new ObjectType(['name' => 'Q', 'fields' => []])); - - $ast = $server->parse('{q}'); - $errors = $server->validate($ast); - - $this->assertInternalType('array', $errors); - $this->assertNotEmpty($errors); - - try { - $server = Server::create(); - $server->validate($ast); - $this->fail('Expected exception not thrown'); - } catch (InvariantViolation $e) { - $this->assertEquals( - 'Cannot validate, schema contains errors: Schema query must be Object Type but got: NULL', - $e->getMessage() - ); - } - } - - public function testPromiseAdapter() - { - $adapter1 = new SyncPromiseAdapter(); - $adapter2 = new SyncPromiseAdapter(); - - $server = Server::create() - ->setPromiseAdapter($adapter1); - - $this->assertSame($adapter1, $server->getPromiseAdapter()); - $server->setPromiseAdapter($adapter1); - - try { - $server->setPromiseAdapter($adapter2); - $this->fail('Expected exception not thrown'); - } catch (InvariantViolation $e) { - $this->assertEquals( - 'Cannot set promise adapter: Different adapter is already set', - $e->getMessage() - ); - } - } - - public function testValidationRules() - { - $rules = []; - $server = Server::create() - ->setValidationRules($rules); - - $this->assertSame($rules, $server->getValidationRules()); - } - - public function testExecuteQuery() - { - $called = false; - $queryType = new ObjectType([ - 'name' => 'Q', - 'fields' => [ - 'field' => [ - 'type' => Type::string(), - 'resolve' => function($value, $args, $context, ResolveInfo $info) use (&$called) { - $called = true; - $this->assertEquals(null, $context); - $this->assertEquals(null, $value); - $this->assertEquals(null, $info->rootValue); - return 'ok'; - } - ] - ] - ]); - - $server = Server::create() - ->setQueryType($queryType); - - $result = $server->executeQuery('{field}'); - $this->assertEquals(true, $called); - $this->assertInstanceOf('GraphQL\Executor\ExecutionResult', $result); - $this->assertEquals(['data' => ['field' => 'ok']], $result->toArray()); - - $called = false; - $contextValue = new \stdClass(); - $rootValue = new \stdClass(); - - $queryType = new ObjectType([ - 'name' => 'QueryType', - 'fields' => [ - 'field' => [ - 'type' => Type::string(), - 'resolve' => function($value, $args, $context, ResolveInfo $info) use (&$called, $contextValue, $rootValue) { - $called = true; - $this->assertSame($rootValue, $value); - $this->assertSame($contextValue, $context); - $this->assertEquals($rootValue, $info->rootValue); - return 'ok'; - } - ] - ] - ]); - - $server = Server::create() - ->setQueryType($queryType) - ->setRootValue($rootValue) - ->setContext($contextValue); - - $result = $server->executeQuery('{field}'); - $this->assertEquals(true, $called); - $this->assertInstanceOf('GraphQL\Executor\ExecutionResult', $result); - $this->assertEquals(['data' => ['field' => 'ok']], $result->toArray()); - } - - public function testDebugPhpErrors() - { - $queryType = new ObjectType([ - 'name' => 'Query', - 'fields' => [ - 'err' => [ - 'type' => Type::string(), - 'resolve' => function() { - trigger_error('notice', E_USER_NOTICE); - return 'err'; - } - ] - ] - ]); - - $server = Server::create() - ->setDebug(0) - ->setQueryType($queryType); - - $prevEnabled = \PHPUnit_Framework_Error_Notice::$enabled; - \PHPUnit_Framework_Error_Notice::$enabled = false; - $result = @$server->executeQuery('{err}'); - - $expected = [ - 'data' => ['err' => 'err'] - ]; - $this->assertEquals($expected, $result->toArray()); - - $server->setDebug(Server::DEBUG_PHP_ERRORS); - $result = @$server->executeQuery('{err}'); - - $expected = [ - 'data' => ['err' => 'err'], - 'extensions' => [ - 'phpErrors' => [ - [ - 'message' => 'notice', - 'severity' => 1024, - // 'trace' => [...] - ] - ] - ] - ]; - - $this->assertArraySubset($expected, $result->toArray()); - - $server->setPhpErrorFormatter(function(\ErrorException $e) { - return ['test' => $e->getMessage()]; - }); - - $result = $server->executeQuery('{err}'); - $expected = [ - 'data' => ['err' => 'err'], - 'extensions' => [ - 'phpErrors' => [ - [ - 'test' => 'notice' - ] - ] - ] - ]; - $this->assertEquals($expected, $result->toArray()); - - \PHPUnit_Framework_Error_Notice::$enabled = $prevEnabled; - } - - public function testDebugExceptions() - { - $queryType = new ObjectType([ - 'name' => 'Query', - 'fields' => [ - 'withException' => [ - 'type' => Type::string(), - 'resolve' => function() { - throw new \Exception("Error"); - } - ] - ] - ]); - - $server = Server::create() - ->setDebug(0) - ->setQueryType($queryType); - - $result = $server->executeQuery('{withException}'); - $expected = [ - 'data' => [ - 'withException' => null - ], - 'errors' => [[ - 'message' => 'Error', - 'path' => ['withException'], - 'locations' => [[ - 'line' => 1, - 'column' => 2 - ]] - ]] - ]; - $this->assertEquals($expected, $result->toArray()); - - $server->setDebug(Server::DEBUG_EXCEPTIONS); - $result = $server->executeQuery('{withException}'); - - $expected['errors'][0]['exception'] = ['message' => 'Error', 'trace' => []]; - $this->assertArraySubset($expected, $result->toArray()); - - $server->setExceptionFormatter(function(\Exception $e) { - return ['test' => $e->getMessage()]; - }); - - $result = $server->executeQuery('{withException}'); - $expected['errors'][0]['exception'] = ['test' => 'Error']; - $this->assertEquals($expected, $result->toArray()); - } - - public function testHandleRequest() - { - $mock = $this->getMockBuilder('GraphQL\Server') - ->setMethods(['readInput', 'produceOutput']) - ->getMock() - ; - - $mock->method('readInput') - ->will($this->returnValue(json_encode(['query' => '{err}']))); - - $output = null; - $mock->method('produceOutput') - ->will($this->returnCallback(function($a1, $a2) use (&$output) { - $output = func_get_args(); - })); - - /** @var $mock Server */ - $mock->handleRequest(); - - $this->assertInternalType('array', $output); - $this->assertEquals(['errors' => [['message' => 'Unexpected Error']]], $output[0]); - $this->assertEquals(500, $output[1]); - - $output = null; - $mock->setUnexpectedErrorMessage($newErr = 'Hey! Something went wrong!'); - $mock->setUnexpectedErrorStatus(501); - $mock->handleRequest(); - - $this->assertInternalType('array', $output); - $this->assertEquals(['errors' => [['message' => $newErr]]], $output[0]); - $this->assertEquals(501, $output[1]); - - $mock->setQueryType(new ObjectType([ - 'name' => 'Query', - 'fields' => [ - 'test' => [ - 'type' => Type::string(), - 'resolve' => function() { - return 'ok'; - } - ] - ] - ])); - - $_REQUEST = ['query' => '{err}']; - $output = null; - $mock->handleRequest(); - $this->assertInternalType('array', $output); - - $expectedOutput = [ - ['errors' => [[ - 'message' => 'Cannot query field "err" on type "Query".', - 'locations' => [[ - 'line' => 1, - 'column' => 2 - ]] - ]]], - 200 - ]; - - $this->assertEquals($expectedOutput, $output); - - $output = null; - $_SERVER['CONTENT_TYPE'] = 'application/json'; - $_REQUEST = []; - $mock->handleRequest(); - - $this->assertInternalType('array', $output); - $this->assertEquals($expectedOutput, $output); - } -}