diff --git a/.editorconfig b/.editorconfig index 59b337b6..009c31a3 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,27 +1,27 @@ -# top-most EditorConfig file -root = true - -# All PHP files MUST use the Unix LF (linefeed) line ending. -# Code MUST use an indent of 4 spaces, and MUST NOT use tabs for indenting. -# All PHP files MUST end with a single blank line. -# There MUST NOT be trailing whitespace at the end of non-blank lines. -[*] -charset = utf-8 -end_of_line = crlf -insert_final_newline = true -trim_trailing_whitespace = true - -# PHP-Files, Composer.json, MD-Files -[{*.php,composer.json,*.md}] -indent_style = space -indent_size = 4 - -# HTML-Files LESS-Files SASS-Files CSS-Files JS-Files JSON-Files -[{*.html,*.less,*.sass,*.css,*.js,*.json}] -indent_style = tab -indent_size = 4 - -# Gitlab-CI, Travis-CI -[*.yml] -indent_style = space -indent_size = 2 \ No newline at end of file +# top-most EditorConfig file +root = true + +# All PHP files MUST use the Unix LF (linefeed) line ending. +# Code MUST use an indent of 4 spaces, and MUST NOT use tabs for indenting. +# All PHP files MUST end with a single blank line. +# There MUST NOT be trailing whitespace at the end of non-blank lines. +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +# PHP-Files, Composer.json, MD-Files +[{*.php,composer.json,*.md}] +indent_style = space +indent_size = 4 + +# HTML-Files LESS-Files SASS-Files CSS-Files JS-Files JSON-Files +[{*.html,*.less,*.sass,*.css,*.js,*.json}] +indent_style = tab +indent_size = 4 + +# Gitlab-CI, Travis-CI +[*.yml] +indent_style = space +indent_size = 2 diff --git a/composer.json b/composer.json index f0d91a52..677c45e9 100644 --- a/composer.json +++ b/composer.json @@ -1,35 +1,38 @@ -{ - "name": "microsoft/microsoft-graph-core", - "type": "library", - "description": "The Microsoft Graph SDK for PHP", - "homepage": "https://developer.microsoft.com/en-us/graph", - "license": "MIT", - "authors": [ - { - "name": "Microsoft Graph Client Tooling", - "email": "graphtooling@service.microsoft.com", - "role": "Developer" - } - ], - "require": { - "php": "^8.0 || ^7.3", - "guzzlehttp/guzzle": "^6.0 || ^7.0", - "ext-json": "*" - }, - "require-dev": { - "phpunit/phpunit": "^8.0 || ^9.0", - "mikey179/vfsstream": "^1.2", - "phpstan/phpstan": "^0.12.90" - }, - "autoload": { - "psr-4": { - "Microsoft\\Graph\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Microsoft\\Graph\\Test\\": "tests/", - "Microsoft\\Graph\\Http\\Test\\": "tests/Http/" - } - } -} +{ + "name": "microsoft/microsoft-graph-core", + "type": "library", + "description": "The Microsoft Graph SDK for PHP", + "homepage": "https://developer.microsoft.com/en-us/graph", + "license": "MIT", + "authors": [ + { + "name": "Microsoft Graph Client Tooling", + "email": "graphtooling@service.microsoft.com", + "role": "Developer" + } + ], + "require": { + "php": "^8.0 || ^7.3", + "guzzlehttp/guzzle": "^7.0", + "guzzlehttp/psr7": "^2.0", + "php-http/httplug": "^2.2", + "php-http/guzzle7-adapter": "^1.0", + "ext-json": "*" + }, + "require-dev": { + "phpunit/phpunit": "^8.0 || ^9.0", + "mikey179/vfsstream": "^1.2", + "phpstan/phpstan": "^0.12.90" + }, + "autoload": { + "psr-4": { + "Microsoft\\Graph\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Microsoft\\Graph\\Test\\": "tests/", + "Microsoft\\Graph\\Http\\Test\\": "tests/Http/" + } + } +} \ No newline at end of file diff --git a/src/Core/Enum.php b/src/Core/Enum.php index 6ff3f6b1..803d4990 100644 --- a/src/Core/Enum.php +++ b/src/Core/Enum.php @@ -101,4 +101,4 @@ public function value() { return $this->_value; } -} \ No newline at end of file +} diff --git a/src/Core/ExceptionWrapper.php b/src/Core/ExceptionWrapper.php deleted file mode 100644 index 574b79dc..00000000 --- a/src/Core/ExceptionWrapper.php +++ /dev/null @@ -1,77 +0,0 @@ -getResponse(); - - // Safety check for Guzzle < 7.0 - if (!$response) { - return $ex; - } - - /** @see \GuzzleHttp\Exception\RequestException::create() */ - if (preg_match('/^(.+: `.+ .+` resulted in a `.+ .+` response):\n/U', $ex->getMessage(), $match)) { - $message = $match[1]; - - $body = $response->getBody(); - - if (!$body->isSeekable() || !$body->isReadable()) { - return $ex; - } - - $summary = $body->getContents(); - $body->rewind(); - - if ($summary !== '') { - $message .= ":\n{$summary}\n"; - - //return new $ex($message, $ex->getRequest(), $ex->getResponse(), $ex, $ex->getHandlerContext()); - // Better: modify internal message inside original exception object (preserves the stack trace) - (new class() extends \Exception { - public static function overwriteProtectedMessage(\Exception $ex, $message) - { - $ex->message = $message; - } - })::overwriteProtectedMessage($ex, $message); - } - } - - return $ex; - } -} diff --git a/src/Core/GraphConstants.php b/src/Core/GraphConstants.php index a92eb77c..39a79cdb 100644 --- a/src/Core/GraphConstants.php +++ b/src/Core/GraphConstants.php @@ -19,7 +19,6 @@ final class GraphConstants { // These can be overwritten in setters in the Graph object - const API_VERSION = "v1.0"; const REST_ENDPOINT = "https://graph.microsoft.com/"; // Define HTTP request constants diff --git a/src/Core/NationalCloud.php b/src/Core/NationalCloud.php new file mode 100644 index 00000000..e863d5da --- /dev/null +++ b/src/Core/NationalCloud.php @@ -0,0 +1,65 @@ +getConstants(); + foreach ($constants as $constName => $url) { + // Create associative array for O(1) key lookup + $hostname = parse_url($url)["host"]; + self::$hosts[$hostname] = true; + } + } + } +} diff --git a/src/Exception/GraphClientException.php b/src/Exception/GraphClientException.php new file mode 100644 index 00000000..d944d391 --- /dev/null +++ b/src/Exception/GraphClientException.php @@ -0,0 +1,21 @@ +code}]: {$this->message}\n"; + return get_called_class() . ": [{$this->code}]: {$this->message}\n"; } -} \ No newline at end of file +} diff --git a/src/Graph.php b/src/Graph.php deleted file mode 100644 index f32dc7c9..00000000 --- a/src/Graph.php +++ /dev/null @@ -1,184 +0,0 @@ -_apiVersion = GraphConstants::API_VERSION; - $this->_baseUrl = GraphConstants::REST_ENDPOINT; - } - - /** - * Sets the Base URL to call (defaults to https://graph.microsoft.com) - * - * @param string $baseUrl The URL to call - * - * @return Graph object - */ - public function setBaseUrl($baseUrl) - { - $this->_baseUrl = $baseUrl; - return $this; - } - - /** - * Sets the API version to use, e.g. "beta" (defaults to v1.0) - * - * @param string $apiVersion The API version to use - * - * @return Graph object - */ - public function setApiVersion($apiVersion) - { - $this->_apiVersion = $apiVersion; - return $this; - } - - /** - * Sets the access token. A valid access token is required - * to run queries against Graph - * - * @param string $accessToken The user's access token, retrieved from - * MS auth - * - * @return Graph object - */ - public function setAccessToken($accessToken) - { - $this->_accessToken = $accessToken; - return $this; - } - - /** - * Sets the proxy port. This allows you - * to use tools such as Fiddler to view - * requests and responses made with Guzzle - * - * @param string $port The port number to use - * @param bool $verifySSL Whether SSL verification should be enabled - * - * @return Graph object - */ - public function setProxyPort($port, $verifySSL = false) - { - $this->_proxyPort = $port; - $this->_proxyVerifySSL = $verifySSL; - - return $this; - } - - /** - * Creates a new request object with the given Graph information - * - * @param string $requestType The HTTP method to use, e.g. "GET" or "POST" - * @param string $endpoint The Graph endpoint to call - * - * @return GraphRequest The request object, which can be used to - * make queries against Graph - * @throws Exception\GraphException - */ - public function createRequest($requestType, $endpoint) - { - return new GraphRequest( - $requestType, - $endpoint, - $this->_accessToken, - $this->_baseUrl, - $this->_apiVersion, - $this->_proxyPort, - $this->_proxyVerifySSL - ); - } - - /** - * Creates a new collection request object with the given - * Graph information - * - * @param string $requestType The HTTP method to use, e.g. "GET" or "POST" - * @param string $endpoint The Graph endpoint to call - * - * @return GraphCollectionRequest The request object, which can be - * used to make queries against Graph - * @throws Exception\GraphException - */ - public function createCollectionRequest($requestType, $endpoint) - { - return new GraphCollectionRequest( - $requestType, - $endpoint, - $this->_accessToken, - $this->_baseUrl, - $this->_apiVersion, - $this->_proxyPort, - $this->_proxyVerifySSL - ); - } -} diff --git a/src/Http/AbstractGraphClient.php b/src/Http/AbstractGraphClient.php new file mode 100644 index 00000000..20ab94a9 --- /dev/null +++ b/src/Http/AbstractGraphClient.php @@ -0,0 +1,154 @@ +nationalCloud = ($nationalCloud) ?: NationalCloud::GLOBAL; + $this->httpClient = ($httpClient) ?: HttpClientFactory::nationalCloud($nationalCloud)::createAdapter(); + } + + /** + * Sets the access token. A valid access token is required + * to run queries against Graph + * + * @param string $accessToken The user's access token, retrieved from + * MS auth + * + * @return $this object + */ + public function setAccessToken(string $accessToken): self + { + $this->accessToken = $accessToken; + return $this; + } + + /** + * @return string + */ + public function getAccessToken(): string { + return $this->accessToken; + } + + /** + * @return string + */ + public function getNationalCloud(): string + { + return $this->nationalCloud; + } + + /** + * @return HttpClientInterface + */ + public function getHttpClient(): HttpClientInterface + { + return $this->httpClient; + } + + /** + * Creates a new request object with the given Graph information + * + * @param string $requestType The HTTP method to use, e.g. "GET" or "POST" + * @param string $endpoint The Graph endpoint to call + * + * @return GraphRequest The request object, which can be used to + * make queries against Graph + * @throws GraphException + */ + public function createRequest(string $requestType, string $endpoint): GraphRequest + { + return new GraphRequest( + $requestType, + $endpoint, + $this + ); + } + + /** + * Creates a new collection request object with the given + * Graph information + * + * @param string $requestType The HTTP method to use, e.g. "GET" or "POST" + * @param string $endpoint The Graph endpoint to call + * + * @return GraphCollectionRequest The request object, which can be + * used to make queries against Graph + * @throws GraphException + */ + public function createCollectionRequest(string $requestType, string $endpoint): GraphRequest + { + return new GraphCollectionRequest( + $requestType, + $endpoint, + $this + ); + } + + /** + * Return SDK version used in the service library client. + * + * @return string + */ + public abstract function getSdkVersion(): string; + + /** + * Returns API version used in the service library + * + * @return string + */ + public abstract function getApiVersion(): string; +} diff --git a/src/Http/GraphCollectionRequest.php b/src/Http/GraphCollectionRequest.php index 8512d887..87d6dbb3 100644 --- a/src/Http/GraphCollectionRequest.php +++ b/src/Http/GraphCollectionRequest.php @@ -3,30 +3,21 @@ * Copyright (c) Microsoft Corporation. All Rights Reserved. * Licensed under the MIT License. See License in the project root * for license information. -* -* GraphCollectionRequest File -* PHP version 7 -* -* @category Library -* @package Microsoft.Graph -* @copyright 2016 Microsoft Corporation -* @license https://opensource.org/licenses/MIT MIT License -* @version GIT: 0.1.0 -* @link https://graph.microsoft.io/ */ namespace Microsoft\Graph\Http; +use GuzzleHttp\Psr7\Uri; +use Microsoft\Graph\Exception\GraphClientException; use Microsoft\Graph\Exception\GraphException; use Microsoft\Graph\Core\GraphConstants; /** * Class GraphCollectionRequest - * - * @category Library - * @package Microsoft.Graph - * @license https://opensource.org/licenses/MIT MIT License - * @link https://graph.microsoft.io/ + * @package Microsoft\Graph\Http + * @copyright 2021 Microsoft Corporation + * @license https://opensource.org/licenses/MIT MIT License + * @link https://developer.microsoft.com/graph */ class GraphCollectionRequest extends GraphRequest { @@ -54,12 +45,6 @@ class GraphCollectionRequest extends GraphRequest * @var bool */ protected $end; - /** - * The endpoint that the user called (with query parameters) - * - * @var string - */ - protected $originalEndpoint; /** * The return type that the user specified * @@ -68,28 +53,21 @@ class GraphCollectionRequest extends GraphRequest protected $originalReturnType; /** - * Constructs a new GraphCollectionRequest object - * - * @param string $requestType The HTTP verb for the - * request ("GET", "POST", "PUT", etc.) - * @param string $endpoint The URI of the endpoint to hit - * @param string $accessToken A valid access token - * @param string $baseUrl The base URL of the request - * @param string $apiVersion The version of the API to call - * @param string $proxyPort The url where to proxy through - * @param bool $proxyVerifySSL Whether the proxy requests should perform SSL verification - * @throws GraphException when no access token is provided - */ - public function __construct($requestType, $endpoint, $accessToken, $baseUrl, $apiVersion, $proxyPort = null, $proxyVerifySSL = false) + * Constructs a new GraphCollectionRequest object + * + * @param string $requestType The HTTP verb for the request ("GET", "POST", "PUT", etc.) + * @param string $endpoint The URI of the endpoint to hit + * @param AbstractGraphClient $graphClient + * @param string $baseUrl (optional) If empty, it's set to $client's national cloud + * @throws GraphClientException + */ + public function __construct(string $requestType, string $endpoint, AbstractGraphClient $graphClient, string $baseUrl = "") { parent::__construct( $requestType, $endpoint, - $accessToken, - $baseUrl, - $apiVersion, - $proxyPort, - $proxyVerifySSL + $graphClient, + $baseUrl ); $this->end = false; } @@ -98,21 +76,14 @@ public function __construct($requestType, $endpoint, $accessToken, $baseUrl, $ap * Gets the number of entries in the collection * * @return int the number of entries - * @throws GraphException - * @throws \GuzzleHttp\Exception\GuzzleException - */ + * @throws \Psr\Http\Client\ClientExceptionInterface + */ public function count() { $query = '$count=true'; - $request = new GraphRequest( - $this->requestType, - $this->endpoint . $this->getConcatenator() . $query, - $this->accessToken, - $this->baseUrl, - $this->apiVersion, - $this->proxyPort - ); - $result = $request->execute()->getBody(); + $requestUri = $this->getRequestUri(); + $this->setRequestUri(new Uri( $requestUri . GraphRequestUtil::getQueryParamConcatenator($requestUri) . $query)); + $result = $this->execute()->getBody(); if (array_key_exists("@odata.count", $result)) { return $result['@odata.count']; @@ -129,25 +100,25 @@ public function count() * * @param int $pageSize The page size * - * @throws GraphException if the requested page size exceeds + * @throws GraphClientException if the requested page size exceeds * the Graph's defined page size limit * @return GraphCollectionRequest object */ - public function setPageSize($pageSize) + public function setPageSize(int $pageSize): self { if ($pageSize > GraphConstants::MAX_PAGE_SIZE) { - throw new GraphException(GraphConstants::MAX_PAGE_SIZE_ERROR); + throw new GraphClientException(GraphConstants::MAX_PAGE_SIZE_ERROR); } $this->pageSize = $pageSize; return $this; } - /** - * Gets the next page of results - * - * @return array of objects of class $returnType - * @throws \GuzzleHttp\Exception\GuzzleException - */ + /** + * Gets the next page of results + * + * @return array of objects of class $returnType + * @throws \Psr\Http\Client\ClientExceptionInterface + */ public function getPage() { $this->setPageCallInfo(); @@ -157,11 +128,11 @@ public function getPage() } /** - * Sets the required query information to get a new page - * - * @return GraphCollectionRequest - */ - public function setPageCallInfo() + * Sets the required query information to get a new page + * + * @return GraphCollectionRequest + */ + public function setPageCallInfo(): self { // Store these to add temporary query data to request $this->originalReturnType = $this->returnType; @@ -175,11 +146,13 @@ public function setPageCallInfo() } if ($this->nextLink) { - $this->endpoint = "/" . implode("/", array_slice(explode("/", $this->nextLink), 4)); + $this->setRequestUri(new Uri($this->nextLink)); } else { // This is the first request to the endpoint if ($this->pageSize) { - $this->endpoint .= $this->getConcatenator() . '$top=' . $this->pageSize; + $query = '$top='.$this->pageSize; + $requestUri = $this->getRequestUri(); + $this->setRequestUri(new Uri( $requestUri . GraphRequestUtil::getQueryParamConcatenator($requestUri) . $query)); } } return $this; @@ -194,7 +167,7 @@ public function setPageCallInfo() * @return mixed result of the call, formatted according * to the returnType set by the user */ - public function processPageCallReturn($response) + public function processPageCallReturn(GraphResponse $response) { $this->nextLink = $response->getNextLink(); $this->deltaLink = $response->getDeltaLink(); @@ -223,7 +196,7 @@ public function processPageCallReturn($response) * * @return bool The end */ - public function isEnd() + public function isEnd(): bool { return $this->end; } @@ -234,7 +207,7 @@ public function isEnd() * * @return string|null The delta link */ - public function getDeltaLink() + public function getDeltaLink(): ?string { return $this->deltaLink; } diff --git a/src/Http/GraphRequest.php b/src/Http/GraphRequest.php index ec09b371..2da531a9 100644 --- a/src/Http/GraphRequest.php +++ b/src/Http/GraphRequest.php @@ -3,72 +3,44 @@ * Copyright (c) Microsoft Corporation. All Rights Reserved. * Licensed under the MIT License. See License in the project root * for license information. -* -* GraphRequest File -* PHP version 7 -* -* @category Library -* @package Microsoft.Graph -* @copyright 2016 Microsoft Corporation -* @license https://opensource.org/licenses/MIT MIT License -* @version GIT: 0.1.0 -* @link https://graph.microsoft.io/ */ namespace Microsoft\Graph\Http; -use GuzzleHttp\Client; -use GuzzleHttp\Exception\BadResponseException; +use GuzzleHttp\Psr7\Request; +use GuzzleHttp\Psr7\Uri; +use GuzzleHttp\Psr7\Utils; +use Http\Client\HttpAsyncClient; +use Http\Promise\Promise; use Microsoft\Graph\Core\GraphConstants; -use Microsoft\Graph\Core\ExceptionWrapper; +use Microsoft\Graph\Core\NationalCloud; +use Microsoft\Graph\Exception\GraphClientException; use Microsoft\Graph\Exception\GraphException; +use Psr\Http\Client\ClientExceptionInterface; +use Psr\Http\Client\ClientInterface; +use Psr\Http\Message\StreamInterface; /** * Class GraphRequest - * - * @category Library - * @package Microsoft.Graph - * @license https://opensource.org/licenses/MIT MIT License - * @link https://graph.microsoft.io/ + * @package Microsoft\Graph\Http + * @copyright 2021 Microsoft Corporation + * @license https://opensource.org/licenses/MIT MIT License + * @link https://developer.microsoft.com/graph */ class GraphRequest { - /** - * A valid access token - * - * @var string - */ - protected $accessToken; - /** - * The API version to use ("v1.0", "beta") - * - * @var string - */ - protected $apiVersion; - /** - * The base url to call - * - * @var string - */ - protected $baseUrl; - /** - * The endpoint to call - * - * @var string - */ - protected $endpoint; /** * An array of headers to send with the request * * @var array(string => string) */ - protected $headers; + private $headers; /** * The body of the request (optional) * * @var string */ - protected $requestBody; + private $requestBody = null; /** * The type of request to make ("GET", "POST", etc.) * @@ -83,85 +55,69 @@ class GraphRequest */ protected $returnsStream; /** - * The return type to cast the response as + * The object type to cast the response to * - * @var object|null + * @var string */ protected $returnType; /** - * The timeout, in seconds - * - * @var int - */ - protected $timeout; - /** - * The proxy port to use. Null to disable - * - * @var string - */ - protected $proxyPort; + * The Graph client + * + * @var AbstractGraphClient + */ + private $graphClient; /** - * Whether SSL verification should be used for proxy requests + * PSR-7 Request to be passed to HTTP client * - * @var bool + * @var \GuzzleHttp\Psr7\Request */ - protected $proxyVerifySSL; + private $httpRequest; /** - * Request options to decide if Guzzle Client should throw exceptions when http code is 4xx or 5xx - * - * @var bool - */ - protected $http_errors; + * Full Request URI (base URL + endpoint) + * + * @var Uri + */ + private $requestUri; /** - * Constructs a new Graph Request object - * - * @param string $requestType The HTTP method to use, e.g. "GET" or "POST" - * @param string $endpoint The Graph endpoint to call - * @param string $accessToken A valid access token to validate the Graph call - * @param string $baseUrl The base URL to call - * @param string $apiVersion The API version to use - * @param string $proxyPort The url where to proxy through - * @param bool $proxyVerifySSL Whether the proxy requests should perform SSL verification - * @throws GraphException when no access token is provided - */ - public function __construct($requestType, $endpoint, $accessToken, $baseUrl, $apiVersion, $proxyPort = null, $proxyVerifySSL = false) + * GraphRequest constructor. + * Sets $baseUrl by default to $graphClient's national cloud + * Resolves $baseUrl and $endpoint based on RFC 3986 + * + * @param string $requestType The HTTP method to use e.g. "GET" or "POST" + * @param string $endpoint The url path on the host to be called- + * @param AbstractGraphClient $graphClient The Graph client to use + * @param string $baseUrl (optional) If empty, it's set to $client's national cloud + * @throws GraphClientException + */ + public function __construct(string $requestType, string $endpoint, AbstractGraphClient $graphClient, string $baseUrl = "") { - $this->requestType = $requestType; - $this->endpoint = $endpoint; - $this->accessToken = $accessToken; - $this->http_errors = true; - - if (!$this->accessToken) { - throw new GraphException(GraphConstants::NO_ACCESS_TOKEN); + if (!$requestType || !$endpoint || !$graphClient) { + throw new GraphClientException("Request type, endpoint and client cannot be null or empty"); } - - $this->baseUrl = $baseUrl; - $this->apiVersion = $apiVersion; - $this->timeout = 100; - $this->headers = $this->_getDefaultHeaders(); - $this->proxyPort = $proxyPort; - $this->proxyVerifySSL = $proxyVerifySSL; + if (!$graphClient->getAccessToken()) { + throw new GraphClientException(GraphConstants::NO_ACCESS_TOKEN); + } + $this->requestType = $requestType; + $this->graphClient = $graphClient; + $baseUrl = ($baseUrl) ?: $graphClient->getNationalCloud(); + $this->initRequestUri($baseUrl, $endpoint); + $this->initHeaders($baseUrl); + $this->initPsr7HttpRequest(); } - /** - * Gets the Base URL the request is made to - * - * @return string - */ - public function getBaseUrl() + public function getHttpRequest(): Request { - return $this->baseUrl; + return $this->httpRequest; } - /** - * Gets the API version in use for the request - * - * @return string - */ - public function getApiVersion() - { - return $this->apiVersion; + protected function setRequestUri(Uri $uri): void { + $this->requestUri = $uri; + $this->initPsr7HttpRequest(); + } + + protected function getRequestUri(): Uri { + return $this->requestUri; } /** @@ -174,41 +130,27 @@ public function getReturnsStream() return $this->returnsStream; } - /** - * Sets a http errors option - * - * @param bool $http_errors A bool option to the Graph call - * - * @return GraphRequest object - */ - public function setHttpErrors($http_errors) - { - $this->http_errors = $http_errors; - return $this; - } - /** * Sets a new accessToken * * @param string $accessToken A valid access token to validate the Graph call * - * @return GraphRequest object + * @return $this object */ - public function setAccessToken($accessToken) + public function setAccessToken(string $accessToken): self { - $this->accessToken = $accessToken; - $this->headers['Authorization'] = 'Bearer ' . $this->accessToken; + $this->addHeaders(['Authorization' => 'Bearer '.$accessToken]); return $this; } /** * Sets the return type of the response object * - * @param mixed $returnClass The object class to use + * @param string $returnClass The class name to use * - * @return GraphRequest object + * @return $this object */ - public function setReturnType($returnClass) + public function setReturnType(string $returnClass): self { $this->returnType = $returnClass; if ($this->returnType == "GuzzleHttp\Psr7\Stream") { @@ -226,9 +168,10 @@ public function setReturnType($returnClass) * * @return GraphRequest object */ - public function addHeaders($headers) + public function addHeaders(array $headers): self { - $this->headers = array_merge($this->headers, $headers); + $this->headers = array_merge_recursive($this->headers, $headers); + $this->initPsr7HttpRequest(); return $this; } @@ -237,7 +180,7 @@ public function addHeaders($headers) * * @return array of headers */ - public function getHeaders() + public function getHeaders(): array { return $this->headers; } @@ -246,27 +189,28 @@ public function getHeaders() * Attach a body to the request. Will JSON encode * any Microsoft\Graph\Model objects as well as arrays * - * @param mixed $obj The object to include in the request + * @param string|StreamInterface|object $obj The object to include in the request * - * @return GraphRequest object + * @return $this object */ - public function attachBody($obj) + public function attachBody($obj): self { // Attach streams & JSON automatically - if (is_string($obj) || is_a($obj, 'GuzzleHttp\\Psr7\\Stream')) { + if (is_string($obj) || is_a($obj, StreamInterface::class)) { $this->requestBody = $obj; } // By default, JSON-encode else { $this->requestBody = json_encode($obj); } + $this->initPsr7HttpRequest(); return $this; } /** * Get the body of the request * - * @return mixed request body of any type + * @return string|StreamInterface request body */ public function getBody() { @@ -274,56 +218,19 @@ public function getBody() } /** - * Sets the timeout limit of the cURL request - * - * @param int $timeout The timeout in seconds - * - * @return GraphRequest object - */ - public function setTimeout($timeout) - { - $this->timeout = $timeout; - return $this; - } - - /** - * Gets the timeout value of the request + * Executes the HTTP request using $graphClient's http client or a PSR-18 compliant HTTP client * - * @return int + * @param ClientInterface|null $client (optional) When null, uses $graphClient's http client + * @return array|GraphResponse|StreamInterface|object Graph Response object or response body cast to $returnType + * @throws ClientExceptionInterface */ - public function getTimeout() - { - return $this->timeout; - } - - /** - * Executes the HTTP request using Guzzle - * - * @param mixed $client The client to use in the request - * - * @throws \GuzzleHttp\Exception\GuzzleException - * - * @return mixed object or array of objects - * of class $returnType - */ - public function execute($client = null) + public function execute(?ClientInterface $client = null) { if (is_null($client)) { - $client = $this->createGuzzleClient(); + $client = $this->graphClient->getHttpClient(); } - try { - $result = $client->request( - $this->requestType, - $this->_getRequestUrl(), - [ - 'body' => $this->requestBody, - 'timeout' => $this->timeout - ] - ); - } catch(BadResponseException $e) { - throw ExceptionWrapper::wrapGuzzleBadResponseException($e); - } + $result = $client->sendRequest($this->httpRequest); // Check to see if returnType is a stream, if so return it immediately if($this->returnsStream) { @@ -348,27 +255,19 @@ public function execute($client = null) } /** - * Executes the HTTP request asynchronously using Guzzle - * - * @param mixed $client The client to use in the request - * - * @return mixed object or array of objects - * of class $returnType - */ - public function executeAsync($client = null) + * Executes the HTTP request asynchronously using $client + * + * @param HttpAsyncClient|null $client (optional) When null, uses $graphClient's http client + * @return Promise Resolves to GraphResponse object|response body cast to $returnType. Fails throwing the exception + * @throws \Exception when promise fails + */ + public function executeAsync(?HttpAsyncClient $client = null): Promise { if (is_null($client)) { - $client = $this->createGuzzleClient(); + $client = $this->graphClient->getHttpClient(); } - $promise = $client->requestAsync( - $this->requestType, - $this->_getRequestUrl(), - [ - 'body' => $this->requestBody, - 'timeout' => $this->timeout - ] - )->then( + return $client->sendAsyncRequest($this->httpRequest)->then( // On success, return the result/response function ($result) { @@ -391,163 +290,99 @@ function ($result) { } return $returnObject; }, - // On fail, log the error and return null + // On fail, forward the exception function ($reason) { - if ($reason instanceof BadResponseException) { - $reason = ExceptionWrapper::wrapGuzzleBadResponseException($reason); - } - trigger_error("Async call failed: " . $reason->getMessage()); - return null; + throw $reason; } ); - return $promise; } /** - * Download a file from OneDrive to a given location - * - * @param string $path The path to download the file to - * @param mixed $client The client to use in the request - * - * @throws GraphException if file path is invalid - * @throws \GuzzleHttp\Exception\GuzzleException - * - * @return null - */ - public function download($path, $client = null) + * Download a file from OneDrive to a given location + * + * @param string $path path to download the file contents to + * @param ClientInterface|null $client (optional) When null, defaults to $graphClient's http client + * @throws ClientExceptionInterface|GraphClientException when unable to open $path for writing + */ + public function download(string $path, ?ClientInterface $client = null): void { if (is_null($client)) { - $client = $this->createGuzzleClient(); + $client = $this->graphClient->getHttpClient(); } try { - $file = fopen($path, 'w'); - if (!$file) { - throw new GraphException(GraphConstants::INVALID_FILE); - } - - $client->request( - $this->requestType, - $this->_getRequestUrl(), - [ - 'body' => $this->requestBody, - 'sink' => $file, - 'timeout' => $this->timeout - ] - ); - if(is_resource($file)){ - fclose($file); - } - - } catch(GraphException $e) { - throw new GraphException(GraphConstants::INVALID_FILE); - } catch(BadResponseException $e) { - throw ExceptionWrapper::wrapGuzzleBadResponseException($e); + $resource = Utils::tryFopen($path, 'w'); + $stream = Utils::streamFor($resource); + $response = $client->sendRequest($this->httpRequest); + $stream->write($response->getBody()); + $stream->close(); + } catch (\RuntimeException $ex) { + throw new GraphClientException(GraphConstants::INVALID_FILE, $ex->getCode(), $ex); } - - return null; } /** - * Upload a file to OneDrive from a given location - * - * @param string $path The path of the file to upload - * @param mixed $client The client to use in the request - * - * @throws GraphException if file is invalid - * @throws \GuzzleHttp\Exception\GuzzleException - * - * @return mixed DriveItem or array of DriveItems - */ - public function upload($path, $client = null) + * Upload a file from $path to Graph API + * + * @param string $path path of file to be uploaded + * @param ClientInterface|null $client (optional) + * @return array|GraphResponse|StreamInterface|object Graph Response object or response body cast to $returnType + * @throws ClientExceptionInterface|GraphClientException if $path cannot be opened for reading + */ + public function upload(string $path, ?ClientInterface $client = null) { if (is_null($client)) { - $client = $this->createGuzzleClient(); + $client = $this->graphClient->getHttpClient(); } try { - if (file_exists($path) && is_readable($path)) { - $file = fopen($path, 'r'); - $stream = \GuzzleHttp\Psr7\Utils::streamFor($file); - $this->requestBody = $stream; - return $this->execute($client); - } else { - throw new GraphException(GraphConstants::INVALID_FILE); - } - } catch(GraphException $e) { - throw new GraphException(GraphConstants::INVALID_FILE); + $resource = Utils::tryFopen($path, 'r'); + $stream = Utils::streamFor($resource); + $this->attachBody($stream); + return $this->execute($client); + } catch(\RuntimeException $e) { + throw new GraphClientException(GraphConstants::INVALID_FILE, $e->getCode(), $e->getPrevious()); } } /** - * Get a list of headers for the request - * - * @return array The headers for the request - */ - private function _getDefaultHeaders() - { - $headers = [ - 'Host' => $this->baseUrl, - 'Content-Type' => 'application/json', - 'SdkVersion' => 'Graph-php-' . GraphConstants::SDK_VERSION, - 'Authorization' => 'Bearer ' . $this->accessToken - ]; - return $headers; - } - - /** - * Get the concatenated request URL - * - * @return string request URL - */ - private function _getRequestUrl() + * Sets default headers based on baseUrl being a Graph endpoint or not + */ + private function initHeaders(string $baseUrl): void { - //Send request with opaque URL - if (stripos($this->endpoint, "http") === 0) { - return $this->endpoint; + $coreSdkVersion = "graph-php-core/".GraphConstants::SDK_VERSION; + $serviceLibSdkVersion = "Graph-php-".$this->graphClient->getSdkVersion(); + if (NationalCloud::containsNationalCloudHost($baseUrl)) { + $this->headers = [ + 'Content-Type' => 'application/json', + 'SdkVersion' => $coreSdkVersion.", ".$serviceLibSdkVersion, + 'Authorization' => 'Bearer ' . $this->graphClient->getAccessToken() + ]; + } else { + $this->headers = [ + 'Content-Type' => 'application/json', + ]; } - - return $this->apiVersion . $this->endpoint; } /** - * Checks whether the endpoint currently contains query - * parameters and returns the relevant concatenator for - * the new query string - * - * @return string "?" or "&" - */ - protected function getConcatenator() - { - if (stripos($this->endpoint, "?") === false) { - return "?"; + * Creates full request URI by resolving $baseUrl and $endpoint based on RFC 3986 + * + * @param string $baseUrl + * @param $endpoint + * @throws GraphClientException + */ + protected function initRequestUri(string $baseUrl, $endpoint): void { + try { + $this->requestUri = GraphRequestUtil::getRequestUri($baseUrl, $endpoint, $this->graphClient->getApiVersion()); + if (!$this->requestUri) { + // $endpoint is a full URL but doesn't meet criteria + throw new GraphClientException("Endpoint is not a valid URL. Must contain national cloud host."); + } + } catch (\InvalidArgumentException $ex) { + throw new GraphClientException("Unable to resolve base URL=".$baseUrl."\" with endpoint=".$endpoint."\"", 0, $ex); } - return "&"; } - /** - * Create a new Guzzle client - * To allow for user flexibility, the - * client is not reused. This allows the user - * to set and change headers on a per-request - * basis - * - * If a proxyPort was passed in the constructor, all - * requests will be forwared through this proxy. - * - * @return \GuzzleHttp\Client The new client - */ - protected function createGuzzleClient() - { - $clientSettings = [ - 'base_uri' => $this->baseUrl, - 'http_errors' => $this->http_errors, - 'headers' => $this->headers - ]; - if ($this->proxyPort !== null) { - $clientSettings['verify'] = $this->proxyVerifySSL; - $clientSettings['proxy'] = $this->proxyPort; - } - $client = new Client($clientSettings); - - return $client; + protected function initPsr7HttpRequest(): void { + $this->httpRequest = new Request($this->requestType, $this->requestUri, $this->headers, $this->requestBody); } } diff --git a/src/Http/GraphRequestUtil.php b/src/Http/GraphRequestUtil.php new file mode 100644 index 00000000..94dd15e3 --- /dev/null +++ b/src/Http/GraphRequestUtil.php @@ -0,0 +1,53 @@ +clientAdapter = new GuzzleAdapter($guzzleClient); + } + + public function sendRequest(RequestInterface $request): ResponseInterface { + return $this->clientAdapter->sendRequest($request); + } + + public function sendAsyncRequest(RequestInterface $request): Promise { + return $this->clientAdapter->sendAsyncRequest($request); + } + }; + } + + /** + * Returns Graph-specific config for Guzzle + * + * @return array + */ + private static function getDefaultConfig(): array { + return [ + RequestOptions::CONNECT_TIMEOUT => self::CONNECTION_TIMEOUT_SEC, + RequestOptions::TIMEOUT => self::REQUEST_TIMEOUT_SEC, + RequestOptions::HEADERS => [ + "Content-Type" => "application/json" + ], + RequestOptions::HTTP_ERRORS => false, + "base_uri" => self::$nationalCloud + ]; + } + + /** + * Merges client defined config array with Graph's default config. + * Provides defaults for timeouts and headers if none have been provided. + * Overrides base_uri. + */ + private static function mergeConfig(): void { + $defaultConfig = self::getDefaultConfig(); + + if (!isset(self::$clientConfig[RequestOptions::CONNECT_TIMEOUT])) { + self::$clientConfig[RequestOptions::CONNECT_TIMEOUT] = $defaultConfig[RequestOptions::CONNECT_TIMEOUT]; + } + if (!isset(self::$clientConfig[RequestOptions::TIMEOUT])) { + self::$clientConfig[RequestOptions::TIMEOUT] = $defaultConfig[RequestOptions::TIMEOUT]; + } + if (!isset(self::$clientConfig[RequestOptions::HEADERS])) { + self::$clientConfig[RequestOptions::HEADERS] = $defaultConfig[RequestOptions::HEADERS]; + } + self::$clientConfig["base_uri"] = self::$nationalCloud; + } +} diff --git a/src/Http/HttpClientInterface.php b/src/Http/HttpClientInterface.php new file mode 100644 index 00000000..e5c83d85 --- /dev/null +++ b/src/Http/HttpClientInterface.php @@ -0,0 +1,26 @@ +responseBodies = array( - 'short' => json_encode(array('body' => 'content')), // not truncated by Guzzle - 'long' => json_encode(array('body' => base64_encode(random_bytes(120)) . '.')), // truncated by Guzzle - ); - - $this->autoBadResponseExceptions = array(); - $this->manualBadResponseExceptions = array(); - foreach ($this->responseBodies as $name => $responseBody) { - $autoBadResponseException = GuzzleHttp\Exception\RequestException::create(new Request("GET", "/endpoint"), new Response(400, [], $responseBody)); - assert($autoBadResponseException instanceof BadResponseException); - $this->autoBadResponseExceptions[$name] = $autoBadResponseException; - - $manualBadResponseException = new BadResponseException("Error: API returned 400", new Request("GET", "/endpoint"), new Response(400, [], $responseBody)); - $this->manualBadResponseExceptions[$name] = $manualBadResponseException; - } - } - - public function testWrapBadResponseExceptionReturnsInstanceOfSameClass() - { - $name = 'short'; - - $ex = $this->autoBadResponseExceptions[$name]; - $wrappedException = ExceptionWrapper::wrapGuzzleBadResponseException($ex); - $this->assertInstanceOf(get_class($ex), $wrappedException); - - $ex = $this->manualBadResponseExceptions[$name]; - $wrappedException = ExceptionWrapper::wrapGuzzleBadResponseException($ex); - $this->assertInstanceOf(get_class($ex), $wrappedException); - - $name = 'long'; - - $ex = $this->autoBadResponseExceptions[$name]; - $wrappedException = ExceptionWrapper::wrapGuzzleBadResponseException($ex); - $this->assertInstanceOf(get_class($ex), $wrappedException); - - $ex = $this->manualBadResponseExceptions[$name]; - $wrappedException = ExceptionWrapper::wrapGuzzleBadResponseException($ex); - $this->assertInstanceOf(get_class($ex), $wrappedException); - } - - public function testWrapAutoBadResponseExceptionHasResponseBody() - { - $name = 'short'; - $responseBody = $this->responseBodies[$name]; - $ex = $this->autoBadResponseExceptions[$name]; - $wrappedException = ExceptionWrapper::wrapGuzzleBadResponseException($ex); - $this->assertStringContainsString($responseBody, $wrappedException->getMessage()); - - $name = 'long'; - $responseBody = $this->responseBodies[$name]; - $ex = $this->autoBadResponseExceptions[$name]; - $wrappedException = ExceptionWrapper::wrapGuzzleBadResponseException($ex); - $this->assertStringContainsString($responseBody, $wrappedException->getMessage()); - } - - public function testWrapManualBadResponseExceptionHasNotResponseBody() - { - $name = 'short'; - $responseBody = $this->responseBodies[$name]; - $ex = $this->manualBadResponseExceptions[$name]; - $wrappedException = ExceptionWrapper::wrapGuzzleBadResponseException($ex); - $this->assertStringNotContainsString($responseBody, $wrappedException->getMessage()); - - $name = 'long'; - $responseBody = $this->responseBodies[$name]; - $ex = $this->manualBadResponseExceptions[$name]; - $wrappedException = ExceptionWrapper::wrapGuzzleBadResponseException($ex); - $this->assertStringNotContainsString($responseBody, $wrappedException->getMessage()); - } - - public function testWrapBadResponseExceptionWithInvalidInput() - { - $this->expectException(TypeError::class); - ExceptionWrapper::wrapGuzzleBadResponseException(null); - } -} diff --git a/tests/Core/NationalCloudTest.php b/tests/Core/NationalCloudTest.php new file mode 100644 index 00000000..ff3cae8d --- /dev/null +++ b/tests/Core/NationalCloudTest.php @@ -0,0 +1,55 @@ +getConstants()); + foreach ($nationalClouds as $nationalCloud) { + $this->assertTrue(NationalCloud::containsNationalCloudHost($nationalCloud)); + } + } + + function testNationalCloudWithPortIsValid() { + $this->assertTrue(NationalCloud::containsNationalCloudHost(NationalCloud::GLOBAL.":1234")); + } + + function testNationalCloudWithTrailingForwardSlashIsValid() { + $this->assertTrue(NationalCloud::containsNationalCloudHost(NationalCloud::GLOBAL."/")); + } + + function testNationalCloudWithPathIsValid() { + $this->assertTrue(NationalCloud::containsNationalCloudHost(NationalCloud::GLOBAL."/v1.0/")); + } + + function testContainsNationalCloudWithCapitalisedHost() { + $url = "https://GRAPH.microsoft.COM"; + self::assertTrue(NationalCloud::containsNationalCloudHost($url)); + } + + function testEmptyNationalCloudUrlInvalid() { + $this->assertFalse(NationalCloud::containsNationalCloudHost("")); + } + + function testNullNationalCloudThrowsError() { + $this->expectException(\TypeError::class); + NationalCloud::containsNationalCloudHost(null); + } + + function testInvalidNationalCloud() { + $this->assertFalse(NationalCloud::containsNationalCloudHost("https://www.microsoft.com")); + } + + function testNationalCloudWithoutSchemeInvalid() { + $this->assertFalse(NationalCloud::containsNationalCloudHost("graph.microsoft.com")); + } + + function testMalformedNationalCloudInvalid() { + $this->assertFalse(NationalCloud::containsNationalCloudHost("https:///")); + } +} diff --git a/tests/Exception/ExceptionTest.php b/tests/Exception/ExceptionTest.php index 39520e09..8086b42e 100644 --- a/tests/Exception/ExceptionTest.php +++ b/tests/Exception/ExceptionTest.php @@ -1,6 +1,7 @@ assertEquals("Microsoft\Graph\Exception\GraphException: [404]: bad stuff\n", $exception->__toString()); } -} \ No newline at end of file + + public function testChildExceptionClassToString() { + $exception = new GraphClientException("Invalid national cloud"); + $this->assertStringContainsString(get_class($exception), $exception); + } +} diff --git a/tests/GraphTest.php b/tests/GraphTest.php index 8cc0745d..86c8919d 100644 --- a/tests/GraphTest.php +++ b/tests/GraphTest.php @@ -44,7 +44,7 @@ public function testRequestWithCustomEndpoint() $graph->setBaseUrl('url2'); $request = $graph->createRequest("GET", "/me"); - $this->assertEquals('url2', $request->getBaseUrl()); + $this->assertEquals('url2', $request->getNationalCloud()); } public function testBetaRequest() @@ -72,4 +72,4 @@ public function testMultipleGraphObjects() $this->assertEquals(GraphConstants::API_VERSION, $request->getApiVersion()); $this->assertEquals('beta', $request2->getApiVersion()); } -} \ No newline at end of file +} diff --git a/tests/Http/GraphRequestUtilTest.php b/tests/Http/GraphRequestUtilTest.php new file mode 100644 index 00000000..21c55d8f --- /dev/null +++ b/tests/Http/GraphRequestUtilTest.php @@ -0,0 +1,104 @@ +apiVersion = $graphClient->getApiVersion(); + } + + function testGetRequestUriWithFullNationalCloudEndpointUrlReturnsUri() { + $endpoint = NationalCloud::GLOBAL."/me/events?\$skip=100&\$top=10"; + $result = GraphRequestUtil::getRequestUri("", $endpoint, $this->apiVersion); + self::assertEquals($endpoint, strval($result)); + } + + function testGetRequestUriWithFullNonNationalCloudEndpointReturnsNull() { + $endpoint = "https://www.outlook.com/mail?user=me"; + $uri = GraphRequestUtil::getRequestUri("", $endpoint, $this->apiVersion); + self::assertNull($uri); + } + + function testGetRequestUriWithValidBaseUrlResolvesCorrectly() { + $validBaseUrls = [ + "https://graph.microsoft.com", + "https://graph.microsoft.com/", + "https://graph.microsoft.com/beta", + "https://graph.microsoft.com/v1.0/" + ]; + $endpoints = ["/me/events", "me/events"]; + $expected = "https://graph.microsoft.com/v1.0/me/events"; + foreach ($validBaseUrls as $baseUrl) { + foreach ($endpoints as $endpoint) { + $uri = GraphRequestUtil::getRequestUri($baseUrl, $endpoint, $this->apiVersion); + self::assertEquals($expected, strval($uri)); + } + } + } + + function testGetRequestUriWithEmptyBaseUriUsesNationalCloudByDefault() { + $endpoints= ["/me/events", "me/events"]; + $expected = "https://graph.microsoft.com/v1.0/me/events"; + foreach ($endpoints as $endpoint) { + $uri = GraphRequestUtil::getRequestUri("", $endpoint, $this->apiVersion); + self::assertEquals($expected, strval($uri)); + } + } + + function testGetRequestUriWithoutNationalCloudHostDoesntSetApiVersion() { + $baseUrl = "https://outlook.microsoft.com/mail/"; + $endpoint = "?startDate=2020-10-02&sort=desc"; + $expected = $baseUrl.$endpoint; + $uri = GraphRequestUtil::getRequestUri($baseUrl, $endpoint, $this->apiVersion); + self::assertEquals($expected, strval($uri)); + + } + + function testGetRequestUriWithInvalidFullEndpointUrlThrowsException() { + $this->expectException(\InvalidArgumentException::class); + $endpoint = "http:/microsoft.com:localhost\$endpoint"; + $uri = GraphRequestUtil::getRequestUri("", $endpoint, $this->apiVersion); + } + + function testGetRequestUrlWithInvalidBaseUrlAndEndpointThrowsException() { + $this->expectException(\InvalidArgumentException::class); + $baseUrl = "https://graph.microsoft.com"; + $endpoint = "http:/microsoft.com:localhost\$endpoint"; + $uri = GraphRequestUtil::getRequestUri($baseUrl, $endpoint, $this->apiVersion); + } + + function testGetQueryParamConcatenatorWithExistingQueryParams() { + $uri = new Uri("https://graph.microsoft.com?\$skip=10"); + $result = GraphRequestUtil::getQueryParamConcatenator($uri); + self::assertEquals("&", $result); + } + + function testGetQueryParamConcatenatorWithoutQueryParams() { + $uri = new Uri("https://graph.microsoft.com"); + $result = GraphRequestUtil::getQueryParamConcatenator($uri); + self::assertEquals("?", $result); + } +} diff --git a/tests/Http/HttpClientFactoryTest.php b/tests/Http/HttpClientFactoryTest.php new file mode 100644 index 00000000..2f214349 --- /dev/null +++ b/tests/Http/HttpClientFactoryTest.php @@ -0,0 +1,43 @@ +expectException(GraphClientException::class); + HttpClientFactory::nationalCloud(""); + } + + function testNationalCloudWithInvalidUrl() { + $this->expectException(GraphClientException::class); + HttpClientFactory::nationalCloud("https://www.microsoft.com"); + } + + function testCreateWithNoConfigReturnsDefaultClient() { + $client = HttpClientFactory::create(); + $this->assertInstanceOf(\GuzzleHttp\Client::class, $client); + } + + function testCreateWithConfigCreatesClient() { + $config = [ + "proxy" => "localhost:8000", + "verify" => false + ]; + $client = HttpClientFactory::clientConfig($config)::nationalCloud(NationalCloud::GERMANY)::create(); + $this->assertInstanceOf(\GuzzleHttp\Client::class, $client); + } + + function testCreateAdapterReturnsHttpClientInterface() { + $adapter = HttpClientFactory::nationalCloud(NationalCloud::US_DOD)::createAdapter(); + $this->assertInstanceOf(HttpClientInterface::class, $adapter); + } + +}