Version 4.6.0

This commit is contained in:
Neo committed 2025-12-19 05:55:31 -08:00
1 parent f79dcf067a
commit 517a5e1f70
2036 files changed
+110041 -26162

No files matched your search

+205 -153
View File
@@ -1,6 +1,10 @@
<?php
declare(strict_types=1);
namespace GraphQL\Server;
use GraphQL\Error\DebugFlag;
use GraphQL\Error\Error;
use GraphQL\Error\FormattedError;
use GraphQL\Error\InvariantViolation;
@@ -14,16 +18,31 @@ use GraphQL\Language\AST\DocumentNode;
use GraphQL\Language\Parser;
use GraphQL\Utils\AST;
use GraphQL\Utils\Utils;
use JsonSerializable;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\StreamInterface;
use function count;
use function file_get_contents;
use function header;
use function html_entity_decode;
use function is_array;
use function is_callable;
use function is_string;
use function json_decode;
use function json_encode;
use function json_last_error;
use function json_last_error_msg;
use function parse_str;
use function sprintf;
use function stripos;
/**
* Contains functionality that could be re-used by various server implementations
*/
class Helper
{
/**
* Parses HTTP request using PHP globals and returns GraphQL OperationParams
* contained in this request. For batched requests it returns an array of OperationParams.
@@ -39,42 +58,52 @@ class Helper
*
* For PSR-7 request parsing use `parsePsrRequest()` instead.
*
* @api
* @param callable|null $readRawBodyFn
* @return OperationParams|OperationParams[]
*
* @throws RequestError
*
* @api
*/
public function parseHttpRequest(callable $readRawBodyFn = null)
public function parseHttpRequest(?callable $readRawBodyFn = null)
{
$method = isset($_SERVER['REQUEST_METHOD']) ? $_SERVER['REQUEST_METHOD'] : null;
$method = $_SERVER['REQUEST_METHOD'] ?? null;
$bodyParams = [];
$urlParams = $_GET;
$urlParams = $_GET;
if ($method === 'POST') {
$contentType = isset($_SERVER['CONTENT_TYPE']) ? $_SERVER['CONTENT_TYPE'] : null;
$contentType = $_SERVER['CONTENT_TYPE'] ?? null;
if ($contentType === null) {
throw new RequestError('Missing "Content-Type" header');
}
if (stripos($contentType, 'application/graphql') !== false) {
$rawBody = $readRawBodyFn ? $readRawBodyFn() : $this->readRawBody();
$bodyParams = ['query' => $rawBody ?: ''];
} else if (stripos($contentType, 'application/json') !== false) {
$rawBody = $readRawBodyFn ? $readRawBodyFn() : $this->readRawBody();
$bodyParams = json_decode($rawBody ?: '', true);
$rawBody = $readRawBodyFn
? $readRawBodyFn()
: $this->readRawBody();
$bodyParams = ['query' => $rawBody ?? ''];
} elseif (stripos($contentType, 'application/json') !== false) {
$rawBody = $readRawBodyFn ?
$readRawBodyFn()
: $this->readRawBody();
$bodyParams = json_decode($rawBody ?? '', true);
if (json_last_error()) {
throw new RequestError("Could not parse JSON: " . json_last_error_msg());
throw new RequestError('Could not parse JSON: ' . json_last_error_msg());
}
if (!is_array($bodyParams)) {
if (! is_array($bodyParams)) {
throw new RequestError(
"GraphQL Server expects JSON object or array, but got " .
'GraphQL Server expects JSON object or array, but got ' .
Utils::printSafeJson($bodyParams)
);
}
} else if (stripos($contentType, 'application/x-www-form-urlencoded') !== false) {
} elseif (stripos($contentType, 'application/x-www-form-urlencoded') !== false) {
$bodyParams = $_POST;
} elseif (stripos($contentType, 'multipart/form-data') !== false) {
$bodyParams = $_POST;
} else if (null === $contentType) {
throw new RequestError('Missing "Content-Type" header');
} else {
throw new RequestError("Unexpected content type: " . Utils::printSafeJson($contentType));
throw new RequestError('Unexpected content type: ' . Utils::printSafeJson($contentType));
}
}
@@ -87,22 +116,25 @@ class Helper
*
* Returned value is a suitable input for `executeOperation` or `executeBatch` (if array)
*
* @api
* @param string $method
* @param array $bodyParams
* @param array $queryParams
* @param string $method
* @param mixed[] $bodyParams
* @param mixed[] $queryParams
*
* @return OperationParams|OperationParams[]
*
* @throws RequestError
*
* @api
*/
public function parseRequestParams($method, array $bodyParams, array $queryParams)
{
if ($method === 'GET') {
$result = OperationParams::create($queryParams, true);
} else if ($method === 'POST') {
} elseif ($method === 'POST') {
if (isset($bodyParams[0])) {
$result = [];
foreach ($bodyParams as $index => $entry) {
$op = OperationParams::create($entry);
$op = OperationParams::create($entry);
$result[] = $op;
}
} else {
@@ -111,6 +143,7 @@ class Helper
} else {
throw new RequestError('HTTP Method "' . $method . '" is not supported');
}
return $result;
}
@@ -118,45 +151,49 @@ class Helper
* Checks validity of OperationParams extracted from HTTP request and returns an array of errors
* if params are invalid (or empty array when params are valid)
*
* @return array<int, RequestError>
*
* @api
* @param OperationParams $params
* @return Error[]
*/
public function validateOperationParams(OperationParams $params)
{
$errors = [];
if (!$params->query && !$params->queryId) {
if (! $params->query && ! $params->queryId) {
$errors[] = new RequestError('GraphQL Request must include at least one of those two parameters: "query" or "queryId"');
}
if ($params->query && $params->queryId) {
$errors[] = new RequestError('GraphQL Request parameters "query" and "queryId" are mutually exclusive');
}
if ($params->query !== null && (!is_string($params->query) || empty($params->query))) {
if ($params->query !== null && ! is_string($params->query)) {
$errors[] = new RequestError(
'GraphQL Request parameter "query" must be string, but got ' .
Utils::printSafeJson($params->query)
);
}
if ($params->queryId !== null && (!is_string($params->queryId) || empty($params->queryId))) {
if ($params->queryId !== null && ! is_string($params->queryId)) {
$errors[] = new RequestError(
'GraphQL Request parameter "queryId" must be string, but got ' .
Utils::printSafeJson($params->queryId)
);
}
if ($params->operation !== null && (!is_string($params->operation) || empty($params->operation))) {
if ($params->operation !== null && ! is_string($params->operation)) {
$errors[] = new RequestError(
'GraphQL Request parameter "operation" must be string, but got ' .
Utils::printSafeJson($params->operation)
);
}
if ($params->variables !== null && (!is_array($params->variables) || isset($params->variables[0]))) {
if ($params->variables !== null && (! is_array($params->variables) || isset($params->variables[0]))) {
$errors[] = new RequestError(
'GraphQL Request parameter "variables" must be object or JSON string parsed to object, but got ' .
Utils::printSafeJson($params->getOriginalInput('variables'))
);
}
return $errors;
}
@@ -164,16 +201,14 @@ class Helper
* Executes GraphQL operation with given server configuration and returns execution result
* (or promise when promise adapter is different from SyncPromiseAdapter)
*
* @api
* @param ServerConfig $config
* @param OperationParams $op
*
* @return ExecutionResult|Promise
*
* @api
*/
public function executeOperation(ServerConfig $config, OperationParams $op)
{
$promiseAdapter = $config->getPromiseAdapter() ?: Executor::getPromiseAdapter();
$result = $this->promiseToExecuteOperation($promiseAdapter, $config, $op);
$promiseAdapter = $config->getPromiseAdapter() ?? Executor::getPromiseAdapter();
$result = $this->promiseToExecuteOperation($promiseAdapter, $config, $op);
if ($promiseAdapter instanceof SyncPromiseAdapter) {
$result = $promiseAdapter->wait($result);
@@ -186,15 +221,16 @@ class Helper
* Executes batched GraphQL operations with shared promise queue
* (thus, effectively batching deferreds|promises of all queries at once)
*
* @api
* @param ServerConfig $config
* @param OperationParams[] $operations
* @return ExecutionResult[]|Promise
*
* @return ExecutionResult|ExecutionResult[]|Promise
*
* @api
*/
public function executeBatch(ServerConfig $config, array $operations)
{
$promiseAdapter = $config->getPromiseAdapter() ?: Executor::getPromiseAdapter();
$result = [];
$promiseAdapter = $config->getPromiseAdapter() ?? Executor::getPromiseAdapter();
$result = [];
foreach ($operations as $operation) {
$result[] = $this->promiseToExecuteOperation($promiseAdapter, $config, $operation, true);
@@ -206,46 +242,61 @@ class Helper
if ($promiseAdapter instanceof SyncPromiseAdapter) {
$result = $promiseAdapter->wait($result);
}
return $result;
}
/**
* @param PromiseAdapter $promiseAdapter
* @param ServerConfig $config
* @param OperationParams $op
* @param bool $isBatch
*
* @return Promise
*/
private function promiseToExecuteOperation(PromiseAdapter $promiseAdapter, ServerConfig $config, OperationParams $op, $isBatch = false)
{
private function promiseToExecuteOperation(
PromiseAdapter $promiseAdapter,
ServerConfig $config,
OperationParams $op,
$isBatch = false
) {
try {
if (!$config->getSchema()) {
throw new InvariantViolation("Schema is required for the server");
if ($config->getSchema() === null) {
throw new InvariantViolation('Schema is required for the server');
}
if ($isBatch && !$config->getQueryBatching()) {
throw new RequestError("Batched queries are not supported by this server");
if ($isBatch && ! $config->getQueryBatching()) {
throw new RequestError('Batched queries are not supported by this server');
}
$errors = $this->validateOperationParams($op);
if (!empty($errors)) {
$errors = Utils::map($errors, function(RequestError $err) {
return Error::createLocatedError($err, null, null);
});
if (count($errors) > 0) {
$errors = Utils::map(
$errors,
static function (RequestError $err) : Error {
return Error::createLocatedError($err, null, null);
}
);
return $promiseAdapter->createFulfilled(
new ExecutionResult(null, $errors)
);
}
$doc = $op->queryId ? $this->loadPersistedQuery($config, $op) : $op->query;
$doc = $op->queryId
? $this->loadPersistedQuery($config, $op)
: $op->query;
if (!$doc instanceof DocumentNode) {
if (! $doc instanceof DocumentNode) {
$doc = Parser::parse($doc);
}
$operationType = AST::getOperation($doc, $op->operation);
if ($op->isReadOnly() && $operationType !== 'query') {
throw new RequestError("GET supports only query operation");
if ($operationType === false) {
throw new RequestError('Failed to determine operation type');
}
if ($operationType !== 'query' && $op->isReadOnly()) {
throw new RequestError('GET supports only query operation');
}
$result = GraphQL::promiseToExecute(
@@ -259,7 +310,6 @@ class Helper
$config->getFieldResolver(),
$this->resolveValidationRules($config, $op, $doc, $operationType)
);
} catch (RequestError $e) {
$result = $promiseAdapter->createFulfilled(
new ExecutionResult(null, [Error::createLocatedError($e)])
@@ -270,16 +320,19 @@ class Helper
);
}
$applyErrorHandling = function (ExecutionResult $result) use ($config) {
$applyErrorHandling = static function (ExecutionResult $result) use ($config) : ExecutionResult {
if ($config->getErrorsHandler()) {
$result->setErrorsHandler($config->getErrorsHandler());
}
if ($config->getErrorFormatter() || $config->getDebug()) {
if ($config->getErrorFormatter() || $config->getDebugFlag() !== DebugFlag::NONE) {
$result->setErrorFormatter(
FormattedError::prepareFormatter($config->getErrorFormatter(),
$config->getDebug())
FormattedError::prepareFormatter(
$config->getErrorFormatter(),
$config->getDebugFlag()
)
);
}
return $result;
};
@@ -287,25 +340,24 @@ class Helper
}
/**
* @param ServerConfig $config
* @param OperationParams $op
* @return mixed
*
* @throws RequestError
*/
private function loadPersistedQuery(ServerConfig $config, OperationParams $op)
private function loadPersistedQuery(ServerConfig $config, OperationParams $operationParams)
{
// Load query if we got persisted query id:
$loader = $config->getPersistentQueryLoader();
if (!$loader) {
throw new RequestError("Persisted queries are not supported by this server");
if ($loader === null) {
throw new RequestError('Persisted queries are not supported by this server');
}
$source = $loader($op->queryId, $op);
$source = $loader($operationParams->queryId, $operationParams);
if (!is_string($source) && !$source instanceof DocumentNode) {
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",
'Persistent query loader must return query string or instance of %s but got: %s',
DocumentNode::class,
Utils::printSafe($source)
));
@@ -315,23 +367,25 @@ class Helper
}
/**
* @param ServerConfig $config
* @param OperationParams $params
* @param DocumentNode $doc
* @param $operationType
* @return array
* @param string $operationType
*
* @return mixed[]|null
*/
private function resolveValidationRules(ServerConfig $config, OperationParams $params, DocumentNode $doc, $operationType)
{
private function resolveValidationRules(
ServerConfig $config,
OperationParams $params,
DocumentNode $doc,
$operationType
) {
// Allow customizing validation rules per operation:
$validationRules = $config->getValidationRules();
if (is_callable($validationRules)) {
$validationRules = $validationRules($params, $doc, $operationType);
if (!is_array($validationRules)) {
if (! is_array($validationRules)) {
throw new InvariantViolation(sprintf(
"Expecting validation rules to be array or callable returning array, but got: %s",
'Expecting validation rules to be array or callable returning array, but got: %s',
Utils::printSafe($validationRules)
));
}
@@ -341,35 +395,33 @@ class Helper
}
/**
* @param ServerConfig $config
* @param OperationParams $params
* @param DocumentNode $doc
* @param $operationType
* @return mixed
*/
private function resolveRootValue(ServerConfig $config, OperationParams $params, DocumentNode $doc, $operationType)
private function resolveRootValue(ServerConfig $config, OperationParams $params, DocumentNode $doc, string $operationType)
{
$root = $config->getRootValue();
$rootValue = $config->getRootValue();
if ($root instanceof \Closure) {
$root = $root($params, $doc, $operationType);
if (is_callable($rootValue)) {
$rootValue = $rootValue($params, $doc, $operationType);
}
return $root;
return $rootValue;
}
/**
* @param ServerConfig $config
* @param OperationParams $params
* @param DocumentNode $doc
* @param $operationType
* @param string $operationType
*
* @return mixed
*/
private function resolveContextValue(ServerConfig $config, OperationParams $params, DocumentNode $doc, $operationType)
{
private function resolveContextValue(
ServerConfig $config,
OperationParams $params,
DocumentNode $doc,
$operationType
) {
$context = $config->getContext();
if ($context instanceof \Closure) {
if (is_callable($context)) {
$context = $context($params, $doc, $operationType);
}
@@ -379,14 +431,15 @@ class Helper
/**
* Send response using standard PHP `header()` and `echo`.
*
* @api
* @param Promise|ExecutionResult|ExecutionResult[] $result
* @param bool $exitWhenDone
* @param bool $exitWhenDone
*
* @api
*/
public function sendResponse($result, $exitWhenDone = false)
{
if ($result instanceof Promise) {
$result->then(function($actualResult) use ($exitWhenDone) {
$result->then(function ($actualResult) use ($exitWhenDone) : void {
$this->doSendResponse($actualResult, $exitWhenDone);
});
} else {
@@ -394,10 +447,6 @@ class Helper
}
}
/**
* @param $result
* @param $exitWhenDone
*/
private function doSendResponse($result, $exitWhenDone)
{
$httpStatus = $this->resolveHttpStatus($result);
@@ -405,9 +454,9 @@ class Helper
}
/**
* @param array|\JsonSerializable $jsonSerializable
* @param int $httpStatus
* @param bool $exitWhenDone
* @param mixed[]|JsonSerializable $jsonSerializable
* @param int $httpStatus
* @param bool $exitWhenDone
*/
public function emitResponse($jsonSerializable, $httpStatus, $exitWhenDone)
{
@@ -429,119 +478,122 @@ class Helper
}
/**
* @param $result
* @param ExecutionResult|mixed[] $result
*
* @return int
*/
private function resolveHttpStatus($result)
{
if (is_array($result) && isset($result[0])) {
Utils::each($result, function ($executionResult, $index) {
if (!$executionResult instanceof ExecutionResult) {
throw new InvariantViolation(sprintf(
"Expecting every entry of batched query result to be instance of %s but entry at position %d is %s",
ExecutionResult::class,
$index,
Utils::printSafe($executionResult)
));
Utils::each(
$result,
static function ($executionResult, $index) : void {
if (! $executionResult instanceof ExecutionResult) {
throw new InvariantViolation(sprintf(
'Expecting every entry of batched query result to be instance of %s but entry at position %d is %s',
ExecutionResult::class,
$index,
Utils::printSafe($executionResult)
));
}
}
});
);
$httpStatus = 200;
} else {
if (!$result instanceof ExecutionResult) {
if (! $result instanceof ExecutionResult) {
throw new InvariantViolation(sprintf(
"Expecting query result to be instance of %s but got %s",
'Expecting query result to be instance of %s but got %s',
ExecutionResult::class,
Utils::printSafe($result)
));
}
if ($result->data === null && !empty($result->errors)) {
if ($result->data === null && count($result->errors) > 0) {
$httpStatus = 400;
} else {
$httpStatus = 200;
}
}
return $httpStatus;
}
/**
* Converts PSR-7 request to OperationParams[]
*
* @api
* @param ServerRequestInterface $request
* @return array|Helper
* @return OperationParams[]|OperationParams
*
* @throws RequestError
*
* @api
*/
public function parsePsrRequest(ServerRequestInterface $request)
public function parsePsrRequest(RequestInterface $request)
{
if ($request->getMethod() === 'GET') {
$bodyParams = [];
} else {
$contentType = $request->getHeader('content-type');
if (!isset($contentType[0])) {
if (! isset($contentType[0])) {
throw new RequestError('Missing "Content-Type" header');
}
if (stripos('application/graphql', $contentType[0]) !== false) {
$bodyParams = ['query' => $request->getBody()->getContents()];
} else if (stripos('application/json', $contentType[0]) !== false) {
$bodyParams = $request->getParsedBody();
if (stripos($contentType[0], 'application/graphql') !== false) {
$bodyParams = ['query' => (string) $request->getBody()];
} elseif (stripos($contentType[0], 'application/json') !== false) {
$bodyParams = $request instanceof ServerRequestInterface
? $request->getParsedBody()
: json_decode((string) $request->getBody(), true);
if (null === $bodyParams) {
if ($bodyParams === null) {
throw new InvariantViolation(
"PSR-7 request is expected to provide parsed body for \"application/json\" requests but got null"
$request instanceof ServerRequestInterface
? 'Expected to receive a parsed body for "application/json" PSR-7 request but got null'
: 'Expected to receive a JSON array in body for "application/json" PSR-7 request'
);
}
// Try parsing ourselves if PSR-7 implementation doesn't parse JSON automatically
if (is_array($bodyParams) && empty($bodyParams)) {
$bodyParams = json_decode($request->getBody(), true);
if (json_last_error()) {
throw new RequestError("Could not parse JSON: " . json_last_error_msg());
}
}
if (!is_array($bodyParams)) {
if (! is_array($bodyParams)) {
throw new RequestError(
"GraphQL Server expects JSON object or array, but got " .
'GraphQL Server expects JSON object or array, but got ' .
Utils::printSafeJson($bodyParams)
);
}
} else {
$bodyParams = $request->getParsedBody();
parse_str((string) $request->getBody(), $bodyParams);
if (!is_array($bodyParams)) {
throw new RequestError("Unexpected content type: " . Utils::printSafeJson($contentType[0]));
if (! is_array($bodyParams)) {
throw new RequestError('Unexpected content type: ' . Utils::printSafeJson($contentType[0]));
}
}
}
parse_str(html_entity_decode($request->getUri()->getQuery()), $queryParams);
return $this->parseRequestParams(
$request->getMethod(),
$bodyParams,
$request->getQueryParams()
$queryParams
);
}
/**
* Converts query execution result to PSR-7 response
*
* @api
* @param Promise|ExecutionResult|ExecutionResult[] $result
* @param ResponseInterface $response
* @param StreamInterface $writableBodyStream
*
* @return Promise|ResponseInterface
*
* @api
*/
public function toPsrResponse($result, ResponseInterface $response, StreamInterface $writableBodyStream)
{
if ($result instanceof Promise) {
return $result->then(function($actualResult) use ($response, $writableBodyStream) {
return $result->then(function ($actualResult) use ($response, $writableBodyStream) {
return $this->doConvertToPsrResponse($actualResult, $response, $writableBodyStream);
});
} else {
return $this->doConvertToPsrResponse($result, $response, $writableBodyStream);
}
return $this->doConvertToPsrResponse($result, $response, $writableBodyStream);
}
private function doConvertToPsrResponse($result, ResponseInterface $response, StreamInterface $writableBodyStream)