diff --git a/composer.json b/composer.json index bb9c04f4..e06b8ab4 100644 --- a/composer.json +++ b/composer.json @@ -14,8 +14,9 @@ "require": { "php": "^8.0 || ^7.3", "guzzlehttp/guzzle": "^7.0", - "php-http/httplug": "^2.2.0", - "php-http/guzzle7-adapter": "^1.0.0", + "guzzlehttp/psr7": "^2.0", + "php-http/httplug": "^2.2", + "php-http/guzzle7-adapter": "^1.0", "ext-json": "*" }, "require-dev": { 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 c8b6bbf8..cac4be0d 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 index 2d16d4ad..e863d5da 100644 --- a/src/Core/NationalCloud.php +++ b/src/Core/NationalCloud.php @@ -7,8 +7,6 @@ namespace Microsoft\Graph\Core; -use Microsoft\Graph\Http\GraphRequestUtil; - /** * Class NationalCloud * @@ -40,10 +38,14 @@ final class NationalCloud * @param string $url * @return bool */ - public static function isValidNationalCloudHost(string $url): bool { + public static function containsNationalCloudHost(string $url): bool { self::initHosts(); - $validUrlParts = GraphRequestUtil::isValidBaseUrl($url); - return $validUrlParts && array_key_exists($validUrlParts["host"], self::$hosts); + $validUrlParts = parse_url($url); + return $validUrlParts + && array_key_exists("scheme", $validUrlParts) + && $validUrlParts["scheme"] == "https" + && array_key_exists("host", $validUrlParts) + && array_key_exists(strtolower($validUrlParts["host"]), self::$hosts); } /** diff --git a/src/Http/BaseClient.php b/src/Http/AbstractGraphClient.php similarity index 82% rename from src/Http/BaseClient.php rename to src/Http/AbstractGraphClient.php index 1494012b..20ab94a9 100644 --- a/src/Http/BaseClient.php +++ b/src/Http/AbstractGraphClient.php @@ -22,7 +22,7 @@ * @license https://opensource.org/licenses/MIT MIT License * @link https://developer.microsoft.com/graph */ -class BaseClient +abstract class AbstractGraphClient { /** * The access_token provided after authenticating @@ -32,14 +32,6 @@ class BaseClient */ private $accessToken; - /** - * The api version to use ("v1.0", "beta") - * Default is "v1.0" - * - * @var string - */ - private $apiVersion = GraphConstants::API_VERSION; - /** * Host to use as the base URL and for authentication * @var string @@ -58,16 +50,13 @@ class BaseClient * * Creates a Graph client object used to make requests to the Graph API * - * @param string|null $apiVersion if null|"" defaults to "v1.0" * @param string|null $nationalCloud if null defaults to "https://graph.microsoft.com" * @param HttpClientInterface|null $httpClient if null creates default Guzzle client * @throws GraphClientException */ - public function __construct(?string $apiVersion = GraphConstants::API_VERSION, - ?string $nationalCloud = NationalCloud::GLOBAL, + public function __construct(?string $nationalCloud = NationalCloud::GLOBAL, ?HttpClientInterface $httpClient = null) { - $this->apiVersion = ($apiVersion) ?: GraphConstants::API_VERSION; $this->nationalCloud = ($nationalCloud) ?: NationalCloud::GLOBAL; $this->httpClient = ($httpClient) ?: HttpClientFactory::nationalCloud($nationalCloud)::createAdapter(); } @@ -94,13 +83,6 @@ public function getAccessToken(): string { return $this->accessToken; } - /** - * @return string - */ - public function getApiVersion(): string { - return $this->apiVersion; - } - /** * @return string */ @@ -132,10 +114,7 @@ public function createRequest(string $requestType, string $endpoint): GraphReque return new GraphRequest( $requestType, $endpoint, - $this->accessToken, - $this->nationalCloud, - $this->apiVersion, - $this->httpClient + $this ); } @@ -155,10 +134,21 @@ public function createCollectionRequest(string $requestType, string $endpoint): return new GraphCollectionRequest( $requestType, $endpoint, - $this->accessToken, - $this->nationalCloud, - $this->apiVersion, - $this->httpClient + $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 89d4d4f9..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,26 +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 HttpClientInterface $httpClient The HTTP client to use - * @throws GraphException when no access token is provided - */ - public function __construct($requestType, $endpoint, $accessToken, $baseUrl, $apiVersion, $httpClient) + * 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, - $httpClient + $graphClient, + $baseUrl ); $this->end = false; } @@ -96,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']; @@ -127,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(); @@ -155,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; @@ -173,12 +146,13 @@ public function setPageCallInfo() } if ($this->nextLink) { - $baseLength = strlen($this->baseUrl) + strlen($this->apiVersion); - $this->endpoint = substr($this->nextLink, $baseLength); + $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; @@ -193,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(); @@ -222,7 +196,7 @@ public function processPageCallReturn($response) * * @return bool The end */ - public function isEnd() + public function isEnd(): bool { return $this->end; } @@ -233,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 b5f6ed65..2da531a9 100644 --- a/src/Http/GraphRequest.php +++ b/src/Http/GraphRequest.php @@ -3,76 +3,48 @@ * 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.) * - * @var object + * @var string */ protected $requestType; /** @@ -83,88 +55,69 @@ class GraphRequest */ protected $returnsStream; /** - * The return type to cast the response as - * - * @var object - */ - protected $returnType; - /** - * The timeout, in seconds + * The object type to cast the response to * * @var string */ - protected $timeout; + protected $returnType; /** - * 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; - - protected $httpClient; + * 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 HttpClientInterface $httpClient The HTTP client to use - - * @throws GraphException when no access token is provided | Invalid base URL provided - */ - public function __construct($requestType, $endpoint, $accessToken, $baseUrl, $apiVersion, $httpClient) + * 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"); } - if (!GraphRequestUtil::isValidBaseUrl($baseUrl)) { - throw new GraphException("Invalid base url provided. Ensure url contains a scheme and no query params/paths are included."); + if (!$graphClient->getAccessToken()) { + throw new GraphClientException(GraphConstants::NO_ACCESS_TOKEN); } - $this->baseUrl = $baseUrl; - $this->apiVersion = $apiVersion; - $this->timeout = 100; - $this->headers = $this->_getDefaultHeaders(); - $this->httpClient = $httpClient; + $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; } /** @@ -177,41 +130,27 @@ public function getReturnsStream() return $this->returnsStream; } - /** - * Sets a http errors option - * - * @param string $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") { @@ -229,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; } @@ -240,7 +180,7 @@ public function addHeaders($headers) * * @return array of headers */ - public function getHeaders() + public function getHeaders(): array { return $this->headers; } @@ -249,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() { @@ -277,56 +218,19 @@ public function getBody() } /** - * Sets the timeout limit of the cURL request - * - * @param string $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 string + * @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) { @@ -351,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) { @@ -394,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\stream_for($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 index 7af649f0..94dd15e3 100644 --- a/src/Http/GraphRequestUtil.php +++ b/src/Http/GraphRequestUtil.php @@ -8,6 +8,11 @@ namespace Microsoft\Graph\Http; +use GuzzleHttp\Psr7\Uri; +use GuzzleHttp\Psr7\UriResolver; +use Microsoft\Graph\Core\NationalCloud; +use Psr\Http\Message\UriInterface; + /** * Class GraphRequestUtil * @package Microsoft\Graph\Http @@ -18,30 +23,31 @@ class GraphRequestUtil { /** - * Determine if $url meets criteria for use as a base URL. - * Returns null if $url is invalid - * Returns an array of URL parts if $url is valid + * Returns full request URI by resolving $baseUrl and $endpoint based on RFC 3986 + * Prepends $apiVersion to $endpoint if $baseUrl contains a national cloud host + * $endpoint can be a full URI with a national cloud host * - * @param string $url - * @return array|null + * @param string $baseUrl if empty, is overwritten with $client's national cloud + * @param string $endpoint can be a full URL + * @param string $apiVersion + * @return UriInterface|null */ - public static function isValidBaseUrl(string $url): ?array { - $urlParts = parse_url($url); - if ($urlParts - && array_key_exists("scheme", $urlParts) - && strtolower($urlParts["scheme"]) == "https" - && array_key_exists("host", $urlParts) - && ( - // if there's a path, must end with "/" e.g. https://graph.microsoft.com/beta/ - (array_key_exists("path", $urlParts) && substr($url, -1) == "/") - // hostname alone without path is also valid e.g. https://graph.microsoft.com - || !array_key_exists("path", $urlParts) - ) - && !(array_key_exists("query", $urlParts)) - ) { - return $urlParts; + public static function getRequestUri(string $baseUrl, string $endpoint, string $apiVersion = "v1.0"): ?UriInterface { + // If endpoint is a full url, ensure the host is a national cloud or custom host + if (parse_url($endpoint, PHP_URL_SCHEME)) { + return (NationalCloud::containsNationalCloudHost($endpoint)) ? new Uri($endpoint) : null; } - return null; + $relativeUrl = (NationalCloud::containsNationalCloudHost($baseUrl)) ? "/".$apiVersion : ""; + $relativeUrl .= (substr($endpoint, 0, 1) == "/") ? $endpoint : "/".$endpoint; + return UriResolver::resolve(new Uri($baseUrl), new Uri($relativeUrl)); } + /** + * Determine correct symbol to add before concatenating query parameters to $url + * @param Uri $url + * @return string + */ + public static function getQueryParamConcatenator(Uri $url): string { + return stripos($url, "?") ? "&" : "?"; + } } diff --git a/src/Http/HttpClientFactory.php b/src/Http/HttpClientFactory.php index 49afa8b8..4a5a503f 100644 --- a/src/Http/HttpClientFactory.php +++ b/src/Http/HttpClientFactory.php @@ -72,10 +72,7 @@ private function __construct() { * @throws GraphClientException if $nationalCloud is empty or an invalid national cloud Host */ public static function nationalCloud(string $nationalCloud = NationalCloud::GLOBAL): HttpClientFactory { - if (!$nationalCloud) { - throw new GraphClientException("National cloud cannot be empty string"); - } - if (!NationalCloud::isValidNationalCloudHost($nationalCloud)) { + if (!$nationalCloud || !NationalCloud::containsNationalCloudHost($nationalCloud)) { throw new GraphClientException("Invalid national cloud passed. See https://docs.microsoft.com/en-us/graph/deployments#microsoft-graph-and-graph-explorer-service-root-endpoints."); } self::$nationalCloud = $nationalCloud; diff --git a/tests/Core/ExceptionWrapperTest.php b/tests/Core/ExceptionWrapperTest.php deleted file mode 100644 index 5079c822..00000000 --- a/tests/Core/ExceptionWrapperTest.php +++ /dev/null @@ -1,91 +0,0 @@ -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 index c100bf23..ff3cae8d 100644 --- a/tests/Core/NationalCloudTest.php +++ b/tests/Core/NationalCloudTest.php @@ -11,48 +11,45 @@ class NationalCloudTest extends \PHPUnit\Framework\TestCase function testNationalCloudConstantsAreValid() { $nationalClouds = array_values((new \ReflectionClass(NationalCloud::class))->getConstants()); foreach ($nationalClouds as $nationalCloud) { - $this->assertTrue(NationalCloud::isValidNationalCloudHost($nationalCloud)); + $this->assertTrue(NationalCloud::containsNationalCloudHost($nationalCloud)); } } function testNationalCloudWithPortIsValid() { - $this->assertTrue(NationalCloud::isValidNationalCloudHost(NationalCloud::GLOBAL.":1234")); + $this->assertTrue(NationalCloud::containsNationalCloudHost(NationalCloud::GLOBAL.":1234")); } function testNationalCloudWithTrailingForwardSlashIsValid() { - $this->assertTrue(NationalCloud::isValidNationalCloudHost(NationalCloud::GLOBAL."/")); + $this->assertTrue(NationalCloud::containsNationalCloudHost(NationalCloud::GLOBAL."/")); } function testNationalCloudWithPathIsValid() { - $this->assertTrue(NationalCloud::isValidNationalCloudHost(NationalCloud::GLOBAL."/v1.0/")); + $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::isValidNationalCloudHost("")); + $this->assertFalse(NationalCloud::containsNationalCloudHost("")); } function testNullNationalCloudThrowsError() { $this->expectException(\TypeError::class); - NationalCloud::isValidNationalCloudHost(null); + NationalCloud::containsNationalCloudHost(null); } function testInvalidNationalCloud() { - $this->assertFalse(NationalCloud::isValidNationalCloudHost("https://www.microsoft.com")); + $this->assertFalse(NationalCloud::containsNationalCloudHost("https://www.microsoft.com")); } function testNationalCloudWithoutSchemeInvalid() { - $this->assertFalse(NationalCloud::isValidNationalCloudHost("graph.microsoft.com")); - } - - function testNationalCloudWithUrlPathInvalid() { - $this->assertFalse(NationalCloud::isValidNationalCloudHost(NationalCloud::GLOBAL."/v1.0")); - } - - function testNationalCloudWithQueryParamsInvalid() { - $this->assertFalse(NationalCloud::isValidNationalCloudHost(NationalCloud::GLOBAL."?key=value")); + $this->assertFalse(NationalCloud::containsNationalCloudHost("graph.microsoft.com")); } function testMalformedNationalCloudInvalid() { - $this->assertFalse(NationalCloud::isValidNationalCloudHost("https:///")); + $this->assertFalse(NationalCloud::containsNationalCloudHost("https:///")); } } diff --git a/tests/Http/GraphRequestUtilTest.php b/tests/Http/GraphRequestUtilTest.php index 4268beac..21c55d8f 100644 --- a/tests/Http/GraphRequestUtilTest.php +++ b/tests/Http/GraphRequestUtilTest.php @@ -8,55 +8,97 @@ namespace Http; +use GuzzleHttp\Psr7\Uri; +use Microsoft\Graph\Core\NationalCloud; +use Microsoft\Graph\Http\AbstractGraphClient; use Microsoft\Graph\Http\GraphRequestUtil; class GraphRequestUtilTest extends \PHPUnit\Framework\TestCase { - function testValidBaseUrlReturnsUrlParts() { - $url = "https://graph.microsoft.com"; - $expected = [ - "scheme" => "https", - "host" => "graph.microsoft.com" - ]; - $this->assertEquals($expected, GraphRequestUtil::isValidBaseUrl($url)); + private $apiVersion; + + function setUp(): void { + $graphClient = (new class extends AbstractGraphClient { + public function getSdkVersion(): string { + return ""; + } + + public function getApiVersion(): string { + return "v1.0"; + } + }); + $this->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 testValidBaseUrlWithPathReturnsUrlParts() { - $url = "https://graph.microsoft.com/beta/"; - $expected = [ - "scheme" => "https", - "host" => "graph.microsoft.com", - "path" => "/beta/" + function testGetRequestUriWithValidBaseUrlResolvesCorrectly() { + $validBaseUrls = [ + "https://graph.microsoft.com", + "https://graph.microsoft.com/", + "https://graph.microsoft.com/beta", + "https://graph.microsoft.com/v1.0/" ]; - $this->assertEquals($expected, GraphRequestUtil::isValidBaseUrl($url)); + $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 testBaseUrlWithPathButNoTrailingBackslashReturnsNull() { - $url = "https://graph.microsoft.com/v1.0"; - $this->assertNull(GraphRequestUtil::isValidBaseUrl($url)); + 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 testBaseUrlWithoutHttpsReturnsNull() { - $url = "http://graph.microsoft.com"; - $this->assertNull(GraphRequestUtil::isValidBaseUrl($url)); + 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 testBaseUrlWithoutHostReturnsNull() { - $url = "https:///beta"; - $this->assertNull(GraphRequestUtil::isValidBaseUrl($url)); + function testGetRequestUriWithInvalidFullEndpointUrlThrowsException() { + $this->expectException(\InvalidArgumentException::class); + $endpoint = "http:/microsoft.com:localhost\$endpoint"; + $uri = GraphRequestUtil::getRequestUri("", $endpoint, $this->apiVersion); } - function testBaseUrlWithQueryParamsReturnsNull() { - $url = "https://graph.microsoft.com/v1.0?key=value"; - $this->assertNull(GraphRequestUtil::isValidBaseUrl($url)); + 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 testEmptyBaseUrlReturnsNull() { - $this->assertNull(GraphRequestUtil::isValidBaseUrl("")); + function testGetQueryParamConcatenatorWithExistingQueryParams() { + $uri = new Uri("https://graph.microsoft.com?\$skip=10"); + $result = GraphRequestUtil::getQueryParamConcatenator($uri); + self::assertEquals("&", $result); } - function testNullBaseUrlThrowsException() { - $this->expectException(\TypeError::class); - GraphRequestUtil::isValidBaseUrl(null); + function testGetQueryParamConcatenatorWithoutQueryParams() { + $uri = new Uri("https://graph.microsoft.com"); + $result = GraphRequestUtil::getQueryParamConcatenator($uri); + self::assertEquals("?", $result); } }