diff --git a/src/Server/Helper.php b/src/Server/Helper.php index eb271ce..a3e032c 100644 --- a/src/Server/Helper.php +++ b/src/Server/Helper.php @@ -223,7 +223,7 @@ class Helper { $root = $config->getRootValue(); - if (is_callable($root)) { + if ($root instanceof \Closure) { $root = $root($params, $doc, $operationType); } @@ -241,7 +241,7 @@ class Helper { $context = $config->getContext(); - if (is_callable($context)) { + if ($context instanceof \Closure) { $context = $context($params, $doc, $operationType); } @@ -302,14 +302,43 @@ class Helper * * @param ServerRequestInterface $request * @return array|Helper + * @throws RequestError */ public function parsePsrRequest(ServerRequestInterface $request) { - $contentType = $request->getHeader('content-type'); - if (isset($contentType[0]) && $contentType[0] === 'application/graphql') { - $bodyParams = ['query' => $request->getBody()->getContents()]; + if ($request->getMethod() === 'GET') { + $bodyParams = []; } else { - $bodyParams = $request->getParsedBody(); + $contentType = $request->getHeader('content-type'); + + 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 (null === $bodyParams) { + throw new InvariantViolation( + "PSR request is expected to provide parsed body for \"application/json\" requests but got null" + ); + } + + if (!is_array($bodyParams)) { + throw new RequestError( + "GraphQL Server expects JSON object or array, but got " . + Utils::printSafeJson($bodyParams) + ); + } + } else { + $bodyParams = $request->getParsedBody(); + + if (!is_array($bodyParams)) { + throw new RequestError("Unexpected content type: " . Utils::printSafeJson($contentType[0])); + } + } } return $this->parseRequestParams( @@ -473,8 +502,8 @@ class Helper */ private function resolveHttpStatus($result) { - if (is_array($result)) { - Utils::each($tmp, function ($executionResult, $index) { + 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", diff --git a/src/Server/ServerConfig.php b/src/Server/ServerConfig.php index f8334f2..d211233 100644 --- a/src/Server/ServerConfig.php +++ b/src/Server/ServerConfig.php @@ -30,12 +30,12 @@ class ServerConfig private $schema; /** - * @var mixed|callable + * @var mixed|\Closure */ private $context; /** - * @var mixed|callable + * @var mixed|\Closure */ private $rootValue; @@ -83,7 +83,7 @@ class ServerConfig } /** - * @param mixed|callable $context + * @param mixed|\Closure $context * @return $this */ public function setContext($context) @@ -93,7 +93,7 @@ class ServerConfig } /** - * @param mixed|callable $rootValue + * @param mixed|\Closure $rootValue * @return $this */ public function setRootValue($rootValue) diff --git a/src/Utils/AST.php b/src/Utils/AST.php index 9da879f..3ea0346 100644 --- a/src/Utils/AST.php +++ b/src/Utils/AST.php @@ -342,7 +342,7 @@ class AST */ public static function getOperation(DocumentNode $document, $operationName = null) { - if (is_array($document->definitions)) { + if ($document->definitions) { foreach ($document->definitions as $def) { if ($def instanceof OperationDefinitionNode) { if (!$operationName || (isset($def->name->value) && $def->name->value === $operationName)) { diff --git a/tests/Server/Psr7/PsrRequestStub.php b/tests/Server/Psr7/PsrRequestStub.php new file mode 100644 index 0000000..6a912af --- /dev/null +++ b/tests/Server/Psr7/PsrRequestStub.php @@ -0,0 +1,606 @@ +getHeaders() as $name => $values) { + * echo $name . ": " . implode(", ", $values); + * } + * + * // Emit headers iteratively: + * foreach ($message->getHeaders() as $name => $values) { + * foreach ($values as $value) { + * header(sprintf('%s: %s', $name, $value), false); + * } + * } + * + * While header names are not case-sensitive, getHeaders() will preserve the + * exact case in which headers were originally specified. + * + * @return string[][] Returns an associative array of the message's headers. Each + * key MUST be a header name, and each value MUST be an array of strings + * for that header. + */ + public function getHeaders() + { + throw new \Exception("Not implemented"); + } + + /** + * Checks if a header exists by the given case-insensitive name. + * + * @param string $name Case-insensitive header field name. + * @return bool Returns true if any header names match the given header + * name using a case-insensitive string comparison. Returns false if + * no matching header name is found in the message. + */ + public function hasHeader($name) + { + throw new \Exception("Not implemented"); + } + + /** + * Retrieves a message header value by the given case-insensitive name. + * + * This method returns an array of all the header values of the given + * case-insensitive header name. + * + * If the header does not appear in the message, this method MUST return an + * empty array. + * + * @param string $name Case-insensitive header field name. + * @return string[] An array of string values as provided for the given + * header. If the header does not appear in the message, this method MUST + * return an empty array. + */ + public function getHeader($name) + { + $name = strtolower($name); + return isset($this->headers[$name]) ? $this->headers[$name] : []; + } + + /** + * Retrieves a comma-separated string of the values for a single header. + * + * This method returns all of the header values of the given + * case-insensitive header name as a string concatenated together using + * a comma. + * + * NOTE: Not all header values may be appropriately represented using + * comma concatenation. For such headers, use getHeader() instead + * and supply your own delimiter when concatenating. + * + * If the header does not appear in the message, this method MUST return + * an empty string. + * + * @param string $name Case-insensitive header field name. + * @return string A string of values as provided for the given header + * concatenated together using a comma. If the header does not appear in + * the message, this method MUST return an empty string. + */ + public function getHeaderLine($name) + { + throw new \Exception("Not implemented"); + } + + /** + * Return an instance with the provided value replacing the specified header. + * + * While header names are case-insensitive, the casing of the header will + * be preserved by this function, and returned from getHeaders(). + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * new and/or updated header and value. + * + * @param string $name Case-insensitive header field name. + * @param string|string[] $value Header value(s). + * @return static + * @throws \InvalidArgumentException for invalid header names or values. + */ + public function withHeader($name, $value) + { + throw new \Exception("Not implemented"); + } + + /** + * Return an instance with the specified header appended with the given value. + * + * Existing values for the specified header will be maintained. The new + * value(s) will be appended to the existing list. If the header did not + * exist previously, it will be added. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * new header and/or value. + * + * @param string $name Case-insensitive header field name to add. + * @param string|string[] $value Header value(s). + * @return static + * @throws \InvalidArgumentException for invalid header names or values. + */ + public function withAddedHeader($name, $value) + { + throw new \Exception("Not implemented"); + } + + /** + * Return an instance without the specified header. + * + * Header resolution MUST be done without case-sensitivity. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that removes + * the named header. + * + * @param string $name Case-insensitive header field name to remove. + * @return static + */ + public function withoutHeader($name) + { + throw new \Exception("Not implemented"); + } + + /** + * Gets the body of the message. + * + * @return StreamInterface Returns the body as a stream. + */ + public function getBody() + { + return $this->body; + } + + /** + * Return an instance with the specified message body. + * + * The body MUST be a StreamInterface object. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return a new instance that has the + * new body stream. + * + * @param StreamInterface $body Body. + * @return static + * @throws \InvalidArgumentException When the body is not valid. + */ + public function withBody(StreamInterface $body) + { + throw new \Exception("Not implemented"); + } + + /** + * Retrieves the message's request target. + * + * Retrieves the message's request-target either as it will appear (for + * clients), as it appeared at request (for servers), or as it was + * specified for the instance (see withRequestTarget()). + * + * In most cases, this will be the origin-form of the composed URI, + * unless a value was provided to the concrete implementation (see + * withRequestTarget() below). + * + * If no URI is available, and no request-target has been specifically + * provided, this method MUST return the string "/". + * + * @return string + */ + public function getRequestTarget() + { + throw new \Exception("Not implemented"); + } + + /** + * Return an instance with the specific request-target. + * + * If the request needs a non-origin-form request-target — e.g., for + * specifying an absolute-form, authority-form, or asterisk-form — + * this method may be used to create an instance with the specified + * request-target, verbatim. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * changed request target. + * + * @link http://tools.ietf.org/html/rfc7230#section-5.3 (for the various + * request-target forms allowed in request messages) + * @param mixed $requestTarget + * @return static + */ + public function withRequestTarget($requestTarget) + { + throw new \Exception("Not implemented"); + } + + /** + * Retrieves the HTTP method of the request. + * + * @return string Returns the request method. + */ + public function getMethod() + { + return $this->method; + } + + /** + * Return an instance with the provided HTTP method. + * + * While HTTP method names are typically all uppercase characters, HTTP + * method names are case-sensitive and thus implementations SHOULD NOT + * modify the given string. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * changed request method. + * + * @param string $method Case-sensitive method. + * @return static + * @throws \InvalidArgumentException for invalid HTTP methods. + */ + public function withMethod($method) + { + throw new \Exception("Not implemented"); + } + + /** + * Retrieves the URI instance. + * + * This method MUST return a UriInterface instance. + * + * @link http://tools.ietf.org/html/rfc3986#section-4.3 + * @return UriInterface Returns a UriInterface instance + * representing the URI of the request. + */ + public function getUri() + { + // TODO: Implement getUri() method. + } + + /** + * Returns an instance with the provided URI. + * + * This method MUST update the Host header of the returned request by + * default if the URI contains a host component. If the URI does not + * contain a host component, any pre-existing Host header MUST be carried + * over to the returned request. + * + * You can opt-in to preserving the original state of the Host header by + * setting `$preserveHost` to `true`. When `$preserveHost` is set to + * `true`, this method interacts with the Host header in the following ways: + * + * - If the Host header is missing or empty, and the new URI contains + * a host component, this method MUST update the Host header in the returned + * request. + * - If the Host header is missing or empty, and the new URI does not contain a + * host component, this method MUST NOT update the Host header in the returned + * request. + * - If a Host header is present and non-empty, this method MUST NOT update + * the Host header in the returned request. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * new UriInterface instance. + * + * @link http://tools.ietf.org/html/rfc3986#section-4.3 + * @param UriInterface $uri New request URI to use. + * @param bool $preserveHost Preserve the original state of the Host header. + * @return static + */ + public function withUri(UriInterface $uri, $preserveHost = false) + { + throw new \Exception("Not implemented"); + } + + /** + * Retrieve server parameters. + * + * Retrieves data related to the incoming request environment, + * typically derived from PHP's $_SERVER superglobal. The data IS NOT + * REQUIRED to originate from $_SERVER. + * + * @return array + */ + public function getServerParams() + { + throw new \Exception("Not implemented"); + } + + /** + * Retrieve cookies. + * + * Retrieves cookies sent by the client to the server. + * + * The data MUST be compatible with the structure of the $_COOKIE + * superglobal. + * + * @return array + */ + public function getCookieParams() + { + throw new \Exception("Not implemented"); + } + + /** + * Return an instance with the specified cookies. + * + * The data IS NOT REQUIRED to come from the $_COOKIE superglobal, but MUST + * be compatible with the structure of $_COOKIE. Typically, this data will + * be injected at instantiation. + * + * This method MUST NOT update the related Cookie header of the request + * instance, nor related values in the server params. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * updated cookie values. + * + * @param array $cookies Array of key/value pairs representing cookies. + * @return static + */ + public function withCookieParams(array $cookies) + { + throw new \Exception("Not implemented"); + } + + /** + * Retrieve query string arguments. + * + * Retrieves the deserialized query string arguments, if any. + * + * Note: the query params might not be in sync with the URI or server + * params. If you need to ensure you are only getting the original + * values, you may need to parse the query string from `getUri()->getQuery()` + * or from the `QUERY_STRING` server param. + * + * @return array + */ + public function getQueryParams() + { + return $this->queryParams; + } + + /** + * Return an instance with the specified query string arguments. + * + * These values SHOULD remain immutable over the course of the incoming + * request. They MAY be injected during instantiation, such as from PHP's + * $_GET superglobal, or MAY be derived from some other value such as the + * URI. In cases where the arguments are parsed from the URI, the data + * MUST be compatible with what PHP's parse_str() would return for + * purposes of how duplicate query parameters are handled, and how nested + * sets are handled. + * + * Setting query string arguments MUST NOT change the URI stored by the + * request, nor the values in the server params. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * updated query string arguments. + * + * @param array $query Array of query string arguments, typically from + * $_GET. + * @return static + */ + public function withQueryParams(array $query) + { + throw new \Exception("Not implemented"); + } + + /** + * Retrieve normalized file upload data. + * + * This method returns upload metadata in a normalized tree, with each leaf + * an instance of Psr\Http\Message\UploadedFileInterface. + * + * These values MAY be prepared from $_FILES or the message body during + * instantiation, or MAY be injected via withUploadedFiles(). + * + * @return array An array tree of UploadedFileInterface instances; an empty + * array MUST be returned if no data is present. + */ + public function getUploadedFiles() + { + throw new \Exception("Not implemented"); + } + + /** + * Create a new instance with the specified uploaded files. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * updated body parameters. + * + * @param array $uploadedFiles An array tree of UploadedFileInterface instances. + * @return static + * @throws \InvalidArgumentException if an invalid structure is provided. + */ + public function withUploadedFiles(array $uploadedFiles) + { + throw new \Exception("Not implemented"); + } + + /** + * Retrieve any parameters provided in the request body. + * + * If the request Content-Type is either application/x-www-form-urlencoded + * or multipart/form-data, and the request method is POST, this method MUST + * return the contents of $_POST. + * + * Otherwise, this method may return any results of deserializing + * the request body content; as parsing returns structured content, the + * potential types MUST be arrays or objects only. A null value indicates + * the absence of body content. + * + * @return null|array|object The deserialized body parameters, if any. + * These will typically be an array or object. + */ + public function getParsedBody() + { + return $this->parsedBody; + } + + /** + * Return an instance with the specified body parameters. + * + * These MAY be injected during instantiation. + * + * If the request Content-Type is either application/x-www-form-urlencoded + * or multipart/form-data, and the request method is POST, use this method + * ONLY to inject the contents of $_POST. + * + * The data IS NOT REQUIRED to come from $_POST, but MUST be the results of + * deserializing the request body content. Deserialization/parsing returns + * structured data, and, as such, this method ONLY accepts arrays or objects, + * or a null value if nothing was available to parse. + * + * As an example, if content negotiation determines that the request data + * is a JSON payload, this method could be used to create a request + * instance with the deserialized parameters. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * updated body parameters. + * + * @param null|array|object $data The deserialized body data. This will + * typically be in an array or object. + * @return static + * @throws \InvalidArgumentException if an unsupported argument type is + * provided. + */ + public function withParsedBody($data) + { + throw new \Exception("Not implemented"); + } + + /** + * Retrieve attributes derived from the request. + * + * The request "attributes" may be used to allow injection of any + * parameters derived from the request: e.g., the results of path + * match operations; the results of decrypting cookies; the results of + * deserializing non-form-encoded message bodies; etc. Attributes + * will be application and request specific, and CAN be mutable. + * + * @return array Attributes derived from the request. + */ + public function getAttributes() + { + throw new \Exception("Not implemented"); + } + + /** + * Retrieve a single derived request attribute. + * + * Retrieves a single derived request attribute as described in + * getAttributes(). If the attribute has not been previously set, returns + * the default value as provided. + * + * This method obviates the need for a hasAttribute() method, as it allows + * specifying a default value to return if the attribute is not found. + * + * @see getAttributes() + * @param string $name The attribute name. + * @param mixed $default Default value to return if the attribute does not exist. + * @return mixed + */ + public function getAttribute($name, $default = null) + { + throw new \Exception("Not implemented"); + } + + /** + * Return an instance with the specified derived request attribute. + * + * This method allows setting a single derived request attribute as + * described in getAttributes(). + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * updated attribute. + * + * @see getAttributes() + * @param string $name The attribute name. + * @param mixed $value The value of the attribute. + * @return static + */ + public function withAttribute($name, $value) + { + throw new \Exception("Not implemented"); + } + + /** + * Return an instance that removes the specified derived request attribute. + * + * This method allows removing a single derived request attribute as + * described in getAttributes(). + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that removes + * the attribute. + * + * @see getAttributes() + * @param string $name The attribute name. + * @return static + */ + public function withoutAttribute($name) + { + throw new \Exception("Not implemented"); + } +} diff --git a/tests/Server/Psr7/PsrResponseStub.php b/tests/Server/Psr7/PsrResponseStub.php new file mode 100644 index 0000000..01201f2 --- /dev/null +++ b/tests/Server/Psr7/PsrResponseStub.php @@ -0,0 +1,279 @@ +getHeaders() as $name => $values) { + * echo $name . ": " . implode(", ", $values); + * } + * + * // Emit headers iteratively: + * foreach ($message->getHeaders() as $name => $values) { + * foreach ($values as $value) { + * header(sprintf('%s: %s', $name, $value), false); + * } + * } + * + * While header names are not case-sensitive, getHeaders() will preserve the + * exact case in which headers were originally specified. + * + * @return string[][] Returns an associative array of the message's headers. Each + * key MUST be a header name, and each value MUST be an array of strings + * for that header. + */ + public function getHeaders() + { + throw new \Exception("Not implemented"); + } + + /** + * Checks if a header exists by the given case-insensitive name. + * + * @param string $name Case-insensitive header field name. + * @return bool Returns true if any header names match the given header + * name using a case-insensitive string comparison. Returns false if + * no matching header name is found in the message. + */ + public function hasHeader($name) + { + throw new \Exception("Not implemented"); + } + + /** + * Retrieves a message header value by the given case-insensitive name. + * + * This method returns an array of all the header values of the given + * case-insensitive header name. + * + * If the header does not appear in the message, this method MUST return an + * empty array. + * + * @param string $name Case-insensitive header field name. + * @return string[] An array of string values as provided for the given + * header. If the header does not appear in the message, this method MUST + * return an empty array. + */ + public function getHeader($name) + { + throw new \Exception("Not implemented"); + } + + /** + * Retrieves a comma-separated string of the values for a single header. + * + * This method returns all of the header values of the given + * case-insensitive header name as a string concatenated together using + * a comma. + * + * NOTE: Not all header values may be appropriately represented using + * comma concatenation. For such headers, use getHeader() instead + * and supply your own delimiter when concatenating. + * + * If the header does not appear in the message, this method MUST return + * an empty string. + * + * @param string $name Case-insensitive header field name. + * @return string A string of values as provided for the given header + * concatenated together using a comma. If the header does not appear in + * the message, this method MUST return an empty string. + */ + public function getHeaderLine($name) + { + throw new \Exception("Not implemented"); + } + + /** + * Return an instance with the provided value replacing the specified header. + * + * While header names are case-insensitive, the casing of the header will + * be preserved by this function, and returned from getHeaders(). + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * new and/or updated header and value. + * + * @param string $name Case-insensitive header field name. + * @param string|string[] $value Header value(s). + * @return static + * @throws \InvalidArgumentException for invalid header names or values. + */ + public function withHeader($name, $value) + { + $tmp = clone $this; + $tmp->headers[$name][] = $value; + return $tmp; + } + + /** + * Return an instance with the specified header appended with the given value. + * + * Existing values for the specified header will be maintained. The new + * value(s) will be appended to the existing list. If the header did not + * exist previously, it will be added. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * new header and/or value. + * + * @param string $name Case-insensitive header field name to add. + * @param string|string[] $value Header value(s). + * @return static + * @throws \InvalidArgumentException for invalid header names or values. + */ + public function withAddedHeader($name, $value) + { + throw new \Exception("Not implemented"); + } + + /** + * Return an instance without the specified header. + * + * Header resolution MUST be done without case-sensitivity. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that removes + * the named header. + * + * @param string $name Case-insensitive header field name to remove. + * @return static + */ + public function withoutHeader($name) + { + throw new \Exception("Not implemented"); + } + + /** + * Gets the body of the message. + * + * @return StreamInterface Returns the body as a stream. + */ + public function getBody() + { + throw new \Exception("Not implemented"); + } + + /** + * Return an instance with the specified message body. + * + * The body MUST be a StreamInterface object. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return a new instance that has the + * new body stream. + * + * @param StreamInterface $body Body. + * @return static + * @throws \InvalidArgumentException When the body is not valid. + */ + public function withBody(StreamInterface $body) + { + $tmp = clone $this; + $tmp->body = $body; + return $tmp; + } + + /** + * Gets the response status code. + * + * The status code is a 3-digit integer result code of the server's attempt + * to understand and satisfy the request. + * + * @return int Status code. + */ + public function getStatusCode() + { + throw new \Exception("Not implemented"); + } + + /** + * Return an instance with the specified status code and, optionally, reason phrase. + * + * If no reason phrase is specified, implementations MAY choose to default + * to the RFC 7231 or IANA recommended reason phrase for the response's + * status code. + * + * This method MUST be implemented in such a way as to retain the + * immutability of the message, and MUST return an instance that has the + * updated status and reason phrase. + * + * @link http://tools.ietf.org/html/rfc7231#section-6 + * @link http://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml + * @param int $code The 3-digit integer result code to set. + * @param string $reasonPhrase The reason phrase to use with the + * provided status code; if none is provided, implementations MAY + * use the defaults as suggested in the HTTP specification. + * @return static + * @throws \InvalidArgumentException For invalid status code arguments. + */ + public function withStatus($code, $reasonPhrase = '') + { + $tmp = clone $this; + $tmp->statusCode = $code; + return $tmp; + } + + /** + * Gets the response reason phrase associated with the status code. + * + * Because a reason phrase is not a required element in a response + * status line, the reason phrase value MAY be null. Implementations MAY + * choose to return the default RFC 7231 recommended reason phrase (or those + * listed in the IANA HTTP Status Code Registry) for the response's + * status code. + * + * @link http://tools.ietf.org/html/rfc7231#section-6 + * @link http://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml + * @return string Reason phrase; must return an empty string if none present. + */ + public function getReasonPhrase() + { + throw new \Exception("Not implemented"); + } +} \ No newline at end of file diff --git a/tests/Server/Psr7/PsrStreamStub.php b/tests/Server/Psr7/PsrStreamStub.php new file mode 100644 index 0000000..50adecf --- /dev/null +++ b/tests/Server/Psr7/PsrStreamStub.php @@ -0,0 +1,200 @@ +content; + } + + /** + * Closes the stream and any underlying resources. + * + * @return void + */ + public function close() + { + throw new \Exception("Not implemented"); + } + + /** + * Separates any underlying resources from the stream. + * + * After the stream has been detached, the stream is in an unusable state. + * + * @return resource|null Underlying PHP stream, if any + */ + public function detach() + { + throw new \Exception("Not implemented"); + } + + /** + * Get the size of the stream if known. + * + * @return int|null Returns the size in bytes if known, or null if unknown. + */ + public function getSize() + { + return strlen($this->content?:''); + } + + /** + * Returns the current position of the file read/write pointer + * + * @return int Position of the file pointer + * @throws \RuntimeException on error. + */ + public function tell() + { + throw new \Exception("Not implemented"); + } + + /** + * Returns true if the stream is at the end of the stream. + * + * @return bool + */ + public function eof() + { + throw new \Exception("Not implemented"); + } + + /** + * Returns whether or not the stream is seekable. + * + * @return bool + */ + public function isSeekable() + { + throw new \Exception("Not implemented"); + } + + /** + * Seek to a position in the stream. + * + * @link http://www.php.net/manual/en/function.fseek.php + * @param int $offset Stream offset + * @param int $whence Specifies how the cursor position will be calculated + * based on the seek offset. Valid values are identical to the built-in + * PHP $whence values for `fseek()`. SEEK_SET: Set position equal to + * offset bytes SEEK_CUR: Set position to current location plus offset + * SEEK_END: Set position to end-of-stream plus offset. + * @throws \RuntimeException on failure. + */ + public function seek($offset, $whence = SEEK_SET) + { + throw new \Exception("Not implemented"); + } + + /** + * Seek to the beginning of the stream. + * + * If the stream is not seekable, this method will raise an exception; + * otherwise, it will perform a seek(0). + * + * @see seek() + * @link http://www.php.net/manual/en/function.fseek.php + * @throws \RuntimeException on failure. + */ + public function rewind() + { + throw new \Exception("Not implemented"); + } + + /** + * Returns whether or not the stream is writable. + * + * @return bool + */ + public function isWritable() + { + return true; + } + + /** + * Write data to the stream. + * + * @param string $string The string that is to be written. + * @return int Returns the number of bytes written to the stream. + * @throws \RuntimeException on failure. + */ + public function write($string) + { + $this->content = $string; + return strlen($string); + } + + /** + * Returns whether or not the stream is readable. + * + * @return bool + */ + public function isReadable() + { + throw new \Exception("Not implemented"); + } + + /** + * Read data from the stream. + * + * @param int $length Read up to $length bytes from the object and return + * them. Fewer than $length bytes may be returned if underlying stream + * call returns fewer bytes. + * @return string Returns the data read from the stream, or an empty string + * if no bytes are available. + * @throws \RuntimeException if an error occurs. + */ + public function read($length) + { + throw new \Exception("Not implemented"); + } + + /** + * Returns the remaining contents in a string + * + * @return string + * @throws \RuntimeException if unable to read or an error occurs while + * reading. + */ + public function getContents() + { + return $this->content; + } + + /** + * Get stream metadata as an associative array or retrieve a specific key. + * + * The keys returned are identical to the keys returned from PHP's + * stream_get_meta_data() function. + * + * @link http://php.net/manual/en/function.stream-get-meta-data.php + * @param string $key Specific metadata to retrieve. + * @return array|mixed|null Returns an associative array if no key is + * provided. Returns a specific key value if a key is provided and the + * value is found, or null if the key is not found. + */ + public function getMetadata($key = null) + { + throw new \Exception("Not implemented"); + } +} diff --git a/tests/Server/PsrResponseTest.php b/tests/Server/PsrResponseTest.php new file mode 100644 index 0000000..6c45335 --- /dev/null +++ b/tests/Server/PsrResponseTest.php @@ -0,0 +1,24 @@ + 'value']); + $stream = new PsrStreamStub(); + $psrResponse = new PsrResponseStub(); + + $helper = new Helper(); + + /** @var PsrResponseStub $resp */ + $resp = $helper->toPsrResponse($result, $psrResponse, $stream); + $this->assertSame(json_encode($result), $resp->body->content); + $this->assertSame(['Content-Type' => ['application/json']], $resp->headers); + } +} diff --git a/tests/Server/QueryExecutionTest.php b/tests/Server/QueryExecutionTest.php index ca913b5..92ed101 100644 --- a/tests/Server/QueryExecutionTest.php +++ b/tests/Server/QueryExecutionTest.php @@ -6,6 +6,7 @@ use GraphQL\Error\Error; use GraphQL\Error\InvariantViolation; use GraphQL\Error\UserError; use GraphQL\Executor\ExecutionResult; +use GraphQL\Language\AST\DocumentNode; use GraphQL\Language\Parser; use GraphQL\Schema; use GraphQL\Server\Helper; @@ -230,6 +231,27 @@ class QueryExecutionTest extends \PHPUnit_Framework_TestCase $this->assertTrue($called); } + public function testAllowsValidationRulesAsClosure() + { + $called = false; + $params = $doc = $operationType = null; + + $this->config->setValidationRules(function($p, $d, $o) use (&$called, &$params, &$doc, &$operationType) { + $called = true; + $params = $p; + $doc = $d; + $operationType = $o; + return []; + }); + + $this->assertFalse($called); + $this->executeQuery('{f1}'); + $this->assertTrue($called); + $this->assertInstanceOf(OperationParams::class, $params); + $this->assertInstanceOf(DocumentNode::class, $doc); + $this->assertEquals('query', $operationType); + } + public function testAllowsDifferentValidationRulesDependingOnOperation() { $q1 = '{f1}'; @@ -587,6 +609,46 @@ class QueryExecutionTest extends \PHPUnit_Framework_TestCase ); } + public function testAllowsContextAsClosure() + { + $called = false; + $params = $doc = $operationType = null; + + $this->config->setContext(function($p, $d, $o) use (&$called, &$params, &$doc, &$operationType) { + $called = true; + $params = $p; + $doc = $d; + $operationType = $o; + }); + + $this->assertFalse($called); + $this->executeQuery('{f1}'); + $this->assertTrue($called); + $this->assertInstanceOf(OperationParams::class, $params); + $this->assertInstanceOf(DocumentNode::class, $doc); + $this->assertEquals('query', $operationType); + } + + public function testAllowsRootValueAsClosure() + { + $called = false; + $params = $doc = $operationType = null; + + $this->config->setRootValue(function($p, $d, $o) use (&$called, &$params, &$doc, &$operationType) { + $called = true; + $params = $p; + $doc = $d; + $operationType = $o; + }); + + $this->assertFalse($called); + $this->executeQuery('{f1}'); + $this->assertTrue($called); + $this->assertInstanceOf(OperationParams::class, $params); + $this->assertInstanceOf(DocumentNode::class, $doc); + $this->assertEquals('query', $operationType); + } + private function executePersistedQuery($queryId, $variables = null) { $op = OperationParams::create(['queryId' => $queryId, 'variables' => $variables]); diff --git a/tests/Server/RequestParsingTest.php b/tests/Server/RequestParsingTest.php index 8a100f4..9b8e74c 100644 --- a/tests/Server/RequestParsingTest.php +++ b/tests/Server/RequestParsingTest.php @@ -6,16 +6,23 @@ use GraphQL\Error\InvariantViolation; use GraphQL\Server\Helper; use GraphQL\Server\OperationParams; use GraphQL\Server\RequestError; +use GraphQL\Tests\Server\Psr7\PsrRequestStub; +use GraphQL\Tests\Server\Psr7\PsrStreamStub; class RequestParsingTest extends \PHPUnit_Framework_TestCase { public function testParsesGraphqlRequest() { $query = '{my query}'; - $parsedBody = $this->parseRawRequest('application/graphql', $query); + $parsed = [ + 'raw' => $this->parseRawRequest('application/graphql', $query), + 'psr' => $this->parsePsrRequest('application/graphql', $query) + ]; - $this->assertValidOperationParams($parsedBody, $query); - $this->assertFalse($parsedBody->isReadOnly()); + foreach ($parsed as $source => $parsedBody) { + $this->assertValidOperationParams($parsedBody, $query, null, null, null, $source); + $this->assertFalse($parsedBody->isReadOnly(), $source); + } } public function testParsesUrlencodedRequest() @@ -29,10 +36,15 @@ class RequestParsingTest extends \PHPUnit_Framework_TestCase 'variables' => $variables, 'operation' => $operation ]; + $parsed = [ + 'raw' => $this->parseRawFormUrlencodedRequest($post), + 'psr' => $this->parsePsrFormUrlEncodedRequest($post) + ]; - $parsedBody = $this->parseFormUrlencodedRequest($post); - $this->assertValidOperationParams($parsedBody, $query, null, $variables, $operation); - $this->assertFalse($parsedBody->isReadOnly()); + foreach ($parsed as $method => $parsedBody) { + $this->assertValidOperationParams($parsedBody, $query, null, $variables, $operation, $method); + $this->assertFalse($parsedBody->isReadOnly(), $method); + } } public function testParsesGetRequest() @@ -46,10 +58,15 @@ class RequestParsingTest extends \PHPUnit_Framework_TestCase 'variables' => $variables, 'operation' => $operation ]; + $parsed = [ + 'raw' => $this->parseRawGetRequest($get), + 'psr' => $this->parsePsrGetRequest($get) + ]; - $parsedBody = $this->parseGetRequest($get); - $this->assertValidOperationParams($parsedBody, $query, null, $variables, $operation); - $this->assertTrue($parsedBody->isReadonly()); + foreach ($parsed as $method => $parsedBody) { + $this->assertValidOperationParams($parsedBody, $query, null, $variables, $operation, $method); + $this->assertTrue($parsedBody->isReadonly(), $method); + } } public function testParsesJSONRequest() @@ -63,9 +80,14 @@ class RequestParsingTest extends \PHPUnit_Framework_TestCase 'variables' => $variables, 'operation' => $operation ]; - $parsedBody = $this->parseRawRequest('application/json', json_encode($body)); - $this->assertValidOperationParams($parsedBody, $query, null, $variables, $operation); - $this->assertFalse($parsedBody->isReadOnly()); + $parsed = [ + 'raw' => $this->parseRawRequest('application/json', json_encode($body)), + 'psr' => $this->parsePsrRequest('application/json', json_encode($body)) + ]; + foreach ($parsed as $method => $parsedBody) { + $this->assertValidOperationParams($parsedBody, $query, null, $variables, $operation, $method); + $this->assertFalse($parsedBody->isReadOnly(), $method); + } } public function testParsesVariablesAsJSON() @@ -79,9 +101,14 @@ class RequestParsingTest extends \PHPUnit_Framework_TestCase 'variables' => json_encode($variables), 'operation' => $operation ]; - $parsedBody = $this->parseRawRequest('application/json', json_encode($body)); - $this->assertValidOperationParams($parsedBody, $query, null, $variables, $operation); - $this->assertFalse($parsedBody->isReadOnly()); + $parsed = [ + 'raw' => $this->parseRawRequest('application/json', json_encode($body)), + 'psr' => $this->parsePsrRequest('application/json', json_encode($body)) + ]; + foreach ($parsed as $method => $parsedBody) { + $this->assertValidOperationParams($parsedBody, $query, null, $variables, $operation, $method); + $this->assertFalse($parsedBody->isReadOnly(), $method); + } } public function testIgnoresInvalidVariablesJson() @@ -95,9 +122,14 @@ class RequestParsingTest extends \PHPUnit_Framework_TestCase 'variables' => $variables, 'operation' => $operation ]; - $parsedBody = $this->parseRawRequest('application/json', json_encode($body)); - $this->assertValidOperationParams($parsedBody, $query, null, $variables, $operation); - $this->assertFalse($parsedBody->isReadOnly()); + $parsed = [ + 'raw' => $this->parseRawRequest('application/json', json_encode($body)), + 'psr' => $this->parsePsrRequest('application/json', json_encode($body)), + ]; + foreach ($parsed as $method => $parsedBody) { + $this->assertValidOperationParams($parsedBody, $query, null, $variables, $operation, $method); + $this->assertFalse($parsedBody->isReadOnly(), $method); + } } public function testParsesBatchJSONRequest() @@ -114,47 +146,115 @@ class RequestParsingTest extends \PHPUnit_Framework_TestCase 'operation' => 'op2' ], ]; - - $parsedBody = $this->parseRawRequest('application/json', json_encode($body)); - $this->assertInternalType('array', $parsedBody); - $this->assertCount(2, $parsedBody); - - $this->assertValidOperationParams($parsedBody[0], $body[0]['query'], null, $body[0]['variables'], $body[0]['operation']); - $this->assertValidOperationParams($parsedBody[1], null, $body[1]['queryId'], $body[1]['variables'], $body[1]['operation']); + $parsed = [ + 'raw' => $this->parseRawRequest('application/json', json_encode($body)), + 'psr' => $this->parsePsrRequest('application/json', json_encode($body)) + ]; + foreach ($parsed as $method => $parsedBody) { + $this->assertInternalType('array', $parsedBody, $method); + $this->assertCount(2, $parsedBody, $method); + $this->assertValidOperationParams($parsedBody[0], $body[0]['query'], null, $body[0]['variables'], $body[0]['operation'], $method); + $this->assertValidOperationParams($parsedBody[1], null, $body[1]['queryId'], $body[1]['variables'], $body[1]['operation'], $method); + } } - public function testFailsParsingInvalidJsonRequest() + public function testFailsParsingInvalidRawJsonRequest() { $body = 'not really{} a json'; - $this->setExpectedException(RequestError::class, 'Could not parse JSON: Syntax error'); - $this->parseRawRequest('application/json', $body); + try { + $this->parseRawRequest('application/json', $body); + $this->fail('Expected exception not thrown'); + } catch (RequestError $e) { + $this->assertEquals('Could not parse JSON: Syntax error', $e->getMessage()); + } + + try { + $this->parsePsrRequest('application/json', $body); + $this->fail('Expected exception not thrown'); + } catch (InvariantViolation $e) { + // Expecting parsing exception to be thrown somewhere else: + $this->assertEquals( + 'PSR request is expected to provide parsed body for "application/json" requests but got null', + $e->getMessage() + ); + } } + // There is no equivalent for psr request, because it should throw + public function testFailsParsingNonArrayOrObjectJsonRequest() { $body = '"str"'; - $this->setExpectedException(RequestError::class, 'GraphQL Server expects JSON object or array, but got "str"'); - $this->parseRawRequest('application/json', $body); + try { + $this->parseRawRequest('application/json', $body); + $this->fail('Expected exception not thrown'); + } catch (RequestError $e) { + $this->assertEquals('GraphQL Server expects JSON object or array, but got "str"', $e->getMessage()); + } + + try { + $this->parsePsrRequest('application/json', $body); + $this->fail('Expected exception not thrown'); + } catch (RequestError $e) { + $this->assertEquals('GraphQL Server expects JSON object or array, but got "str"', $e->getMessage()); + } + } public function testFailsParsingInvalidContentType() { - $this->setExpectedException(RequestError::class, 'Unexpected content type: "not-supported-content-type"'); - $this->parseRawRequest('not-supported-content-type', 'test'); + $contentType = 'not-supported-content-type'; + $body = 'test'; + + try { + $this->parseRawRequest($contentType, $body); + $this->fail('Expected exception not thrown'); + } catch (RequestError $e) { + $this->assertEquals('Unexpected content type: "not-supported-content-type"', $e->getMessage()); + } + + try { + $this->parsePsrRequest($contentType, $body); + $this->fail('Expected exception not thrown'); + } catch (RequestError $e) { + $this->assertEquals('Unexpected content type: "not-supported-content-type"', $e->getMessage()); + } } public function testFailsWithMissingContentType() { - $this->setExpectedException(RequestError::class, 'Missing "Content-Type" header'); - $this->parseRawRequest(null, 'test'); + try { + $this->parseRawRequest(null, 'test'); + $this->fail('Expected exception not thrown'); + } catch (RequestError $e) { + $this->assertEquals('Missing "Content-Type" header', $e->getMessage()); + } + + try { + $this->parsePsrRequest(null, 'test'); + $this->fail('Expected exception not thrown'); + } catch (RequestError $e) { + $this->assertEquals('Missing "Content-Type" header', $e->getMessage()); + } } public function testFailsOnMethodsOtherThanPostOrGet() { - $this->setExpectedException(RequestError::class, 'HTTP Method "PUT" is not supported'); - $this->parseRawRequest(null, 'test', "PUT"); + try { + $this->parseRawRequest('application/json', json_encode([]), "PUT"); + $this->fail('Expected exception not thrown'); + } catch (RequestError $e) { + $this->assertEquals('HTTP Method "PUT" is not supported', $e->getMessage()); + } + + try { + $this->parsePsrRequest('application/json', json_encode([]), "PUT"); + $this->fail('Expected exception not thrown'); + } catch (RequestError $e) { + $this->assertEquals('HTTP Method "PUT" is not supported', $e->getMessage()); + } } /** @@ -175,11 +275,41 @@ class RequestParsingTest extends \PHPUnit_Framework_TestCase }); } + /** + * @param string $contentType + * @param string $content + * @param $method + * + * @return OperationParams|OperationParams[] + */ + private function parsePsrRequest($contentType, $content, $method = 'POST') + { + $psrRequestBody = new PsrStreamStub(); + $psrRequestBody->content = $content; + + $psrRequest = new PsrRequestStub(); + $psrRequest->headers['content-type'] = [$contentType]; + $psrRequest->method = $method; + $psrRequest->body = $psrRequestBody; + + if ($contentType === 'application/json') { + $parsedBody = json_decode($content, true); + $parsedBody = $parsedBody === false ? null : $parsedBody; + } else { + $parsedBody = null; + } + + $psrRequest->parsedBody = $parsedBody; + + $helper = new Helper(); + return $helper->parsePsrRequest($psrRequest); + } + /** * @param array $postValue * @return OperationParams|OperationParams[] */ - private function parseFormUrlencodedRequest($postValue) + private function parseRawFormUrlencodedRequest($postValue) { $_SERVER['CONTENT_TYPE'] = 'application/x-www-form-urlencoded'; $_SERVER['REQUEST_METHOD'] = 'POST'; @@ -191,11 +321,26 @@ class RequestParsingTest extends \PHPUnit_Framework_TestCase }); } + /** + * @param $postValue + * @return array|Helper + */ + private function parsePsrFormUrlEncodedRequest($postValue) + { + $psrRequest = new PsrRequestStub(); + $psrRequest->headers['content-type'] = ['application/x-www-form-urlencoded']; + $psrRequest->method = 'POST'; + $psrRequest->parsedBody = $postValue; + + $helper = new Helper(); + return $helper->parsePsrRequest($psrRequest); + } + /** * @param $getValue * @return OperationParams */ - private function parseGetRequest($getValue) + private function parseRawGetRequest($getValue) { $_SERVER['REQUEST_METHOD'] = 'GET'; $_GET = $getValue; @@ -206,6 +351,20 @@ class RequestParsingTest extends \PHPUnit_Framework_TestCase }); } + /** + * @param $getValue + * @return array|Helper + */ + private function parsePsrGetRequest($getValue) + { + $psrRequest = new PsrRequestStub(); + $psrRequest->method = 'GET'; + $psrRequest->queryParams = $getValue; + + $helper = new Helper(); + return $helper->parsePsrRequest($psrRequest); + } + /** * @param OperationParams $params * @param string $query @@ -213,13 +372,13 @@ class RequestParsingTest extends \PHPUnit_Framework_TestCase * @param array $variables * @param string $operation */ - private function assertValidOperationParams($params, $query, $queryId = null, $variables = null, $operation = null) + private function assertValidOperationParams($params, $query, $queryId = null, $variables = null, $operation = null, $message = '') { - $this->assertInstanceOf(OperationParams::class, $params); + $this->assertInstanceOf(OperationParams::class, $params, $message); - $this->assertSame($query, $params->query); - $this->assertSame($queryId, $params->queryId); - $this->assertSame($variables, $params->variables); - $this->assertSame($operation, $params->operation); + $this->assertSame($query, $params->query, $message); + $this->assertSame($queryId, $params->queryId, $message); + $this->assertSame($variables, $params->variables, $message); + $this->assertSame($operation, $params->operation, $message); } }