diff --git a/.gitignore b/.gitignore index 173454b..9263fd4 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ *.iml out gen +composer.lock +vendor/ diff --git a/README.md b/README.md index 478a079..6dfabb5 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,161 @@ -### [_EVE-Online_](https://www.eveonline.com) - [_ESI_ API](https://esi.tech.ccp.is) client library for [_Pathfinder_](https://github.com/exodus4d/pathfinder) +## Web API client for [_EVE-Online_](https://www.eveonline.com) - [_ESI_ API](https://esi.evetech.net) +This Web API client library is used by [_Pathfinder_](https://github.com/exodus4d/pathfinder) and handles all _ESI_ API requests.
+Additional APIs can easily be added and can be used side by side with their own configuration. Included clients: -This ESI API client handles all _ESI_ API calls within _Pathfinder_ (`>= v.1.2.3`) and implements all required endpoints. +- _CCP ESI_ API client: [ESI.php](https://github.com/exodus4d/pathfinder_esi/blob/master/app/Client/ESI.php) +- _CCP SSO_ API client: [SSO.php](https://github.com/exodus4d/pathfinder_esi/blob/master/app/Client/SSO.php) +- _GitHub_ basic API client: [Github.php](https://github.com/exodus4d/pathfinder_esi/blob/master/app/Client/Github.php) + +This Web client is build on [_Guzzle_](http://guzzlephp.org) and makes much use of the build in +[_Middleware_](http://docs.guzzlephp.org/en/stable/handlers-and-middleware.html#middleware) concept in _Guzzle_. -#### Installation -This _ESI_ client is automatically installed through [_Composer_](https://getcomposer.org/) with all dependencies from your _Pathfinder_ project root. (see [composer.json](https://github.com/exodus4d/pathfinder/blob/master/composer.json)). +### Installation: +Use [_Composer_](https://getcomposer.org/) for installation. In `composer.json` `require` section add: +```json +{ + "require": { + "php-64bit": ">=7.1", + "exodus4d/pathfinder_esi": "dev-master#v1.2.5" + } +} +``` +> **_Pathfinder_:** This web API client lib is automatically installed through [_Composer_](https://getcomposer.org/) along with all other required dependencies for the _Pathfinder_ project. (→ see [composer.json](https://github.com/exodus4d/pathfinder/blob/master/composer.json)). +> +> A newer version of _Pathfinder_ **may** require a newer version of this repository as well. So running `composer install` **after** a _Pathfinder_ update will upgrade/install a newer _ESI_ client. +Check _Pathfinder_ [release](https://github.com/exodus4d/pathfinder/releases) notes for further information. -A newer version of _Pathfinder_ **may** require a newer version of this client as well. So running `composer install` **after** a _Pathfinder_ update will upgrade/install a newer _ESI_ client. -Check the _Pathfinder_ [release](https://github.com/exodus4d/pathfinder/releases) notes for further information. +### Use Client: +#### 1. Init client: -#### Bug report +```php +// New web client instance for GitHub API [→ Github() implements ApiInterface()] +$client = new \Exodus4D\ESI\Client\Github('https://api.github.com'); + +// configure client [→ check ApiInterface() for methods] +$client->setTimeout(3); // Timeout of the request (seconds) +$client->setUserAgent('My Example App'); // User-Agent Header (string) +$client->setDecodeContent('gzip, deflate'); // Accept-Encoding Header +$client->setDebugLevel(3); // Debug level [0-3] +$client->setNewLog(function() : \Closure { // Callback for new LogInterface + return function(string $action, string $level = 'warning') : logging\LogInterface { + $log = new logging\ApiLog($action, $level); + $log->addHandler('stream', 'json', './logs/requests.log'); + return $log; + }; +}); + +// Loggable $requests (e.g. HTTP 5xx resp.) will not get logged if return false; +$client->setIsLoggable(function() : \Closure { + return function(RequestInterface $request) use ($f3) : bool { + return true; + }; +}); + +$client->setLogStats(true); // Add some cURL status information (e.g. transferTime) to logged responses + +$client->setLogCache(true); // Add (local) cache info (e.g. response data cached) to logged requests +// $client->setLogAllStatus(true); // Log all requests regardless of response HTTP status code +$client->setLogFile('requests'); // Log file name for request/response errors +$client->setRetryLogFile('retry_requests'); // Log file for requests errors due to max request retry exceeds + +$client->setCacheDebug(true); // Add debug HTTP Header with local cache status information (HIT/MISS) +$client->setCachePool(function() : \Closure { + return function() : ?CacheItemPoolInterface { + $client = new \Redis(); // Cache backend used accross the web client + $client->connect('localhost', 6379); + + // → more PSR-6 compatible adapters at www.php-cache.com (e.g. Filesystem, Array,..) + $poolRedis = new RedisCachePool($client); + $cachePool = new NamespacedCachePool($poolRedis, 'myCachePoolName'); + return $cachePool; // This can be any PSR-6 compatible instance of CacheItemPoolInterface() + }; +}); +``` + +#### 2. Send requests +```php +// get all releases from GitHub for a repo +$releases = $client->getProjectReleases('exodus4d/pathfinder'); +// .. more requests here ... +``` + +## Concept +### _Guzzle_ [_Middlewares_](http://docs.guzzlephp.org/en/stable/handlers-and-middleware.html#middleware) : +_Middlewares_ classes are _small_ functions that _hook_ into the "request → response" chain in _Guzzle_. +- A _Middleware_ can _manipulate_ the `request` and `response` objects +- Each _Middleware_ is dedicated to handles its own task. +- There are _Middlewares_ for "logging", "caching",... pre-configured. +- Each _Middleware_ has its own set of config options that can be set through the `$client->`. +- All configured _Middlewares_ are pushed into a [_HandlerStack()_](http://docs.guzzlephp.org/en/stable/handlers-and-middleware.html#handlerstack) that gets _resolved_ for **each** request. +- The **order** in the `HandlerStack()` is essential! + +### _Guzzle_ [_HandlerStack_](http://docs.guzzlephp.org/en/stable/handlers-and-middleware.html#handlerstack) : +This flowchart shows all _Middlewares_ used by [ESI.php](https://github.com/exodus4d/pathfinder_esi/blob/master/app/Client/ESI.php) API client. +Each request to _ESI_ API invokes all _Middlewares_ in the following **order**: +##### Before request +[GuzzleJsonMiddleware](https://github.com/exodus4d/pathfinder_esi/blob/master/app/Lib/Middleware/GuzzleJsonMiddleware.php) → +[GuzzleLogMiddleware](https://github.com/exodus4d/pathfinder_esi/blob/master/app/Lib/Middleware/GuzzleLogMiddleware.php) → +[GuzzleCacheMiddleware](https://github.com/exodus4d/pathfinder_esi/blob/master/app/Lib/Middleware/GuzzleCacheMiddleware.php) → +[GuzzleCcpLogMiddleware](https://github.com/exodus4d/pathfinder_esi/blob/master/app/Lib/Middleware/GuzzleCcpLogMiddleware.php) → +[GuzzleRetryMiddleware](https://github.com/exodus4d/pathfinder_esi/blob/master/app/Lib/Middleware/GuzzleRetryMiddleware.php) → +[GuzzleCcpErrorLimitMiddleware](https://github.com/exodus4d/pathfinder_esi/blob/master/app/Lib/Middleware/GuzzleCcpErrorLimitMiddleware.php) +##### After response (→ reverse order!) +[GuzzleCcpErrorLimitMiddleware](https://github.com/exodus4d/pathfinder_esi/blob/master/app/Lib/Middleware/GuzzleCcpErrorLimitMiddleware.php) → +[GuzzleRetryMiddleware](https://github.com/exodus4d/pathfinder_esi/blob/master/app/Lib/Middleware/GuzzleRetryMiddleware.php) → +[GuzzleCcpLogMiddleware](https://github.com/exodus4d/pathfinder_esi/blob/master/app/Lib/Middleware/GuzzleCcpLogMiddleware.php) → +[GuzzleCacheMiddleware](https://github.com/exodus4d/pathfinder_esi/blob/master/app/Lib/Middleware/GuzzleCacheMiddleware.php) → +[GuzzleLogMiddleware](https://github.com/exodus4d/pathfinder_esi/blob/master/app/Lib/Middleware/GuzzleLogMiddleware.php) → +[GuzzleJsonMiddleware](https://github.com/exodus4d/pathfinder_esi/blob/master/app/Lib/Middleware/GuzzleJsonMiddleware.php) + +### Default _Middlewares_: +#### JSON +Requests with expected _JSON_ encoded `response` data have [GuzzleJsonMiddleware](https://github.com/exodus4d/pathfinder_esi/blob/master/app/Lib/Middleware/GuzzleJsonMiddleware.php) +in _HandlerStack_.
+This adds `Accept: application/json` Header to `request` and `response` body gets _wrapped_ into [JsonStream](https://github.com/exodus4d/pathfinder_esi/blob/master/app/Lib/Stream/JsonStream.php). + +`$client->setAcceptType('json');` + +#### Caching +A client instance _should_ be set up with a [_PSR-6_](https://www.php-fig.org/psr/psr-6) compatible cache pool where _persistent_ data can be stored. +Valid `response` data can be cached by its `Cache-Expire` HTTP Header. +[GuzzleCacheMiddleware](https://github.com/exodus4d/pathfinder_esi/blob/master/app/Lib/Middleware/GuzzleCacheMiddleware.php) also handle `Etag` Headers. +Other _Middlewares_ can also access the cache pool for their needs. +E.g. [GuzzleLogMiddleware](https://github.com/exodus4d/pathfinder_esi/blob/master/app/Lib/Middleware/GuzzleLogMiddleware.php) can _throttle_ error logging by using the cache pool for error counts,.. + +→ See: `$client->setCachePool();` +> **Hint:** Check out [www.php-cache.com](http://www.php-cache.com) for _PSR-6_ compatible cache pools. + +#### Logging +Errors (or other _events_) during (~before) a request can be logged (e.g. connect errors, or 4xx/5xx `responses`).
+The _primary_ _Middleware_ for logging is [GuzzleLogMiddleware](https://github.com/exodus4d/pathfinder_esi/blob/master/app/Lib/Middleware/GuzzleLogMiddleware.php)
+Other _Middlewares_ also have access to the _global_ new log callback and implement their own logs. + +`$client->setNewLog();` + +#### Retry +Requests result in an _expected_ error (timeouts, _cURL_ connect errors,.. ) will be retried [default: 2 times → configurable!]. +Check out [GuzzleRetryMiddleware](https://github.com/exodus4d/pathfinder_esi/blob/master/app/Lib/Middleware/GuzzleRetryMiddleware.php) for more information. + +### _CCP ESI_ exclusive _Middlewares_: +Each web client has its own stack of _Middlewares_. These _Middlewares_ are exclusive for `requests` to _CCP´s ESI_ API: + +#### [GuzzleCcpLogMiddleware](https://github.com/exodus4d/pathfinder_esi/blob/master/app/Lib/Middleware/GuzzleCcpLogMiddleware.php) +Requests to endpoints that return a `warning` HTTP Header for `deprecated` /or `legacy` marked endpoints get logged into separate log files. + +#### [GuzzleCcpErrorLimitMiddleware](https://github.com/exodus4d/pathfinder_esi/blob/master/app/Lib/Middleware/GuzzleCcpErrorLimitMiddleware.php) +Failed _ESI_ requests (4xx/5xx status code) implement the concept of "Error Rate Limiting" (→ blog: [ESI error rate limiting](https://developers.eveonline.com/blog/article/esi-error-limits-go-live)). +In case a request failed multiple times in a period, this _Middleware_ keeps track of logging this **and** _pre-block_ requests (e.g. for a user) an endpoint before _CCP_ actual does. + +### Content Encoding +The default configuration for "[decode-content](http://docs.guzzlephp.org/en/stable/request-options.html#decode-content)" is `true` → decode "_gzip_" or "_deflate_" responses.
+Most APIs will only send compressed response data if `Accept-Encoding` HTTP Header found in request. A `string` value will add this Header and response data gets decoded. + +`$client->setDecodeContent('gzip, deflate');` + +## Bug report Issues can be tracked here: https://github.com/exodus4d/pathfinder/issues -#### Development +## Development If you are a developer you might have **both** repositories ([exodus4d/pathfinder](https://github.com/exodus4d/pathfinder), [exodus4d/pathfinder_esi](https://github.com/exodus4d/pathfinder_esi) ) checked out locally. In this case you probably want to _test_ changes in your **local** [exodus4d/pathfinder_esi](https://github.com/exodus4d/pathfinder_esi) repo using your **local** [exodus4d/pathfinder](https://github.com/exodus4d/pathfinder) installation. diff --git a/app/Client/AbstractApi.php b/app/Client/AbstractApi.php new file mode 100644 index 0000000..526b8ed --- /dev/null +++ b/app/Client/AbstractApi.php @@ -0,0 +1,813 @@ + affects "Accept" request HTTP Header + */ + const DEFAULT_ACCEPT_TYPE = 'json'; + + /** + * default for: request timeout + */ + const DEFAULT_TIMEOUT = 3.0; + + /** + * default for: connect timeout + */ + const DEFAULT_CONNECT_TIMEOUT = 3.0; + + /** + * default for: read timeout + */ + const DEFAULT_READ_TIMEOUT = 10.0; + + /** + * default for: auto decode responses with encoded body + * -> checks "Content-Encoding" response HTTP Header for 'gzip' or 'deflate' value + * @see http://docs.guzzlephp.org/en/stable/request-options.html#decode-content + */ + const DEFAULT_DECODE_CONTENT = true; + + /** + * default for: debug requests + */ + const DEFAULT_DEBUG_REQUESTS = false; + + /** + * default for: log level + */ + const DEFAULT_DEBUG_LEVEL = 0; + + // ================================================================================================================ + // API class properties + // ================================================================================================================ + + /** + * WebClient instance + * @var \Exodus4D\ESI\Lib\WebClient|null + */ + private $client = null; + + /** + * base API URL + * @var string + */ + private $url = ''; + + /** + * @var string + */ + private $acceptType = self::DEFAULT_ACCEPT_TYPE; + + /** + * Timeout of the request in seconds + * Use 0 to wait indefinitely + * @see https://guzzle.readthedocs.io/en/latest/request-options.html#timeout + * @var float + */ + private $timeout = self::DEFAULT_TIMEOUT; + + /** + * Timeout for server connect in seconds + * @see https://guzzle.readthedocs.io/en/latest/request-options.html#connect-timeout + * @var float + */ + private $connectTimeout = self::DEFAULT_CONNECT_TIMEOUT; + + /** + * Read timeout for Streams + * Should be less than "default_socket_timeout" PHP ini + * @see https://guzzle.readthedocs.io/en/latest/request-options.html#read-timeout + * @var float + */ + private $readTimeout = self::DEFAULT_READ_TIMEOUT; + + /** + * decode response body + * @see http://docs.guzzlephp.org/en/stable/request-options.html#decode-content + * @var bool|array|string + */ + private $decodeContent = self::DEFAULT_DECODE_CONTENT; + + /** + * HTTP proxy + * -> for debugging purpose it might help to proxy requests through a local proxy + * e.g. 127.0.0.1:8888 (check out Fiddler https://www.telerik.com/fiddler) + * this should be used with 'verify' == false for HTTPS requests + * @see http://docs.guzzlephp.org/en/stable/request-options.html#proxy + * @var null|string|array + */ + private $proxy = null; + + /** + * SSL certificate verification behavior of a request + * @see http://docs.guzzlephp.org/en/stable/request-options.html#verify + * @var bool + */ + private $verify = true; + + /** + * Debug requests if enabled + * @see https://guzzle.readthedocs.io/en/latest/request-options.html#debug + * @var bool + */ + private $debugRequests = self::DEFAULT_DEBUG_REQUESTS; + + /** + * Debug level for API requests + * @var int + */ + private $debugLevel = self::DEFAULT_DEBUG_LEVEL; + + /** + * UserAgent send with requests + * @var string + */ + private $userAgent = ''; + + /** + * Callback function that returns new CacheItemPoolInterface + * -> This is a PSR-6 compatible Cache pool + * Used as Cache Backend in this API + * e.g. RedisCachePool() or FilesystemCachePool() + * @see http://www.php-cache.com + * @var null|\Closure + */ + private $getCachePool = null; + + /** + * Callback function that returns new Log object + * that extends logging\LogInterface class + * @var null|callable + */ + private $getLog = null; + + /** + * Callback function that returns true|false + * if a $request should be logged + * @var null|callable + */ + private $isLoggable = null; + + // Guzzle Log Middleware config ----------------------------------------------------------------------------------- + + /** + * @see GuzzleLogMiddleware::DEFAULT_LOG_ENABLED + * @var bool + */ + private $logEnabled = GuzzleLogMiddleware::DEFAULT_LOG_ENABLED; + + /** + * @see GuzzleLogMiddleware::DEFAULT_LOG_STATS + * @var bool + */ + private $logStats = GuzzleLogMiddleware::DEFAULT_LOG_STATS; + + /** + * @see GuzzleLogMiddleware::DEFAULT_LOG_CACHE + * @var bool + */ + private $logCache = GuzzleLogMiddleware::DEFAULT_LOG_CACHE; + + /** + * @see GuzzleLogMiddleware::DEFAULT_LOG_CACHE_HEADER + * @var string + */ + private $logCacheHeader = GuzzleLogMiddleware::DEFAULT_LOG_CACHE_HEADER; + + /** + * @see GuzzleLogMiddleware::DEFAULT_LOG_ALL_STATUS + * @var bool + */ + private $logAllStatus = GuzzleLogMiddleware::DEFAULT_LOG_ALL_STATUS; + + /** + * @see GuzzleLogMiddleware::DEFAULT_LOG_FILE + * @var string + */ + private $logFile = GuzzleLogMiddleware::DEFAULT_LOG_FILE; + + // Guzzle Cache Middleware config --------------------------------------------------------------------------------- + + /** + * @see GuzzleCacheMiddleware::DEFAULT_CACHE_ENABLED + * @var bool + */ + private $cacheEnabled = GuzzleCacheMiddleware::DEFAULT_CACHE_ENABLED; + + /** + * @see GuzzleCacheMiddleware::DEFAULT_CACHE_DEBUG + * @var bool + */ + private $cacheDebug = GuzzleCacheMiddleware::DEFAULT_CACHE_DEBUG; + + /** + * @see GuzzleCacheMiddleware::DEFAULT_CACHE_DEBUG_HEADER + * @var string + */ + private $cacheDebugHeader = GuzzleCacheMiddleware::DEFAULT_CACHE_DEBUG_HEADER; + + // Guzzle Retry Middleware config --------------------------------------------------------------------------------- + + /** + * @see GuzzleRetryMiddleware::DEFAULT_RETRY_ENABLED + * @var bool + */ + private $retryEnabled = GuzzleRetryMiddleware::DEFAULT_RETRY_ENABLED; + + /** + * @see GuzzleRetryMiddleware::DEFAULT_RETRY_MAX_ATTEMPTS + * @var int + */ + private $retryMaxAttempts = GuzzleRetryMiddleware::DEFAULT_RETRY_MAX_ATTEMPTS; + + /** + * @see GuzzleRetryMiddleware::DEFAULT_RETRY_MULTIPLIER + * @var float + */ + private $retryMultiplier = GuzzleRetryMiddleware::DEFAULT_RETRY_MULTIPLIER; + + /** + * @see GuzzleRetryMiddleware::DEFAULT_RETRY_ON_TIMEOUT + * @var bool + */ + private $retryOnTimeout = GuzzleRetryMiddleware::DEFAULT_RETRY_ON_TIMEOUT; + + /** + * @see GuzzleRetryMiddleware::DEFAULT_RETRY_ON_STATUS + * @var array + */ + private $retryOnStatus = GuzzleRetryMiddleware::DEFAULT_RETRY_ON_STATUS; + + /** + * @see GuzzleRetryMiddleware::DEFAULT_RETRY_EXPOSE_RETRY_HEADER + * @var bool + */ + private $retryExposeRetryHeader = GuzzleRetryMiddleware::DEFAULT_RETRY_EXPOSE_RETRY_HEADER; + + /** + * @see GuzzleRetryMiddleware::DEFAULT_RETRY_LOG_ERROR + * @var bool + */ + private $retryLogError = GuzzleRetryMiddleware::DEFAULT_RETRY_LOG_ERROR; + + /** + * @see GuzzleRetryMiddleware::DEFAULT_RETRY_LOG_FILE + * @var string + */ + private $retryLogFile = GuzzleRetryMiddleware::DEFAULT_RETRY_LOG_FILE; + + // ================================================================================================================ + // API class methods + // ================================================================================================================ + + /** + * Api constructor. + * @param string $url + */ + public function __construct(string $url){ + $this->setUrl($url); + } + + /** + * @return WebClient + */ + protected function getClient() : WebClient { + if(!$this->client){ + $this->client = $this->initClient(); + } + + return $this->client; + } + + /** + * @param string $url + */ + public function setUrl(string $url){ + $this->url = $url; + } + + /** + * @param string $acceptType + */ + public function setAcceptType(string $acceptType = self::DEFAULT_ACCEPT_TYPE){ + $this->acceptType = $acceptType; + } + + /** + * @param float $timeout + */ + public function setTimeout(float $timeout = self::DEFAULT_TIMEOUT){ + $this->timeout = $timeout; + } + + /** + * @param float $connectTimeout + */ + public function setConnectTimeout(float $connectTimeout = self::DEFAULT_CONNECT_TIMEOUT){ + $this->connectTimeout = $connectTimeout; + } + + /** + * @param float $readTimeout + */ + public function setReadTimeout(float $readTimeout = self::DEFAULT_READ_TIMEOUT){ + $this->readTimeout = $readTimeout; + } + + /** + * @param array|bool|string $decodeContent + */ + public function setDecodeContent($decodeContent = self::DEFAULT_DECODE_CONTENT){ + $this->decodeContent = $decodeContent; + } + + /** + * @param null|string|array $proxy + */ + public function setProxy($proxy){ + $this->proxy = $proxy; + } + + /** + * @param bool $verify + */ + public function setVerify(bool $verify){ + $this->verify = $verify; + } + + /** + * debug requests + * @param bool $debugRequests + */ + public function setDebugRequests(bool $debugRequests = self::DEFAULT_DEBUG_REQUESTS){ + $this->debugRequests = $debugRequests; + } + + /** + * @param int $debugLevel + */ + public function setDebugLevel(int $debugLevel = self::DEFAULT_DEBUG_LEVEL){ + $this->debugLevel = $debugLevel; + } + + /** + * @param string $userAgent + */ + public function setUserAgent(string $userAgent){ + $this->userAgent = $userAgent; + } + + /** + * set a callback that returns instance of + * @param \Closure $cachePool + */ + public function setCachePool(\Closure $cachePool){ + $this->getCachePool = $cachePool; + } + + /** + * set a callback that returns an new Log object that implements LogInterface + * @param \Closure $newLog + */ + public function setNewLog(\Closure $newLog){ + $this->getLog = $newLog; + } + + /** + * set a callback that returns true/false, param: ResponseInterface + * @param \Closure $isLoggable + */ + public function setIsLoggable(\Closure $isLoggable){ + $this->isLoggable = $isLoggable; + } + + /** + * GuzzleLogMiddleware config + * @param bool $logEnabled + */ + public function setLogEnabled(bool $logEnabled = GuzzleLogMiddleware::DEFAULT_LOG_ENABLED){ + $this->logEnabled = $logEnabled; + } + + /** + * GuzzleLogMiddleware config + * @param bool $logStats + */ + public function setLogStats(bool $logStats = GuzzleLogMiddleware::DEFAULT_LOG_STATS){ + $this->logStats = $logStats; + } + + /** + * GuzzleLogMiddleware config + * @param bool $logCache + */ + public function setLogCache(bool $logCache = GuzzleLogMiddleware::DEFAULT_LOG_CACHE){ + $this->logCache = $logCache; + } + + /** + * GuzzleLogMiddleware config + * @param string $logCacheHeader + */ + public function setLogCacheHeader(string $logCacheHeader = GuzzleLogMiddleware::DEFAULT_LOG_CACHE_HEADER){ + $this->logCacheHeader = $logCacheHeader; + } + + /** + * @param bool $logAllStatus + */ + public function setLogAllStatus(bool $logAllStatus = GuzzleLogMiddleware::DEFAULT_LOG_ALL_STATUS){ + $this->logAllStatus = $logAllStatus; + } + + /** + * GuzzleLogMiddleware config + * @param string $logFile + */ + public function setLogFile(string $logFile = GuzzleLogMiddleware::DEFAULT_LOG_FILE){ + $this->logFile = $logFile; + } + + /** + * GuzzleCacheMiddleware + * @param bool $cacheEnabled + */ + public function setCacheEnabled(bool $cacheEnabled = GuzzleCacheMiddleware::DEFAULT_CACHE_ENABLED){ + $this->cacheEnabled = $cacheEnabled; + } + + /** + * GuzzleCacheMiddleware config + * @param bool $cacheDebug + */ + public function setCacheDebug(bool $cacheDebug = GuzzleCacheMiddleware::DEFAULT_CACHE_DEBUG){ + $this->cacheDebug = $cacheDebug; + } + + /** + * GuzzleCacheMiddleware config + * @param string $cacheDebugHeader + */ + public function setCacheDebugHeader(string $cacheDebugHeader = GuzzleCacheMiddleware::DEFAULT_CACHE_DEBUG_HEADER){ + $this->cacheDebugHeader = $cacheDebugHeader; + } + + /** + * @param bool $retryEnabled + */ + public function setRetryEnabled(bool $retryEnabled = GuzzleRetryMiddleware::DEFAULT_RETRY_ENABLED){ + $this->retryEnabled = $retryEnabled; + } + + /** + * GuzzleRetryMiddleware config + * @param string $logFile + */ + public function setRetryLogFile(string $logFile = GuzzleRetryMiddleware::DEFAULT_RETRY_LOG_FILE){ + $this->retryLogFile = $logFile; + } + + /** + * @return string + */ + public function getUrl() : string { + return $this->url; + } + + /** + * @return string + */ + public function getAcceptType() : string { + return $this->acceptType; + } + + /** + * @return float + */ + public function getTimeout() : float { + return $this->timeout; + } + + /** + * @return float + */ + public function getConnectTimeout() : float { + return $this->connectTimeout; + } + + /** + * @return float + */ + public function getReadTimeout() : float { + return $this->readTimeout; + } + + /** + * @return array|bool|string + */ + public function getDecodeContent(){ + return $this->decodeContent; + } + + /** + * @return array|string|null + */ + public function getProxy(){ + return $this->proxy; + } + + /** + * @return bool + */ + public function getVerify(): bool { + return $this->verify; + } + + /** + * @return bool + */ + public function getDebugRequests() : bool { + return $this->debugRequests; + } + + /** + * @return int + */ + public function getDebugLevel() : int { + return $this->debugLevel; + } + + /** + * @return string + */ + public function getUserAgent() : string { + return $this->userAgent; + } + + /** + * @return \Closure|null + */ + public function getCachePool() : ?\Closure { + return $this->getCachePool; + } + + /** + * @return callable|null + */ + public function getNewLog() : ?\Closure { + return $this->getLog; + } + + /** + * @return callable|null + */ + public function getIsLoggable() : ?callable { + return $this->isLoggable; + } + + /** + * log callback function + * @return \Closure + */ + protected function log() : \Closure { + return function(string $action, string $level, string $message, array $data = [], string $tag = 'default'){ + if(is_callable($newLog = $this->getNewLog())){ + /** + * @var LogInterface $log + */ + $log = $newLog($action, $level); + $log->setMessage($message); + $log->setData($data); + $log->setTag($tag); + $log->buffer(); + } + }; + } + + /** + * get HTTP request Header for Authorization + * @param string $credentials + * @param string $type + * @return array + */ + protected function getAuthHeader(string $credentials, string $type = 'Basic') : array { + return ['Authorization' => ucfirst($type) . ' ' . $credentials]; + } + + /** + * init new webClient for this Api + * @return WebClient + */ + protected function initClient() : WebClient { + return new WebClient( + $this->getUrl(), + $this->getClientConfig(), + function(HandlerStack &$stack){ + $this->initStack($stack); + } + ); + } + + /** + * get webClient config based on current Api settings + * @return array + */ + protected function getClientConfig() : array { + return [ + 'timeout' => $this->getTimeout(), + 'connect_timeout' => $this->getConnectTimeout(), + 'read_timeout' => $this->getReadTimeout(), + 'decode_content' => $this->getDecodeContent(), + 'proxy' => $this->getProxy(), + 'verify' => $this->getVerify(), + 'debug' => $this->getDebugRequests(), + 'headers' => [ + 'User-Agent' => $this->getUserAgent() + ], + + // custom config + 'get_cache_pool' => $this->getCachePool() // make cachePool available in Middlewares + ]; + } + + /** + * modify HandlerStack by ref + * -> use this to manipulate the Stack and add/remove custom Middleware + * -> order of Stack is important! Execution order of each Middleware depends on Stack order: + * @see https://guzzle.readthedocs.io/en/stable/handlers-and-middleware.html#handlerstack + * @param HandlerStack $stack + */ + protected function initStack(HandlerStack &$stack) : void { + + if($this->getAcceptType() == 'json'){ + // json middleware prepares request and response for JSON data + $stack->push( GuzzleJsonMiddleware::factory(), 'json'); + } + + // error log middleware logs all request errors + // -> add somewhere to stack BOTTOM so that it runs at the end catches errors from previous middlewares + $stack->push(GuzzleLogMiddleware::factory($this->getLogMiddlewareConfig()), 'log'); + + // cache responses based on the response Headers and cache configuration + $stack->push(GuzzleCacheMiddleware::factory( + $this->getCacheMiddlewareConfig(), + $this->getCacheMiddlewareStrategy() + ), 'cache'); + + // retry failed requests should be on TOP of stack + // -> in case of retry other middleware don´t need to know about the failed attempts + $stack->push(GuzzleRetryMiddleware::factory($this->getRetryMiddlewareConfig()), 'retry'); + } + + /** + * get configuration for GuzzleLogMiddleware Middleware + * @return array + */ + protected function getLogMiddlewareConfig() : array { + return [ + 'log_enabled' => $this->logEnabled, + 'log_stats' => $this->logStats, + 'log_cache' => $this->logCache, + 'log_cache_header' => $this->logCacheHeader, + 'log_5xx' => true, + 'log_4xx' => true, + 'log_all_status' => $this->logAllStatus, + 'log_off_status' => [420], // error rate limit -> logged by other middleware + 'log_loggable_callback' => $this->getIsLoggable(), + 'log_callback' => $this->log(), + 'log_file' => $this->logFile + ]; + } + + /** + * get configuration for GuzzleCacheMiddleware Middleware + * @return array + */ + protected function getCacheMiddlewareConfig() : array { + return [ + 'cache_enabled' => $this->cacheEnabled, + 'cache_debug' => $this->cacheDebug, + 'cache_debug_header' => $this->cacheDebugHeader + ]; + } + + /** + * @return CacheStrategyInterface + */ + protected function getCacheMiddlewareStrategy() : CacheStrategyInterface { + return new PrivateCacheStrategy($this->getCacheMiddlewareStorage()); + } + + /** + * get instance of a CacheStore that is used in GuzzleCacheMiddleware + * -> we use a PSR-6 compatible CacheStore that can handle any $cachePool + * that implements the PSR-6 CacheItemPoolInterface + * (e.g. an adapter for Redis -> more adapters here: http://www.php-cache.com) + * @return CacheStorageInterface|null + */ + protected function getCacheMiddlewareStorage() : ?CacheStorageInterface { + if(is_callable($this->getCachePool) && !is_null($cachePool = ($this->getCachePool)())){ + return new Psr6CacheStorage($cachePool); + } + return null; + } + + /** + * get configuration GuzzleRetryMiddleware Retry Middleware + * @see https://packagist.org/packages/caseyamcl/guzzle_retry_middleware + * @return array + */ + protected function getRetryMiddlewareConfig() : array { + return [ + 'retry_enabled' => $this->retryEnabled, + 'max_retry_attempts' => $this->retryMaxAttempts, + 'default_retry_multiplier' => $this->retryMultiplier, + 'retry_on_status' => $this->retryOnStatus, + 'retry_on_timeout' => $this->retryOnTimeout, + 'expose_retry_header' => $this->retryExposeRetryHeader, + + 'retry_log_error' => $this->retryLogError, + 'retry_loggable_callback' => $this->getIsLoggable(), + 'retry_log_callback' => $this->log(), + 'retry_log_file' => $this->retryLogFile + ]; + } + + /** + * same as PHP´s array_merge_recursive() function except of "distinct" array values in return + * -> works like jQuery extend() + * @param array $array1 + * @param array $array2 + * @return array + */ + protected static function array_merge_recursive_distinct(array &$array1, array &$array2) : array { + $merged = $array1; + foreach($array2 as $key => &$value){ + if (is_array($value) && isset($merged[$key]) && is_array($merged[$key])){ + $merged[$key] = self::array_merge_recursive_distinct($merged[$key], $value); + }else{ + $merged[$key] = $value; + } + } + return $merged; + } + + /** + * @param string $method + * @param string $uri + * @param array $options + * @return JsonStreamInterface|StreamInterface|null + */ + protected function request(string $method, string $uri, array $options = []) : ?StreamInterface { + $body = null; + + try{ + $request = $this->getClient()->newRequest($method, $uri); + /** + * @var $response Response + */ + $response = $this->getClient()->send($request, $options); + $body = $response->getBody(); + }catch(TransferException $e){ + // Base Exception of Guzzle errors + // -> this includes "expected" errors like 4xx responses (ClientException) + // and "unexpected" errors like cURL fails (ConnectException)... + // -> error is already logged by LogMiddleware + $body = $this->getClient()->newErrorResponse($e, $this->getAcceptType())->getBody(); + }catch(\Exception $e){ + // Hard fail! Any other type of error + // -> e.g. RuntimeException,... + $body = $this->getClient()->newErrorResponse($e, $this->getAcceptType())->getBody(); + } + + return $body; + } +} \ No newline at end of file diff --git a/app/Client/AbstractCcp.php b/app/Client/AbstractCcp.php new file mode 100644 index 0000000..8225158 --- /dev/null +++ b/app/Client/AbstractCcp.php @@ -0,0 +1,76 @@ + "deprecated" or "legacy" endpoint request + $stack->after('cache', GuzzleCcpLogMiddleware::factory($this->getCcpLogMiddlewareConfig()), 'ccp_log'); + + // check response headers for ESI error limits + $stack->after('retry', GuzzleCcpErrorLimitMiddleware::factory($this->getCcpErrorLimitMiddlewareConfig()), 'ccp_error_limit'); + + /* + // test "ccp_log" middleware. Legacy endpoint + $stack->after('ccp_log', \GuzzleHttp\Middleware::mapResponse(function(\Psr\Http\Message\ResponseInterface $response){ + return $response->withHeader('warning', '199 - This endpoint has been updated.'); + }), 'test_ccp_log_legacy'); + + // test "ccp_log" middleware. Deprecated endpoint + $stack->after('ccp_log', \GuzzleHttp\Middleware::mapResponse(function(\Psr\Http\Message\ResponseInterface $response){ + return $response->withHeader('warning', '299 - This endpoint is deprecated.'); + }), 'test_ccp_log_deprecated'); + + // test "ccp_error_limit" middleware. Error limit exceeded + $stack->after('ccp_error_limit', \GuzzleHttp\Middleware::mapResponse(function(\Psr\Http\Message\ResponseInterface $response){ + return $response->withStatus(420) // 420 is ESI default response for limited requests + ->withHeader('X-Esi-Error-Limited', ''); // endpoint blocked + }), 'test_ccp_error_limit_exceeded'); + + // test "ccp_error_limit" middleware. Error limit above threshold + $stack->after('ccp_error_limit', \GuzzleHttp\Middleware::mapResponse(function(\Psr\Http\Message\ResponseInterface $response){ + return $response->withStatus(400) // 4xx or 5xx response for error requests + ->withHeader('X-Esi-Error-Limit-Reset', 50) // error window reset in s + ->withHeader('X-Esi-Error-Limit-Remain', 8); // errors possible in current error window + }), 'test_ccp_error_limit_threshold'); + */ + } + + /** + * get configuration for GuzzleCcpLogMiddleware Middleware + * @return array + */ + protected function getCcpLogMiddlewareConfig() : array { + return [ + 'ccp_log_loggable_callback' => $this->getIsLoggable(), + 'ccp_log_callback' => $this->log() + ]; + } + + /** + * get configuration for GuzzleCcpErrorLimitMiddleware Middleware + * @return array + */ + protected function getCcpErrorLimitMiddlewareConfig() : array { + return [ + 'ccp_limit_log_callback' => $this->log() + ]; + } +} \ No newline at end of file diff --git a/app/Client/ApiInterface.php b/app/Client/ApiInterface.php new file mode 100644 index 0000000..999dcd4 --- /dev/null +++ b/app/Client/ApiInterface.php @@ -0,0 +1,229 @@ +esiDataSource = $dataSource; + } + + /** + * @param string $version + */ + public function setVersion(string $version){ + $this->endpointVersion = $version; + } + + /** + * @return string + */ + public function getDataSource() : string { + return $this->esiDataSource; + } + + /** + * @return string + */ + public function getVersion() : string { + return $this->endpointVersion; + } + + /** + * @return array + */ + public function getServerStatus() : array { + $uri = $this->getEndpointURI(['status', 'GET']); + $serverStatus = []; + + $requestOptions = $this->getRequestOptions(); + $response = $this->request('GET', $uri, $requestOptions)->getContents(); + + if(!$response->error){ + $serverStatus['status'] = (new Mapper\ServerStatus($response))->getData(); + }else{ + $serverStatus['error'] = $response->error; + + } + + return $serverStatus; + } + + /** + * @param array $characterIds + * @return array + */ + public function getCharacterAffiliationData(array $characterIds) : array { + $uri = $this->getEndpointURI(['characters', 'affiliation', 'POST']); + $characterAffiliationData = []; + + $requestOptions = $this->getRequestOptions('', $characterIds); + $response = $this->request('POST', $uri, $requestOptions)->getContents(); + + if(!$response->error){ + foreach((array)$response as $affiliationData){ + $characterAffiliationData[] = (new Mapper\CharacterAffiliation($affiliationData))->getData(); + } + } + + return $characterAffiliationData; + } + + /** + * @param int $characterId + * @return array + */ + public function getCharacterData(int $characterId) : array { + $uri = $this->getEndpointURI(['characters', 'GET'], [$characterId]); + $characterData = []; + + $requestOptions = $this->getRequestOptions(); + $response = $this->request('GET', $uri, $requestOptions)->getContents(); + + if(!$response->error){ + $characterData = (new Mapper\Character($response))->getData(); + if( !empty($characterData) ){ + $characterData['id'] = $characterId; + } + } + + return $characterData; + } + + /** + * @param int $characterId + * @param string $accessToken + * @return array + */ + public function getCharacterLocationData(int $characterId, string $accessToken) : array { + $uri = $this->getEndpointURI(['characters', 'location', 'GET'], [$characterId]); + $locationData = []; + + $requestOptions = $this->getRequestOptions($accessToken); + $response = $this->request('GET', $uri, $requestOptions)->getContents(); + + if(!$response->error){ + $locationData = (new Mapper\Location($response))->getData(); + } + + return $locationData; + } + + /** + * @param int $characterId + * @param string $accessToken + * @return array + */ + public function getCharacterShipData(int $characterId, string $accessToken) : array { + $uri = $this->getEndpointURI(['characters', 'ship', 'GET'], [$characterId]); + $shipData = []; + + $requestOptions = $this->getRequestOptions($accessToken); + $response = $this->request('GET', $uri, $requestOptions)->getContents(); + + if(!$response->error){ + $shipData = (new Mapper\Ship($response))->getData(); + } + + return $shipData; + } + + /** + * @param int $characterId + * @param string $accessToken + * @return array + */ + public function getCharacterOnlineData(int $characterId, string $accessToken) : array { + $uri = $this->getEndpointURI(['characters', 'online', 'GET'], [$characterId]); + $onlineData = []; + + $requestOptions = $this->getRequestOptions($accessToken); + $response = $this->request('GET', $uri, $requestOptions)->getContents(); + + if(!$response->error){ + $onlineData = (new Mapper\Online($response))->getData(); + } + + return $onlineData; + } + + /** + * @param int $corporationId + * @return array + */ + public function getCorporationData(int $corporationId) : array { + $uri = $this->getEndpointURI(['corporations', 'GET'], [$corporationId]); + $corporationData = []; + + $requestOptions = $this->getRequestOptions(); + $response = $this->request('GET', $uri, $requestOptions)->getContents(); + + if(!$response->error){ + $corporationData = (new Mapper\Corporation($response))->getData(); + if( !empty($corporationData) ){ + $corporationData['id'] = $corporationId; + } + } + + return $corporationData; + } + + /** + * @param int $allianceId + * @return array + */ + public function getAllianceData(int $allianceId) : array { + $uri = $this->getEndpointURI(['alliances', 'GET'], [$allianceId]); + $allianceData = []; + + $requestOptions = $this->getRequestOptions(); + $response = $this->request('GET', $uri, $requestOptions)->getContents(); + + if(!$response->error){ + $allianceData = (new Mapper\Alliance($response))->getData(); + if( !empty($allianceData) ){ + $allianceData['id'] = $allianceId; + } + } + + return $allianceData; + } + + /** + * @param int $corporationId + * @param string $accessToken + * @return array + */ + public function getCorporationRoles(int $corporationId, string $accessToken) : array { + $uri = $this->getEndpointURI(['corporations', 'roles', 'GET'], [$corporationId]); + $rolesData = []; + + $requestOptions = $this->getRequestOptions($accessToken); + + // 403 'Character cannot grant roles' error + $requestOptions['log_off_status'] = [403]; + + $response = $this->request('GET', $uri, $requestOptions)->getContents(); + + if(!$response->error){ + foreach((array)$response as $characterRuleData){ + $rolesData['roles'][(int)$characterRuleData->character_id] = array_map('strtolower', (array)$characterRuleData->roles); + } + }else{ + $rolesData['error'] = $response->error; + } + + return $rolesData; + } + + /** + * @return array + */ + public function getUniverseRegions() : array { + $uri = $this->getEndpointURI(['universe', 'regions', 'list', 'GET']); + $regionData = []; + + $requestOptions = $this->getRequestOptions(); + $response = $this->request('GET', $uri, $requestOptions)->getContents(); + + if(!$response->error){ + $regionData = array_unique( array_map('intval', (array)$response) ); + } + + return $regionData; + } + + /** + * @param int $regionId + * @return array + */ + public function getUniverseRegionData(int $regionId) : array { + $uri = $this->getEndpointURI(['universe', 'regions', 'GET'], [$regionId]); + $regionData = []; + + $requestOptions = $this->getRequestOptions(); + $response = $this->request('GET', $uri, $requestOptions)->getContents(); + + if(!$response->error){ + $regionData = (new Mapper\Region($response))->getData(); + } + + return $regionData; + } + + /** + * @return array + */ + public function getUniverseConstellations() : array{ + $uri = $this->getEndpointURI(['universe', 'constellations', 'list', 'GET']); + $constellationData = []; + + $requestOptions = $this->getRequestOptions(); + $response = $this->request('GET', $uri, $requestOptions)->getContents(); + + if(!$response->error){ + $constellationData = array_unique( array_map('intval', (array)$response) ); + } + + return $constellationData; + } + + /** + * @param int $constellationId + * @return array + */ + public function getUniverseConstellationData(int $constellationId) : array { + $uri = $this->getEndpointURI(['universe', 'constellations', 'GET'], [$constellationId]); + $constellationData = []; + + $requestOptions = $this->getRequestOptions(); + $response = $this->request('GET', $uri, $requestOptions)->getContents(); + + if(!$response->error){ + $constellationData = (new Mapper\Constellation($response))->getData(); + } + + return $constellationData; + } + + /** + * @return array + */ + public function getUniverseSystems() : array{ + $uri = $this->getEndpointURI(['universe', 'systems', 'list', 'GET']); + $systemData = []; + + $requestOptions = $this->getRequestOptions(); + $response = $this->request('GET', $uri, $requestOptions)->getContents(); + + if(!$response->error){ + $systemData = array_unique( array_map('intval', (array)$response) ); + } + + return $systemData; + } + + /** + * @param int $systemId + * @return array + */ + public function getUniverseSystemData(int $systemId) : array { + $uri = $this->getEndpointURI(['universe', 'systems', 'GET'], [$systemId]); + $systemData = []; + + $requestOptions = $this->getRequestOptions(); + $response = $this->request('GET', $uri, $requestOptions)->getContents(); + + if(!$response->error){ + $systemData = (new Mapper\System($response))->getData(); + } + + return $systemData; + } + + /** + * @param int $starId + * @return array + */ + public function getUniverseStarData(int $starId) : array { + $uri = $this->getEndpointURI(['universe', 'stars', 'GET'], [$starId]); + $starData = []; + + $requestOptions = $this->getRequestOptions(); + $response = $this->request('GET', $uri, $requestOptions)->getContents(); + + if(!$response->error){ + $starData = (new Mapper\Universe\Star($response))->getData(); + if( !empty($starData) ){ + $starData['id'] = $starId; + } + } + + return $starData; + } + + /** + * @param int $planetId + * @return array + */ + public function getUniversePlanetData(int $planetId) : array { + $uri = $this->getEndpointURI(['universe', 'planets', 'GET'], [$planetId]); + $planetData = []; + + $requestOptions = $this->getRequestOptions(); + $response = $this->request('GET', $uri, $requestOptions)->getContents(); + + if(!$response->error){ + $planetData = (new Mapper\Universe\Planet($response))->getData(); + } + + return $planetData; + } + + /** + * @param int $stargateId + * @return array + */ + public function getUniverseStargateData(int $stargateId) : array { + $uri = $this->getEndpointURI(['universe', 'stargates', 'GET'], [$stargateId]); + $stargateData = []; + + $requestOptions = $this->getRequestOptions(); + $response = $this->request('GET', $uri, $requestOptions)->getContents(); + + if(!$response->error){ + $stargateData = (new Mapper\Universe\Stargate($response))->getData(); + } + + return $stargateData; + } + + /** + * @param array $universeIds + * @return array + */ + public function getUniverseNamesData(array $universeIds) : array { + $uri = $this->getEndpointURI(['universe', 'names', 'POST']); + $universeData = []; + + $requestOptions = $this->getRequestOptions('', $universeIds); + $response = $this->request('POST', $uri, $requestOptions)->getContents(); + + if(!$response->error){ + foreach((array)$response as $data){ + // store category because $data get changed in Mappers + $category = $data->category; + switch($category){ + case 'character': + $categoryData = (new Mapper\Character($data))->getData(); + break; + case 'alliance': + $categoryData = (new Mapper\Alliance($data))->getData(); + break; + case 'corporation': + $categoryData = (new Mapper\Corporation($data))->getData(); + break; + case 'station': + $categoryData = (new Mapper\Station($data))->getData(); + break; + case 'solar_system': + $category = 'system'; + $categoryData = (new Mapper\System($data))->getData(); + break; + case 'inventory_type': + $category = 'inventoryType'; + $categoryData = (new Mapper\InventoryType($data))->getData(); + break; + default: + $categoryData = []; + } + if( !empty($categoryData) ){ + $universeData[$category][] = $categoryData; + } + } + }else{ + $universeData['error'] = $response->error; + } + + return $universeData; + } + + /** + * @return array + */ + public function getUniverseJumps() : array { + $uri = $this->getEndpointURI(['universe', 'system_jumps', 'GET']); + $systemJumps = []; + + $requestOptions = $this->getRequestOptions(); + $response = $this->request('GET', $uri, $requestOptions)->getContents(); + + if(!$response->error){ + foreach((array)$response as $jumpData){ + $systemJumps[$jumpData->system_id]['jumps'] = (int)$jumpData->ship_jumps; + } + } + + return $systemJumps; + } + + /** + * @return array + */ + public function getUniverseKills() : array { + $uri = $this->getEndpointURI(['universe', 'system_kills', 'GET']); + $systemKills = []; + + $requestOptions = $this->getRequestOptions(); + $response = $this->request('GET', $uri, $requestOptions)->getContents(); + + if(!$response->error){ + foreach((array)$response as $killData){ + $systemKills[$killData->system_id] = [ + 'npc_kills' => (int)$killData->npc_kills, + 'pod_kills' => (int)$killData->pod_kills, + 'ship_kills' => (int)$killData->ship_kills + ]; + } + } + + return $systemKills; + } + + /** + * @return array + */ + public function getUniverseCategories() : array { + $uri = $this->getEndpointURI(['universe', 'categories', 'list', 'GET']); + $categoryData = []; + + $requestOptions = $this->getRequestOptions(); + $response = $this->request('GET', $uri, $requestOptions)->getContents(); + + if(!$response->error){ + $categoryData = array_unique( array_map('intval', (array)$response) ); + } + + return $categoryData; + } + + /** + * @param int $categoryId + * @return array + */ + public function getUniverseCategoryData(int $categoryId) : array { + $uri = $this->getEndpointURI(['universe', 'categories', 'GET'], [$categoryId]); + $categoryData = []; + + $requestOptions = $this->getRequestOptions(); + $response = $this->request('GET', $uri, $requestOptions)->getContents(); + + if(!$response->error){ + $categoryData = (new Mapper\Universe\Category($response))->getData(); + if( !empty($categoryData) ){ + $categoryData['id'] = $categoryId; + } + } + + return $categoryData; + } + + /** + * @return array + */ + public function getUniverseGroups() : array { + $uri = $this->getEndpointURI(['universe', 'groups', 'list', 'GET']); + $groupData = []; + + $requestOptions = $this->getRequestOptions(); + $response = $this->request('GET', $uri, $requestOptions)->getContents(); + + if(!$response->error){ + $groupData = array_unique( array_map('intval', (array)$response) ); + } + + return $groupData; + } + + /** + * @param int $groupId + * @return array + */ + public function getUniverseGroupData(int $groupId) : array { + $uri = $this->getEndpointURI(['universe', 'groups', 'GET'], [$groupId]); + $groupData = []; + + $requestOptions = $this->getRequestOptions(); + $response = $this->request('GET', $uri, $requestOptions)->getContents(); + + if(!$response->error){ + $groupData = (new Mapper\Universe\Group($response))->getData(); + if( !empty($groupData) ){ + $groupData['id'] = $groupId; + } + } + + return $groupData; + } + + /** + * @param int $structureId + * @param string $accessToken + * @return array + */ + public function getUniverseStructureData(int $structureId, string $accessToken) : array { + $uri = $this->getEndpointURI(['universe', 'structures', 'GET'], [$structureId]); + $structureData = []; + + $requestOptions = $this->getRequestOptions($accessToken); + $response = $this->request('GET', $uri, $requestOptions)->getContents(); + + if(!$response->error){ + $structureData = (new Mapper\Universe\Structure($response))->getData(); + if( !empty($structureData) ){ + $structureData['id'] = $structureId; + } + } + + return $structureData; + } + + /** + * @param int $typeId + * @return array + */ + public function getUniverseTypesData(int $typeId) : array { + $uri = $this->getEndpointURI(['universe', 'types', 'GET'], [$typeId]); + $typesData = []; + + $requestOptions = $this->getRequestOptions(); + $response = $this->request('GET', $uri, $requestOptions)->getContents(); + + if(!$response->error){ + $typesData = (new Mapper\Universe\Type($response))->getData(); + } + + return $typesData; + } + + /** + * @param int $sourceId + * @param int $targetId + * @param array $options + * @return array + */ + public function getRouteData(int $sourceId, int $targetId, array $options = []) : array { + $uri = $this->getEndpointURI(['routes', 'GET'], [$sourceId, $targetId]); + $routeData = []; + + $query = []; + if( !empty($options['avoid']) ){ + $query['avoid'] = $options['avoid']; + } + if( !empty($options['connections']) ){ + $query['connections'] = $options['connections']; + } + if( !empty($options['flag']) ){ + $query['flag'] = $options['flag']; + } + + $query = $this->formatUrlParams($query, [ + 'connections' => [',', '|'], + 'avoid' => [','] + ]); + + $requestOptions = $this->getRequestOptions('', null, $query); + + // 404 'No route found' error -> should not be logged + $requestOptions['log_off_status'] = [404]; + + $response = $this->request('GET', $uri, $requestOptions)->getContents(); + + if(!$response->error){ + $routeData['route'] = array_unique( array_map('intval', (array)$response) ); + }else{ + $routeData['error'] = $response->error; + } + + return $routeData; + } + + /** + * @param int $systemId + * @param string $accessToken + * @param array $options + * @return array + */ + public function setWaypoint(int $systemId, string $accessToken, array $options = []) : array { + $uri = $this->getEndpointURI(['ui', 'autopilot', 'waypoint', 'POST']); + $waypointData = []; + + $query = [ + 'add_to_beginning' => var_export( (bool)$options['addToBeginning'], true), + 'clear_other_waypoints' => var_export( (bool)$options['clearOtherWaypoints'], true), + 'destination_id' => $systemId + ]; + + $requestOptions = $this->getRequestOptions($accessToken, null, $query); + $response = $this->request('POST', $uri, $requestOptions)->getContents(); + + // "null" === success => There is no response body send... + if( $response->error ){ + $waypointData['error'] = self::ERROR_ESI_WAYPOINT; + } + + return $waypointData; + } + + /** + * @param int $targetId + * @param string $accessToken + * @return array + */ + public function openWindow(int $targetId, string $accessToken) : array { + $uri = $this->getEndpointURI(['ui', 'openwindow', 'information', 'POST']); + $return = []; + + $query = [ + 'target_id' => $targetId + ]; + + $requestOptions = $this->getRequestOptions($accessToken, null, $query); + $response = $this->request('POST', $uri, $requestOptions)->getContents(); + + // "null" === success => There is no response body send... + if( $response->error ){ + $return['error'] = self::ERROR_ESI_WINDOW; + } + + return $return; + } + + /** + * @param array $categories + * @param string $search + * @param bool $strict + * @return array + */ + public function search(array $categories, string $search, bool $strict = false) : array { + $uri = $this->getEndpointURI(['search', 'GET']); + $searchData = []; + + $query = [ + 'categories' => $categories, + 'search' => $search, + 'strict' => var_export( (bool)$strict, true), + ]; + + $query = $this->formatUrlParams($query, [ + 'categories' => [','] + ]); + + $requestOptions = $this->getRequestOptions('', null, $query); + $response = $this->request('GET', $uri, $requestOptions)->getContents(); + + if($response->error){ + $searchData['error'] = $response->error; + }else{ + $searchData = (new Mapper\Search($response))->getData(); + } + + return $searchData; + } + + /** + * @param string $version + * @return array + */ + public function getStatus(string $version = 'last') : array { + $uri = $this->getEndpointURI(['meta', 'status', 'GET']); + $statusData = []; + + $requestOptions = [ + 'query' => [ + 'version' => $version + ] + ]; + + $response = $this->request('GET', $uri, $requestOptions)->getContents(); + + if(!$response->error){ + foreach((array)$response as $status){ + $statusData['status'][] = (new Mapper\EsiStatus($status))->getData(); + } + }else{ + $statusData['error'] = $response->error; + } + + return $statusData; + } + + /** + * @param string $version + * @return array + */ + public function getStatusForRoutes(string $version = 'last') : array { + // data for all configured ESI endpoints + $statusData = [ + 'status' => Config\ESIConf::getEndpointsData() + ]; + + $statusDataAll = $this->getStatus($version); + if(!isset($statusDataAll['error'])){ + foreach((array)$statusData['status'] as $key => $data){ + foreach((array)$statusDataAll['status'] as $status){ + if( + $status['route'] == $data['route'] && + $status['method'] == $data['method'] + ){ + $statusData['status'][$key]['status'] = $status['status']; + $statusData['status'][$key]['tags'] = $status['tags']; + break; + } + } + } + }else{ + $statusData['error'] = $statusDataAll['error']; + } + + return $statusData; + } + + /** + * @param int $corporationId + * @return bool + */ + public function isNpcCorporation(int $corporationId) : bool { + $npcCorporations = $this->getNpcCorporations(); + return in_array($corporationId, $npcCorporations); + } + + /** + * @return array + */ + protected function getNpcCorporations() : array { + $uri = $this->getEndpointURI(['corporations', 'npccorps', 'GET']); + $npcCorporations = []; + + $requestOptions = $this->getRequestOptions(); + $response = $this->request('GET', $uri, $requestOptions)->getContents(); + + if(!$response->error){ + $npcCorporations = $response; + } + + return $npcCorporations; + } + + /** + * @param array $query + * @param array $format + * @return array + */ + protected function formatUrlParams(array $query = [], array $format = []) : array { + + $formatter = function(&$item, $key, $params) use (&$formatter) { + $params['depth'] = isset($params['depth']) ? ++$params['depth'] : 0; + $params['firstKey'] = isset($params['firstKey']) ? $params['firstKey'] : $key; + + if(is_array($item)){ + if($delimiter = $params[$params['firstKey']][$params['depth']]){ + array_walk($item, $formatter, $params); + $item = implode($delimiter, $item); + } + } + }; + + array_walk($query, $formatter, $format); + + return $query; + } + + /** + * get/build endpoint URI + * @param array $path + * @param array $placeholders + * @return string + */ + protected function getEndpointURI(array $path = [], array $placeholders = []) : string { + $uri = Config\ESIConf::getEndpoint($path, $placeholders); + + // overwrite endpoint version (debug) + if( !empty($endpointVersion = $this->getVersion()) ){ + $uri = preg_replace('/(v[\d]+|latest|dev|legacy)/', $endpointVersion, $uri, 1); + } + + return $uri; + } + + /** + * get "default" request options for ESI endpoints + * @param string $accessToken + * @param null $content + * @param array $query + * @return array + */ + protected function getRequestOptions(string $accessToken = '', $content = null, array $query = []) : array { + $options = []; + + if(!empty($accessToken)){ + // send Authorization HTTP header + // see: https://guzzle.readthedocs.io/en/latest/request-options.html#headers + $options['headers'] = $this->getAuthHeader($accessToken, 'Bearer'); + } + + if(!empty($content)){ + // send content (body) is always Json + // see: https://guzzle.readthedocs.io/en/latest/request-options.html#json + $options['json'] = $content; + } + + if(!empty($datasource = $this->getDataSource())){ + $query += ['datasource' => $datasource]; + } + + if(!empty($query)){ + // URL Query options + // see: https://guzzle.readthedocs.io/en/latest/request-options.html#query + $options['query'] = $query; + } + + return $options; + } +} \ No newline at end of file diff --git a/app/ApiInterface.php b/app/Client/EsiInterface.php similarity index 81% rename from app/ApiInterface.php rename to app/Client/EsiInterface.php index 756ccf6..4cefdaf 100644 --- a/app/ApiInterface.php +++ b/app/Client/EsiInterface.php @@ -1,27 +1,15 @@ send in HEADERS - * @param string $userAgent - */ - public function setUserAgent(string $userAgent); - - /** - * @return string - */ - public function getUserAgent(); +interface EsiInterface { /** * @return array @@ -44,26 +32,23 @@ public function getCharacterData(int $characterId) : array; /** * @param int $characterId * @param string $accessToken - * @param array $additionalOptions * @return array */ - public function getCharacterLocationData(int $characterId, string $accessToken, array $additionalOptions = []) : array; + public function getCharacterLocationData(int $characterId, string $accessToken) : array; /** * @param int $characterId * @param string $accessToken - * @param array $additionalOptions * @return array */ - public function getCharacterShipData(int $characterId, string $accessToken, array $additionalOptions = []) : array; + public function getCharacterShipData(int $characterId, string $accessToken) : array; /** * @param int $characterId * @param string $accessToken - * @param array $additionalOptions * @return array */ - public function getCharacterOnlineData(int $characterId, string $accessToken, array $additionalOptions = []) : array; + public function getCharacterOnlineData(int $characterId, string $accessToken) : array; /** * @param int $corporationId @@ -137,10 +122,9 @@ public function getUniverseStargateData(int $stargateId) : array; /** * @param array $universeIds - * @param array $additionalOptions * @return array */ - public function getUniverseNamesData(array $universeIds, array $additionalOptions = []) : array; + public function getUniverseNamesData(array $universeIds) : array; /** * @return array @@ -177,17 +161,15 @@ public function getUniverseGroupData(int $groupId) : array; /** * @param int $structureId * @param string $accessToken - * @param array $additionalOptions * @return array */ - public function getUniverseStructureData(int $structureId, string $accessToken, array $additionalOptions = []) : array; + public function getUniverseStructureData(int $structureId, string $accessToken) : array; /** * @param int $typeId - * @param array $additionalOptions * @return array */ - public function getUniverseTypesData(int $typeId, array $additionalOptions = []) : array; + public function getUniverseTypesData(int $typeId) : array; /** * @param int $sourceId @@ -196,7 +178,7 @@ public function getUniverseTypesData(int $typeId, array $additionalOptions = []) * @return array */ public function getRouteData(int $sourceId, int $targetId, array $options = []) : array; - + /** * @param int $systemId * @param string $accessToken @@ -204,7 +186,7 @@ public function getRouteData(int $sourceId, int $targetId, array $options = []) * @return array */ public function setWaypoint(int $systemId, string $accessToken, array $options = []) : array; - + /** * @param int $targetId * @param string $accessToken @@ -220,7 +202,19 @@ public function openWindow(int $targetId, string $accessToken) : array; */ public function search(array $categories, string $search, bool $strict = false) : array; - /** + /** + * @param string $version + * @return array + */ + public function getStatus(string $version) : array; + + /** + * @param string $version + * @return array + */ + public function getStatusForRoutes(string $version) : array; + + /** * @param int $corporationId * @return bool */ diff --git a/app/Client/GitHubInterface.php b/app/Client/GitHubInterface.php new file mode 100644 index 0000000..88b3f36 --- /dev/null +++ b/app/Client/GitHubInterface.php @@ -0,0 +1,28 @@ +getReleasesEndpointURI($projectName); + $releasesData = []; + + $requestOptions = [ + 'query' => [ + 'page' => 1, + 'per_page' => $count + ] + ]; + + $response = $this->request('GET', $uri, $requestOptions)->getContents(); + + if(!$response->error){ + foreach((array)$response as $data){ + $releasesData[] = (new Mapper\GitHub\Release($data))->getData(); + } + } + + return $releasesData; + } + + /** + * @param string $context + * @param string $markdown + * @return string + */ + public function markdownToHtml(string $context, string $markdown) : string { + $uri = $this->getMarkdownToHtmlEndpointURI(); + $html = ''; + + $requestOptions = [ + 'json_enabled' => false, // disable JSON Middleware + 'json' => [ + 'text' => $markdown, + 'mode' => 'gfm', + 'context' => $context + ] + ]; + + $response = $this->request('POST', $uri, $requestOptions)->getContents(); + + if(!$response->error){ + $html = (string)$response; + } + + return $html; + } + + /** + * @param string $projectName e.g. "exodus4d/pathfinder" + * @return string + */ + protected function getReleasesEndpointURI(string $projectName) : string { + return '/repos/' . $projectName . '/releases'; + } + + /** + * @return string + */ + protected function getMarkdownToHtmlEndpointURI() : string { + return '/markdown'; + } +} \ No newline at end of file diff --git a/app/Client/SSO.php b/app/Client/SSO.php new file mode 100644 index 0000000..06b6c4b --- /dev/null +++ b/app/Client/SSO.php @@ -0,0 +1,90 @@ + get some basic information (like character id) + * -> if more character information is required, use ESI "characters" endpoints request instead + * @param string $accessToken + * @return array + */ + public function getVerifyCharacterData(string $accessToken) : array { + $uri = $this->getVerifyUserEndpointURI(); + $characterData = []; + + $requestOptions = [ + 'headers' => $this->getAuthHeader($accessToken, 'Bearer') + ]; + + $response = $this->request('GET', $uri, $requestOptions)->getContents(); + + if(!$response->error){ + $characterData = (new Mapper\Sso\Character($response))->getData(); + } + + return $characterData; + } + + /** + * get a valid "access_token" for oAuth 2.0 verification + * -> verify $authCode and get NEW "access_token" + * $requestParams['grant_type] = 'authorization_code' + * $requestParams['code] = 'XXXX' + * -> request NEW "access_token" if isset: + * $requestParams['grant_type] = 'refresh_token' + * $requestParams['refresh_token] = 'XXXX' + * @param array $credentials + * @param array $requestParams + * @return array + */ + public function getAccessData(array $credentials, array $requestParams = []) : array { + $uri = $this->getVerifyAuthorizationCodeEndpointURI(); + $accessData = []; + + $requestOptions = [ + 'json' => $requestParams, + 'auth' => $credentials + ]; + + $response = $this->request('POST', $uri, $requestOptions)->getContents(); + + if(!$response->error){ + $accessData = (new Mapper\Sso\Access($response))->getData(); + } + + return $accessData; + } + + /** + * @return string + */ + public function getAuthorizationEndpointURI() : string { + return '/oauth/authorize'; + } + + /** + * @return string + */ + public function getVerifyUserEndpointURI() : string { + return '/oauth/verify'; + } + + /** + * @return string + */ + public function getVerifyAuthorizationCodeEndpointURI() : string { + return '/oauth/token'; + } +} \ No newline at end of file diff --git a/app/Client/SsoInterface.php b/app/Client/SsoInterface.php new file mode 100644 index 0000000..52e3e39 --- /dev/null +++ b/app/Client/SsoInterface.php @@ -0,0 +1,42 @@ + [ + 'status' => [ + 'GET' => '/status.json' + ] + ], 'status' => [ 'GET' => '/v1/status/' ], @@ -44,13 +52,13 @@ class ESIConf extends \Prefab { ], 'universe' => [ 'names' => [ - 'POST' => '/v2/universe/names/' + 'POST' => '/v3/universe/names/' ], 'system_jumps' => [ - 'GET' => ' /v1/universe/system_jumps/' + 'GET' => '/v1/universe/system_jumps/' ], 'system_kills' => [ - 'GET' => ' /v2/universe/system_kills/' + 'GET' => '/v2/universe/system_kills/' ], 'regions' => [ 'GET' => '/v1/universe/regions/{x}/', @@ -118,6 +126,52 @@ class ESIConf extends \Prefab { ] ]; + /** + * removes version from $endpoint + * -> return found version + * @param string $endpoint + * @return string|null + */ + static function stripVersion(string &$endpoint) : ?string { + $version = null; + $endpoint = preg_replace_callback( + '/^\/(v\d{1})\//', + function($matches) use (&$version){ + // set found version and strip it from $endpoint + $version = $matches[1]; + return '/'; + }, + $endpoint, + 1 + ); + + return $version; + } + + /** + * get endpoint data for all configured ESI endpoints + * @return array + */ + static function getEndpointsData() : array { + $endpointsData = []; + $conf = self::SWAGGER_SPEC; + + array_walk_recursive($conf, function($value, $key) use (&$endpointsData){ + if(is_string($value) && !empty($value)){ + // get version from route and remove it + $version = self::stripVersion($value); + $endpointsData[] = [ + 'method' => strtolower($key), + 'route' => $value, + 'version' => $version, + 'status' => null + ]; + } + }); + + return $endpointsData; + } + /** * get an ESI endpoint path * @param array $path diff --git a/app/ESI.php b/app/ESI.php deleted file mode 100644 index 9709293..0000000 --- a/app/ESI.php +++ /dev/null @@ -1,910 +0,0 @@ -esiUrl = $url; - } - - /** - * @param string $userAgent - */ - public function setUserAgent(string $userAgent){ - $this->esiUserAgent = $userAgent; - } - - /** - * @param string $datasource - */ - public function setDatasource(string $datasource){ - $this->esiDatasource = $datasource; - } - - /** - * @param int $debug - */ - public function setDebugLevel(int $debug = self::DEFAULT_DEBUG_LEVEL){ - $this->debugLevel = $debug; - } - - /** - * log any requests to log file - * @param bool $logRequests - */ - public function setDebugLogRequests(bool $logRequests = self::DEFAULT_DEBUG_LOG_REQUESTS){ - $this->debugLogRequests = $logRequests; - } - - /** - * @param string $version - */ - public function setVersion(string $version){ - $this->endpointVersion = $version; - } - - /** - * @return string - */ - public function getUrl() : string{ - return $this->esiUrl; - } - - /** - * @return string - */ - public function getUserAgent() : string{ - return $this->esiUserAgent; - } - - /** - * @return string - */ - public function getDatasource() : string{ - return $this->esiDatasource; - } - - /** - * @return int - */ - public function getDebugLevel() : int { - return $this->debugLevel; - } - - /** - * @return bool - */ - public function getDebugLogRequests() : bool { - return $this->debugLogRequests; - } - - /** - * @return string - */ - public function getVersion() : string{ - return $this->endpointVersion; - } - - /** - * @return array - */ - public function getServerStatus() : array { - $url = $this->getEndpointURL(['status', 'GET']); - $serverStatus = []; - $response = $this->request($url, 'GET'); - - if( !empty($response) ){ - $serverStatus = (new namespace\Mapper\ServerStatus($response))->getData(); - } - - return $serverStatus; - } - - /** - * @param array $characterIds - * @return array - */ - public function getCharacterAffiliationData(array $characterIds) : array { - $url = $this->getEndpointURL(['characters', 'affiliation', 'POST']); - $characterAffiliationData = []; - - $additionalOptions = [ - 'content' => $characterIds - ]; - $response = $this->request($url, 'POST', '', $additionalOptions); - - if( !empty($response) ){ - foreach((array)$response as $affiliationData){ - $characterAffiliationData[] = (new namespace\Mapper\CharacterAffiliation($affiliationData))->getData(); - } - } - - return $characterAffiliationData; - } - - /** - * @param int $characterId - * @return array - */ - public function getCharacterData(int $characterId) : array { - $url = $this->getEndpointURL(['characters', 'GET'], [$characterId]); - $characterData = []; - $response = $this->request($url, 'GET'); - - if( !empty($response) ){ - $characterData = (new namespace\Mapper\Character($response))->getData(); - if( !empty($characterData) ){ - $characterData['id'] = $characterId; - } - } - - return $characterData; - } - - /** - * @param int $characterId - * @param string $accessToken - * @param array $additionalOptions - * @return array - */ - public function getCharacterLocationData(int $characterId, string $accessToken, array $additionalOptions = []) : array { - $url = $this->getEndpointURL(['characters', 'location', 'GET'], [$characterId]); - $locationData = []; - $response = $this->request($url, 'GET', $accessToken, $additionalOptions); - - if( !empty($response) ){ - $locationData = (new namespace\Mapper\Location($response))->getData(); - } - - return $locationData; - } - - /** - * @param int $characterId - * @param string $accessToken - * @param array $additionalOptions - * @return array - */ - public function getCharacterShipData(int $characterId, string $accessToken, array $additionalOptions = []) : array { - $url = $this->getEndpointURL(['characters', 'ship', 'GET'], [$characterId]); - $shipData = []; - $response = $this->request($url, 'GET', $accessToken, $additionalOptions); - - if( !empty($response) ){ - $shipData = (new namespace\Mapper\Ship($response))->getData(); - } - - return $shipData; - } - - /** - * @param int $characterId - * @param string $accessToken - * @param array $additionalOptions - * @return array - */ - public function getCharacterOnlineData(int $characterId, string $accessToken, array $additionalOptions = []) : array { - $url = $this->getEndpointURL(['characters', 'online', 'GET'], [$characterId]); - $onlineData = []; - $response = $this->request($url, 'GET', $accessToken, $additionalOptions); - - if( !empty($response) ){ - $onlineData = (new namespace\Mapper\Online($response))->getData(); - } - - return $onlineData; - } - - /** - * @param int $corporationId - * @return array - */ - public function getCorporationData(int $corporationId) : array { - $url = $this->getEndpointURL(['corporations', 'GET'], [$corporationId]); - $corporationData = []; - $response = $this->request($url, 'GET'); - - if( !empty($response) ){ - $corporationData = (new namespace\Mapper\Corporation($response))->getData(); - if( !empty($corporationData) ){ - $corporationData['id'] = $corporationId; - } - } - - return $corporationData; - } - - /** - * @param int $allianceId - * @return array - */ - public function getAllianceData(int $allianceId) : array { - $url = $this->getEndpointURL(['alliances', 'GET'], [$allianceId]); - $allianceData = []; - $response = $this->request($url, 'GET'); - - if( !empty($response) ){ - $allianceData = (new namespace\Mapper\Alliance($response))->getData(); - if( !empty($allianceData) ){ - $allianceData['id'] = $allianceId; - } - } - - return $allianceData; - } - - /** - * @param int $corporationId - * @param string $accessToken - * @return array - */ - public function getCorporationRoles(int $corporationId, string $accessToken) : array { - // 403 'Character cannot grant roles' error - $additionalOptions['suppressHTTPLogging'] = [403]; - - $url = $this->getEndpointURL(['corporations', 'roles', 'GET'], [$corporationId]); - $rolesData = []; - $response = $this->request($url, 'GET', $accessToken, $additionalOptions); - - if($response->error){ - $rolesData['error'] = $response->error; - }elseif( !empty($response) ){ - foreach((array)$response as $characterRuleData){ - $rolesData['roles'][(int)$characterRuleData->character_id] = array_map('strtolower', (array)$characterRuleData->roles); - } - } - - return $rolesData; - } - - /** - * @return array - */ - public function getUniverseRegions() : array { - $url = $this->getEndpointURL(['universe', 'regions', 'list', 'GET']); - $regionData = []; - $response = $this->request($url, 'GET'); - - if( !$response->error && !empty($response) ){ - $regionData = array_unique( array_map('intval', $response) ); - } - - return $regionData; - } - - /** - * @param int $regionId - * @return array - */ - public function getUniverseRegionData(int $regionId) : array { - $url = $this->getEndpointURL(['universe', 'regions', 'GET'], [$regionId]); - $regionData = []; - $response = $this->request($url, 'GET'); - - if( !$response->error && !empty($response) ){ - $regionData = (new namespace\Mapper\Region($response))->getData(); - } - - return $regionData; - } - - /** - * @return array - */ - public function getUniverseConstellations() : array{ - $url = $this->getEndpointURL(['universe', 'constellations', 'list', 'GET']); - $constellationData = []; - $response = $this->request($url, 'GET'); - - if( !$response->error && !empty($response) ){ - $constellationData = array_unique( array_map('intval', $response) ); - } - - return $constellationData; - } - - /** - * @param int $constellationId - * @return array - */ - public function getUniverseConstellationData(int $constellationId) : array { - $url = $this->getEndpointURL(['universe', 'constellations', 'GET'], [$constellationId]); - $constellationData = []; - $response = $this->request($url, 'GET'); - - if( !$response->error && !empty($response) ){ - $constellationData = (new namespace\Mapper\Constellation($response))->getData(); - } - - return $constellationData; - } - - /** - * @return array - */ - public function getUniverseSystems() : array{ - $url = $this->getEndpointURL(['universe', 'systems', 'list', 'GET']); - $systemData = []; - $response = $this->request($url, 'GET'); - - if( !$response->error && !empty($response) ){ - $systemData = array_unique( array_map('intval', $response) ); - } - - return $systemData; - } - - /** - * @param int $systemId - * @return array - */ - public function getUniverseSystemData(int $systemId) : array { - $url = $this->getEndpointURL(['universe', 'systems', 'GET'], [$systemId]); - $systemData = []; - $response = $this->request($url, 'GET'); - - if( !$response->error && !empty($response) ){ - $systemData = (new namespace\Mapper\System($response))->getData(); - } - - return $systemData; - } - - /** - * @param int $starId - * @return array - */ - public function getUniverseStarData(int $starId) : array { - $url = $this->getEndpointURL(['universe', 'stars', 'GET'], [$starId]); - $starData = []; - $response = $this->request($url, 'GET'); - - if( !$response->error && !empty($response) ){ - $starData = (new namespace\Mapper\Universe\Star($response))->getData(); - if( !empty($starData) ){ - $starData['id'] = $starId; - } - } - - return $starData; - } - - /** - * @param int $planetId - * @return array - */ - public function getUniversePlanetData(int $planetId) : array { - $url = $this->getEndpointURL(['universe', 'planets', 'GET'], [$planetId]); - $planetData = []; - $response = $this->request($url, 'GET'); - - if( !$response->error && !empty($response) ){ - $planetData = (new namespace\Mapper\Universe\Planet($response))->getData(); - } - - return $planetData; - } - - /** - * @param int $stargateId - * @return array - */ - public function getUniverseStargateData(int $stargateId) : array { - $url = $this->getEndpointURL(['universe', 'stargates', 'GET'], [$stargateId]); - $stargateData = []; - $response = $this->request($url, 'GET'); - - if( !$response->error && !empty($response) ){ - $stargateData = (new namespace\Mapper\Universe\Stargate($response))->getData(); - } - - return $stargateData; - } - - /** - * @param array $universeIds - * @param array $additionalOptions - * @return array - */ - public function getUniverseNamesData(array $universeIds, array $additionalOptions = []) : array { - $url = $this->getEndpointURL(['universe', 'names', 'POST']); - $universeData = []; - - $additionalOptions['content'] = $universeIds; - - $response = $this->request($url, 'POST', '', $additionalOptions); - - if($response->error){ - $universeData['error'] = $response->error; - }elseif( !empty($response) ){ - foreach((array)$response as $data){ - // store category because $data get changed in Mappers - $category = $data->category; - switch($category){ - case 'character': - $categoryData = (new namespace\Mapper\Character($data))->getData(); - break; - case 'alliance': - $categoryData = (new namespace\Mapper\Alliance($data))->getData(); - break; - case 'corporation': - $categoryData = (new namespace\Mapper\Corporation($data))->getData(); - break; - case 'station': - $categoryData = (new namespace\Mapper\Station($data))->getData(); - break; - case 'solar_system': - $category = 'system'; - $categoryData = (new namespace\Mapper\System($data))->getData(); - break; - case 'inventory_type': - $category = 'inventoryType'; - $categoryData = (new namespace\Mapper\InventoryType($data))->getData(); - break; - default: - $categoryData = []; - } - if( !empty($categoryData) ){ - $universeData[$category][] = $categoryData; - } - } - } - - return $universeData; - } - - /** - * @return array - */ - public function getUniverseJumps() : array { - $url = $this->getEndpointURL(['universe', 'system_jumps', 'GET']); - $systemJumps = []; - - $response = $this->request($url, 'GET'); - - if( !empty($response) ){ - foreach((array)$response as $jumpData){ - $systemJumps[$jumpData->system_id]['jumps'] = (int)$jumpData->ship_jumps; - } - } - - return $systemJumps; - } - - /** - * @return array - */ - public function getUniverseKills() : array { - $url = $this->getEndpointURL(['universe', 'system_kills', 'GET']); - $systemKills = []; - - $response = $this->request($url, 'GET'); - - if( !empty($response) ){ - foreach((array)$response as $killData){ - $systemKills[$killData->system_id] = [ - 'npc_kills' => (int)$killData->npc_kills, - 'pod_kills' => (int)$killData->pod_kills, - 'ship_kills' => (int)$killData->ship_kills - ]; - } - } - - return $systemKills; - } - - /** - * @return array - */ - public function getUniverseCategories() : array { - $url = $this->getEndpointURL(['universe', 'categories', 'list', 'GET']); - $categoryData = []; - $response = $this->request($url, 'GET'); - - if( !empty($response) ){ - $categoryData = array_unique( array_map('intval', $response) ); - } - - return $categoryData; - } - - /** - * @param int $categoryId - * @return array - */ - public function getUniverseCategoryData(int $categoryId) : array { - $url = $this->getEndpointURL(['universe', 'categories', 'GET'], [$categoryId]); - $categoryData = []; - - $response = $this->request($url, 'GET'); - - if( !empty($response) ){ - $categoryData = (new namespace\Mapper\Universe\Category($response))->getData(); - if( !empty($categoryData) ){ - $categoryData['id'] = $categoryId; - } - } - - return $categoryData; - } - - /** - * @return array - */ - public function getUniverseGroups() : array { - $url = $this->getEndpointURL(['universe', 'groups', 'list', 'GET']); - $groupData = []; - $response = $this->request($url, 'GET'); - - if( !empty($response) ){ - $groupData = array_unique( array_map('intval', $response) ); - } - - return $groupData; - } - - /** - * @param int $groupId - * @return array - */ - public function getUniverseGroupData(int $groupId) : array { - $url = $this->getEndpointURL(['universe', 'groups', 'GET'], [$groupId]); - $groupData = []; - - $response = $this->request($url, 'GET'); - - if( !empty($response) ){ - $groupData = (new namespace\Mapper\Universe\Group($response))->getData(); - if( !empty($groupData) ){ - $groupData['id'] = $groupId; - } - } - - return $groupData; - } - - /** - * @param int $structureId - * @param string $accessToken - * @param array $additionalOptions - * @return array - */ - public function getUniverseStructureData(int $structureId, string $accessToken, array $additionalOptions = []) : array { - $url = $this->getEndpointURL(['universe', 'structures', 'GET'], [$structureId]); - $structureData = []; - - $response = $this->request($url, 'GET', $accessToken, $additionalOptions); - - if( !empty($response) ){ - $structureData = (new namespace\Mapper\Universe\Structure($response))->getData(); - if( !empty($structureData) ){ - $structureData['id'] = $structureId; - } - } - - return $structureData; - } - - /** - * @param int $typeId - * @param array $additionalOptions - * @return array - */ - public function getUniverseTypesData(int $typeId, array $additionalOptions = []) : array { - $url = $this->getEndpointURL(['universe', 'types', 'GET'], [$typeId]); - $typesData = []; - $response = $this->request($url, 'GET', '', $additionalOptions); - - if( !empty($response) ){ - $typesData = (new namespace\Mapper\Universe\Type($response))->getData(); - } - - return $typesData; - } - - /** - * @param int $sourceId - * @param int $targetId - * @param array $options - * @return array - */ - public function getRouteData(int $sourceId, int $targetId, array $options = []) : array { - // 404 'No route found' error - $additionalOptions['suppressHTTPLogging'] = [404]; - - $urlParams = []; - if( !empty($options['avoid']) ){ - $urlParams['avoid'] = $options['avoid']; - } - if( !empty($options['connections']) ){ - $urlParams['connections'] = $options['connections']; - } - if( !empty($options['flag']) ){ - $urlParams['flag'] = $options['flag']; - } - - $urlParams = $this->formatUrlParams($urlParams, [ - 'connections' => [',', '|'], - 'avoid' => [','] - ]); - - $url = $this->getEndpointURL(['routes', 'GET'], [$sourceId, $targetId], $urlParams); - $routeData = []; - $response = $this->request($url, 'GET', '', $additionalOptions); - - if($response->error){ - $routeData['error'] = $response->error; - }else{ - $routeData['route'] = array_unique( array_map('intval', $response) ); - } - - return $routeData; - } - - /** - * @param int $systemId - * @param string $accessToken - * @param array $options - * @return array - */ - public function setWaypoint(int $systemId, string $accessToken, array $options = []) : array { - $urlParams = [ - 'add_to_beginning' => var_export( (bool)$options['addToBeginning'], true), - 'clear_other_waypoints' => var_export( (bool)$options['clearOtherWaypoints'], true), - 'destination_id' => $systemId - ]; - - $url = $this->getEndpointURL(['ui', 'autopilot', 'waypoint', 'POST'], [], $urlParams); - $waypointData = []; - - // need to be send in "content" vars as well! Otherwise "Content-Length" header is not send - $additionalOptions = [ - 'content' => $urlParams - ]; - - $response = $this->request($url, 'POST', $accessToken, $additionalOptions); - - // "null" === success => There is no response body send... - if( !is_null($response) ){ - $waypointData['error'] = self::ERROR_ESI_WAYPOINT; - } - - return $waypointData; - } - - /** - * @param int $targetId - * @param string $accessToken - * @return array - */ - public function openWindow(int $targetId, string $accessToken) : array { - $urlParams = [ - 'target_id' => $targetId - ]; - - $url = $this->getEndpointURL(['ui', 'openwindow', 'information', 'POST'], [], $urlParams); - $return = []; - - // need to be send in "content" vars as well! Otherwise "Content-Length" header is not send - $additionalOptions = [ - 'content' => $urlParams - ]; - - $response = $this->request($url, 'POST', $accessToken, $additionalOptions); - - // "null" === success => There is no response body send... - if( !is_null($response) ){ - $return['error'] = self::ERROR_ESI_WINDOW; - } - - return $return; - } - - /** - * @param array $categories - * @param string $search - * @param bool $strict - * @return array - */ - public function search(array $categories, string $search, bool $strict = false) : array { - $urlParams = [ - 'categories' => $categories, - 'search' => $search, - 'strict' => var_export( (bool)$strict, true), - ]; - - $urlParams = $this->formatUrlParams($urlParams, [ - 'categories' => [','] - ]); - - $url = $this->getEndpointURL(['search', 'GET'], [], $urlParams); - - $searchData = []; - $response = $this->request($url, 'GET'); - - if($response->error){ - $searchData['error'] = $response->error; - }elseif( !empty($response) ){ - $searchData = (new namespace\Mapper\Search($response))->getData(); - } - - return $searchData; - } - - /** - * @param int $corporationId - * @return bool - */ - public function isNpcCorporation(int $corporationId) : bool { - $npcCorporations = $this->getNpcCorporations(); - return in_array($corporationId, $npcCorporations); - } - - /** - * @return array - */ - protected function getNpcCorporations() : array { - $url = $this->getEndpointURL(['corporations', 'npccorps', 'GET']); - $npcCorporations = []; - $response = $this->request($url, 'GET'); - - if( !empty($response) ){ - $npcCorporations = (array)$response; - } - - return $npcCorporations; - } - - protected function formatUrlParams(array $urlParams = [], array $format = []) : array { - - $formatter = function(&$item, $key, $params) use (&$formatter) { - $params['depth'] = isset($params['depth']) ? ++$params['depth'] : 0; - $params['firstKey'] = isset($params['firstKey']) ? $params['firstKey'] : $key; - - if(is_array($item)){ - if($delimiter = $params[$params['firstKey']][$params['depth']]){ - array_walk($item, $formatter, $params); - $item = implode($delimiter, $item); - } - } - }; - - array_walk($urlParams, $formatter, $format); - - return $urlParams; - } - - /** - * get/build endpoint URL - * @param array $path - * @param array $placeholders - * @param array $params - * @return string - */ - protected function getEndpointURL(array $path = [], array $placeholders = [], array $params = []) : string { - $url = $this->getUrl() . Config\ESIConf::getEndpoint($path, $placeholders); - - // add "datasource" parameter (SISI, TQ) (optional) - if( !empty($datasource = $this->getDatasource()) ){ - $params['datasource'] = $datasource; - } - // overwrite endpoint version (debug) - if( !empty($endpointVersion = $this->getVersion()) ){ - $url = preg_replace('/(v[\d]+|latest|dev|legacy)/',$endpointVersion, $url, 1); - } - - if( !empty($params) ){ - // add URL params - $url .= '?' . http_build_query($params, '', '&', PHP_QUERY_RFC3986 ); - } - - return $url; - } - - /** - * @param string $url - * @param string $method - * @param string $accessToken - * @param array $additionalOptions - * @return null|array|\stdClass - */ - protected function request(string $url, string $method = 'GET', string $accessToken = '', array $additionalOptions = []){ - $responseBody = null; - $method = strtoupper($method); - - $webClient = namespace\Lib\WebClient::instance($this->getDebugLevel(), $this->getDebugLogRequests()); - - if( \Audit::instance()->url($url) ){ - // check if url is blocked (error limit exceeded) - if(!$webClient->isBlockedUrl($url)){ - if( $webClient->checkRequestMethod($method) ){ - $requestOptions = [ - 'timeout' => self::ESI_TIMEOUT, - 'method' => $method, - 'user_agent' => $this->getUserAgent(), - 'header' => [ - 'Accept: application/json', - 'Expect:' - ] - ]; - - // add auth token if available (required for some endpoints) - if( !empty($accessToken) ){ - $requestOptions['header'][] = 'Authorization: Bearer ' . $accessToken; - } - - if( !empty($additionalOptions['content']) ){ - // "Content-Type" Header is required for POST requests - $requestOptions['header'][] = 'Content-Type: application/json'; - - $requestOptions['content'] = json_encode($additionalOptions['content'], JSON_UNESCAPED_SLASHES); - unset($additionalOptions['content']); - } - - $responseBody = $webClient->request($url, $requestOptions, $additionalOptions); - }else{ - $webClient->getLogger('err_server')->write(sprintf(self::ERROR_ESI_METHOD, $method, $url)); - } - } - }else{ - $webClient->getLogger('err_server')->write(sprintf(self::ERROR_ESI_URL, $url)); - } - - return $responseBody; - } -} \ No newline at end of file diff --git a/app/Lib/Middleware/AbstractGuzzleMiddleware.php b/app/Lib/Middleware/AbstractGuzzleMiddleware.php new file mode 100644 index 0000000..b02ee83 --- /dev/null +++ b/app/Lib/Middleware/AbstractGuzzleMiddleware.php @@ -0,0 +1,91 @@ +getCachePool = $options['get_cache_pool']; + } + } + + /** + * get PSR-6 CachePool instance + * @return CacheItemPoolInterface + */ + protected function cache() : CacheItemPoolInterface { + if(!is_null($this->getCachePool)){ + // return should be a full working PSR-6 Cache pool instance + return ($this->getCachePool)(); + }else{ + // no Cache pool provided -> use default "void" Cache Pool + // -> no storage at all! Dummy PSR-6 + return new VoidCachePool(); + } + } + + /** + * get a hashed key from $request URL + * @see cacheKeyFromUrl() + * @param RequestInterface $request + * @param string $tag + * @return string + */ + protected function cacheKeyFromRequestUrl(RequestInterface $request, string $tag = '') : string { + return $this->cacheKeyFromUrl($request->getUri()->__toString(), $tag); + } + + /** + * get a hashed key from $url + * -> $url gets normalized and GET params are stripped + * -> $tag can be used to get multiple unique keys for same $url + * @param string $tag + * @param string $url + * @return string + */ + protected function cacheKeyFromUrl(string $url, string $tag = '') : string { + return $this->hashKey($this->getNormalizedUrl($url) . $tag); + } + + /** + * get "normalized" url (ids in path get replaced) + * @param string $url + * @return string + */ + protected function getNormalizedUrl(string $url) : string { + $urlParts = parse_url($url); + $urlParts['path'] = preg_replace('/\/(\d+)\//', '/x/', $urlParts['path']); + return $urlParts['scheme'] . '://' . $urlParts['host'] . $urlParts['path']; + } + + /** + * get valid PSR-6 key name + * @see http://www.php-cache.com/en/latest/introduction/#cache-keys + * @param string $key + * @return string + */ + protected function hashKey(string $key) : string { + return sha1($key); + } +} \ No newline at end of file diff --git a/app/Lib/Middleware/Cache/CacheEntry.php b/app/Lib/Middleware/Cache/CacheEntry.php new file mode 100644 index 0000000..8278ec2 --- /dev/null +++ b/app/Lib/Middleware/Cache/CacheEntry.php @@ -0,0 +1,273 @@ +dateCreated = new \DateTime(); + $this->request = $request; + $this->response = $response; + $this->staleAt = $staleAt; + + if($response->hasHeader('Cache-Control')){ + $cacheControlHeader = \GuzzleHttp\Psr7\parse_header($response->getHeader('Cache-Control')); + + if(is_null($staleIfErrorTo)){ + $staleIfError = (int)GuzzleCacheMiddleware::arrayKeyDeep($cacheControlHeader, 'stale-if-error'); + if($staleIfError){ + $staleIfErrorTo = (new \DateTime( + '@'.($this->staleAt->getTimestamp() + $staleIfError) + )); + } + } + + if(is_null($staleWhileRevalidateTo)){ + $staleWhileRevalidate = (int)GuzzleCacheMiddleware::arrayKeyDeep($cacheControlHeader, 'stale-while-revalidate'); + if($staleWhileRevalidate){ + $staleWhileRevalidateTo = (new \DateTime( + '@'.($this->staleAt->getTimestamp() + $staleWhileRevalidate) + )); + } + + } + } + + $this->staleIfErrorTo = $staleIfErrorTo; + $this->staleWhileRevalidateTo = $staleWhileRevalidateTo; + } + + /** + * @return ResponseInterface + */ + public function getResponse() : ResponseInterface { + return $this->response->withHeader('Age', $this->getAge()); + } + + /** + * @return ResponseInterface + */ + public function getOriginalResponse() : ResponseInterface { + return $this->response; + } + + /** + * @return RequestInterface + */ + public function getOriginalRequest() : RequestInterface { + return $this->request; + } + + /** + * @param RequestInterface $request + * @return bool + */ + public function isVaryEquals(RequestInterface $request) : bool { + if($this->response->hasHeader('Vary')){ + if($this->request === null){ + return false; + } + + foreach($this->getVaryHeaders() as $varyHeader){ + if(!$this->request->hasHeader($varyHeader) && !$request->hasHeader($varyHeader)){ + // Absent from both + continue; + }elseif($this->request->getHeaderLine($varyHeader) == $request->getHeaderLine($varyHeader)){ + // Same content + continue; + } + return false; + } + } + return true; + } + + /** + * get Vary HTTP Header values as flat array + * @return array + */ + public function getVaryHeaders() : array { + $headers = []; + if($this->response->getHeader('Vary')){ + $varyHeader = \GuzzleHttp\Psr7\parse_header($this->response->getHeader('Vary')); + $headers = GuzzleCacheMiddleware::arrayFlattenByValue($varyHeader); + } + return $headers; + } + + /** + * @return \DateTime|null + */ + public function getStaleAt() : ?\DateTime { + return $this->staleAt; + } + + /** + * @return bool + */ + public function isFresh() : bool { + return !$this->isStale(); + } + + /** + * @return bool + */ + public function isStale() : bool { + return $this->getStaleAge() > 0; + } + + /** + * @return int positive value equal staled + */ + public function getStaleAge() : int { + // This object is immutable + if(is_null($this->timestampStale)){ + $this->timestampStale = $this->staleAt->getTimestamp(); + } + return time() - $this->timestampStale; + } + + /** + * @return bool + * @throws \Exception + */ + public function serveStaleIfError() : bool { + return !is_null($this->staleIfErrorTo) && $this->staleIfErrorTo->getTimestamp() >= (new \DateTime())->getTimestamp(); + } + + /** + * @return bool + * @throws \Exception + */ + public function staleWhileValidate() : bool { + return !is_null($this->staleWhileRevalidateTo) && $this->staleWhileRevalidateTo->getTimestamp() >= (new \DateTime())->getTimestamp(); + } + + /** + * @return bool + */ + public function hasValidationInformation() : bool { + return $this->response->hasHeader('Etag') || $this->response->hasHeader('Last-Modified'); + } + + /** + * @return int TTL in seconds (0 = infinite) + */ + public function getTTL() : int { + if($this->hasValidationInformation()){ + // No TTL if we have a way to re-validate the cache + return 0; + } + + if(!is_null($this->staleIfErrorTo)){ + // Keep it when stale if error + $ttl = $this->staleIfErrorTo->getTimestamp() - time(); + }else{ + // Keep it until it become stale + $ttl = $this->staleAt->getTimestamp() - time(); + } + // Don't return 0, it's reserved for infinite TTL + return $ttl !== 0 ? $ttl : -1; + } + + /** + * Age in seconds + * @return int + */ + public function getAge() : int { + return time() - $this->dateCreated->getTimestamp(); + } + + /** + * magic __sleep() + * @return array + */ + public function __sleep() : array { + if($this->response !== null){ + // Stream/Resource can't be serialized... So we copy the content + $this->responseBody = (string) $this->response->getBody(); + $this->response->getBody()->rewind(); + } + return array_keys(get_object_vars($this)); + } + + /** + * magic __wakeup() + */ + public function __wakeup() : void { + if($this->response !== null){ + // We re-create the stream of the response + $this->response = $this->response->withBody(\GuzzleHttp\Psr7\stream_for($this->responseBody)); + } + } + +} \ No newline at end of file diff --git a/app/Lib/Middleware/Cache/Storage/CacheStorageInterface.php b/app/Lib/Middleware/Cache/Storage/CacheStorageInterface.php new file mode 100644 index 0000000..f99c99c --- /dev/null +++ b/app/Lib/Middleware/Cache/Storage/CacheStorageInterface.php @@ -0,0 +1,34 @@ +cachePool = $cachePool; + } + + /** + * @param $key + * @return CacheEntry|null + * @throws \Psr\Cache\InvalidArgumentException + */ + public function fetch($key) : ?CacheEntry { + $item = $this->cachePool->getItem($key); + $this->lastItem = $item; + + $cacheEntry = $item->get(); + + return ($cacheEntry instanceof CacheEntry) ? $cacheEntry : null; + } + + /** + * @param string $key + * @param CacheEntry $cacheEntry + * @return bool + * @throws \Psr\Cache\InvalidArgumentException + */ + public function save(string $key, CacheEntry $cacheEntry) : bool { + if($this->lastItem && $this->lastItem->getKey() == $key){ + $item = $this->lastItem; + }else{ + $item = $this->cachePool->getItem($key); + } + + $this->lastItem = null; + + $item->set($cacheEntry); + + $ttl = $cacheEntry->getTTL(); + if($ttl === 0){ + // No expiration + $item->expiresAfter(null); + }else{ + $item->expiresAfter($ttl); + } + + return $this->cachePool->save($item); + } + + /** + * @param string $key + * @return bool + * @throws \Psr\Cache\InvalidArgumentException + */ + public function delete(string $key) : bool { + if(!is_null($this->lastItem) && $this->lastItem->getKey() === $key) { + $this->lastItem = null; + } + + return $this->cachePool->deleteItem($key); + } +} \ No newline at end of file diff --git a/app/Lib/Middleware/Cache/Storage/VolatileRuntimeStorage.php b/app/Lib/Middleware/Cache/Storage/VolatileRuntimeStorage.php new file mode 100644 index 0000000..4be6643 --- /dev/null +++ b/app/Lib/Middleware/Cache/Storage/VolatileRuntimeStorage.php @@ -0,0 +1,50 @@ +cache[$key]) ? $this->cache[$key] : null; + } + + /** + * @param string $key + * @param CacheEntry $data + * @return bool + */ + public function save(string $key, CacheEntry $data) : bool { + $this->cache[$key] = $data; + return true; + } + + /** + * @param string $key + * @return bool + */ + public function delete(string $key) : bool { + if(array_key_exists($key, $this->cache)){ + unset($this->cache[$key]); + return true; + } + return false; + } +} \ No newline at end of file diff --git a/app/Lib/Middleware/Cache/Strategy/CacheStrategyInterface.php b/app/Lib/Middleware/Cache/Strategy/CacheStrategyInterface.php new file mode 100644 index 0000000..fd545e9 --- /dev/null +++ b/app/Lib/Middleware/Cache/Strategy/CacheStrategyInterface.php @@ -0,0 +1,44 @@ + 200, + 203 => 203, + 204 => 204, + 300 => 300, + 301 => 301, + 404 => 404, + 405 => 405, + 410 => 410, + 414 => 414, + 418 => 418, + 501 => 501 + ]; + + /** + * @var string[] + */ + protected $ageKey = [ + 'max-age' + ]; + + /** + * PrivateCacheStrategy constructor. + * @param CacheStorageInterface|null $cache + */ + public function __construct(CacheStorageInterface $cache = null){ + // if no CacheStorageInterface (e.g. Psr6CacheStorage) defined + // -> use default VolatileRuntimeStorage (store data in temp array) + $this->storage = !is_null($cache) ? $cache : new VolatileRuntimeStorage(); + } + + /** + * @param RequestInterface $request + * @param ResponseInterface $response + * @return CacheEntry|null entry to save, null if can't cache it + */ + + /** + * @param RequestInterface $request + * @param ResponseInterface $response + * @return CacheEntry|null -> entry to save, null if can't cache it + * @throws \Exception + */ + protected function getCacheObject(RequestInterface $request, ResponseInterface $response) : ?CacheEntry { + if(!isset($this->statusAccepted[$response->getStatusCode()])){ + // Don't cache it + return null; + } + + if($response->hasHeader('Vary')){ + $varyHeader = \GuzzleHttp\Psr7\parse_header($response->getHeader('Vary')); + if(GuzzleCacheMiddleware::inArrayDeep($varyHeader, '*')){ + // This will never match with a request + return null; + } + } + + if($response->hasHeader('Cache-Control')){ + $cacheControlHeader = \GuzzleHttp\Psr7\parse_header($response->getHeader('Cache-Control')); + + if(GuzzleCacheMiddleware::inArrayDeep($cacheControlHeader, 'no-store')){ + // No store allowed (maybe some sensitives data...) + return null; + } + + if(GuzzleCacheMiddleware::inArrayDeep($cacheControlHeader, 'no-cache')){ + // Stale response see RFC7234 section 5.2.1.4 + $cacheEntry = new CacheEntry($request, $response, new \DateTime('-1 seconds')); + return $cacheEntry->hasValidationInformation() ? $cacheEntry : null; + } + + if($maxAge = (int)GuzzleCacheMiddleware::arrayKeyDeep($cacheControlHeader, 'max-age')){ + // Proper max-age send in response (preferred) + return new CacheEntry($request, $response, new \DateTime('+' . $maxAge . 'seconds')); + } + + if($response->hasHeader('Expires')){ + // Expire Header is the last possible header that effects caching (better to use max-age) + $expireAt = \DateTime::createFromFormat(\DateTime::RFC1123, $response->getHeaderLine('Expires')); + if($expireAt !== false){ + return new CacheEntry($request, $response, $expireAt); + } + } + } + + return new CacheEntry($request, $response, new \DateTime('-1 seconds')); + } + + /** + * Generate a key for the response cache + * @param RequestInterface $request + * @param array $varyHeaders $varyHeaders The vary headers which should be honoured by the cache (optional) + * @return string + */ + protected function getCacheKey(RequestInterface $request, array $varyHeaders = []){ + if(empty($varyHeaders)){ + return hash('sha256', $request->getMethod() . $request->getUri()); + } + + $cacheHeaders = []; + foreach($varyHeaders as $varyHeader){ + if($request->hasHeader($varyHeader)){ + $cacheHeaders[$varyHeader] = $request->getHeader($varyHeader); + } + } + + return hash('sha256', $request->getMethod() . $request->getUri() . json_encode($cacheHeaders)); + } + + /** + * Return a CacheEntry or null if no cache + * @param RequestInterface $request + * @return CacheEntry|null + */ + public function fetch(RequestInterface $request) : ?CacheEntry { + /** + * @var int|null $maxAge + */ + $maxAge = null; + if($request->hasHeader('Cache-Control')){ + $reqCacheControl = \GuzzleHttp\Psr7\parse_header($request->getHeader('Cache-Control')); + if(GuzzleCacheMiddleware::inArrayDeep($reqCacheControl, 'no-cache')){ + // Can't return cache + return null; + } + $maxAge = (int)GuzzleCacheMiddleware::arrayKeyDeep($reqCacheControl, 'max-age') ? : null; + }elseif($request->hasHeader('Pragma')){ + $pragma = \GuzzleHttp\Psr7\parse_header($request->getHeader('Pragma')); + if(GuzzleCacheMiddleware::inArrayDeep($pragma, 'no-cache')){ + // Can't return cache + return null; + } + } + + $cache = $this->storage->fetch($this->getCacheKey($request)); + + if(!is_null($cache)){ + $varyHeaders = $cache->getVaryHeaders(); + // vary headers exist from a previous response, check if we have a cache that matches those headers + if(!empty($varyHeaders)){ + $cache = $this->storage->fetch($this->getCacheKey($request, $varyHeaders)); + if(!$cache){ + return null; + } + } + + if((string)$cache->getOriginalRequest()->getUri() !== (string)$request->getUri()){ + return null; + } + + if(!is_null($maxAge)){ + if($cache->getAge() > $maxAge){ + // Cache entry is too old for the request requirements! + return null; + } + } + + if(!$cache->isVaryEquals($request)){ + return null; + } + } + return $cache; + } + + /** + * @param RequestInterface $request + * @param ResponseInterface $response + * @return bool true if success + * @throws \Exception + */ + public function cache(RequestInterface $request, ResponseInterface $response) : bool { + if($request->hasHeader('Cache-Control')){ + $reqCacheControl = \GuzzleHttp\Psr7\parse_header($request->getHeader('Cache-Control')); + if(GuzzleCacheMiddleware::inArrayDeep($reqCacheControl, 'no-store')){ + // No caching allowed + return false; + } + } + + $cacheObject = $this->getCacheObject($request, $response); + if(!is_null($cacheObject)){ + // store the cache against the URI-only key + $success = $this->storage->save($this->getCacheKey($request), $cacheObject); + + $varyHeaders = $cacheObject->getVaryHeaders(); + if(!empty($varyHeaders)){ + // also store the cache against the vary headers based key + $success = $this->storage->save($this->getCacheKey($request, $varyHeaders), $cacheObject); + } + return $success; + } + return false; + } + + /** + * @param RequestInterface $request + * @param ResponseInterface $response + * @return bool true if success + * @throws \Exception + */ + public function update(RequestInterface $request, ResponseInterface $response) : bool { + return $this->cache($request, $response); + } + + /** + * @param RequestInterface $request + * @return bool + */ + public function delete(RequestInterface $request) : bool { + return $this->storage->delete($this->getCacheKey($request)); + } +} \ No newline at end of file diff --git a/app/Lib/Middleware/GuzzleCacheMiddleware.php b/app/Lib/Middleware/GuzzleCacheMiddleware.php new file mode 100644 index 0000000..fbca37e --- /dev/null +++ b/app/Lib/Middleware/GuzzleCacheMiddleware.php @@ -0,0 +1,457 @@ + self::DEFAULT_CACHE_ENABLED, + 'cache_http_methods' => self::DEFAULT_CACHE_HTTP_METHODS, + 'cache_debug' => self::DEFAULT_CACHE_DEBUG, + 'cache_debug_header' => self::DEFAULT_CACHE_DEBUG_HEADER + ]; + + /** + * @var Promise[] + */ + protected $waitingRevalidate = []; + + /** + * @var Client + */ + protected $client; + + /** + * @var CacheStrategyInterface + */ + protected $cacheStrategy; + + /** + * @var callable + */ + private $nextHandler; + + /** + * GuzzleCacheMiddleware constructor. + * @param callable $nextHandler + * @param array $defaultOptions + * @param CacheStrategyInterface|null $cacheStrategy + */ + public function __construct(callable $nextHandler, array $defaultOptions = [], ?CacheStrategyInterface $cacheStrategy = null){ + $this->nextHandler = $nextHandler; + $this->defaultOptions = array_replace($this->defaultOptions, $defaultOptions); + + // if no CacheStrategyInterface defined + // -> use default PrivateCacheStrategy + $this->cacheStrategy = !is_null($cacheStrategy) ? $cacheStrategy : new PrivateCacheStrategy(); + + register_shutdown_function([$this, 'purgeReValidation']); + } + + /** + * Will be called at the end of the script + */ + public function purgeReValidation() : void { + \GuzzleHttp\Promise\inspect_all($this->waitingRevalidate); + } + + /** + * cache response data for successful requests + * -> load data from cache rather than sending the request + * @param RequestInterface $request + * @param array $options + * @return FulfilledPromise + * @throws \Exception + */ + public function __invoke(RequestInterface $request, array $options){ + // Combine options with defaults specified by this middleware + $options = array_replace($this->defaultOptions, $options); + + $next = $this->nextHandler; + + if(!$options['cache_enabled']){ + // middleware disabled -> skip + return $next($request, $options); + } + + // check if request HTTP Method can be cached ----------------------------------------------------------------- + if(!in_array(strtoupper($request->getMethod()), (array)$options['cache_http_methods'])){ + // No caching for this method allowed + return $next($request, $options)->then( + function(ResponseInterface $response) use ($options) { + return static::addDebugHeader($response, self::DEFAULT_CACHE_DEBUG_HEADER_MISS, $options); + } + ); + } + + // check if it´s is a re-validation request, so bypass the cache! --------------------------------------------- + if($request->hasHeader(self::DEFAULT_CACHE_RE_VALIDATION_HEADER)){ + // It's a re-validation request, so bypass the cache! + return $next($request->withoutHeader(self::DEFAULT_CACHE_RE_VALIDATION_HEADER), $options); + } + + // Retrieve information from request (Cache-Control) ---------------------------------------------------------- + $onlyFromCache = false; + $staleResponse = false; + $maxStaleCache = null; + $minFreshCache = null; + + if($request->hasHeader('Cache-Control')){ + $reqCacheControl = \GuzzleHttp\Psr7\parse_header($request->getHeader('Cache-Control')); + + if(GuzzleCacheMiddleware::inArrayDeep($reqCacheControl, 'only-if-cached')){ + $onlyFromCache = true; + } + if(GuzzleCacheMiddleware::inArrayDeep($reqCacheControl, 'max-stale')){ + $staleResponse = true; + } + if($maxStale = (int)GuzzleCacheMiddleware::arrayKeyDeep($reqCacheControl, 'max-stale')){ + $maxStaleCache = $maxStale; + } + if($minFresh = (int)GuzzleCacheMiddleware::arrayKeyDeep($reqCacheControl, 'min-fresh')){ + $minFreshCache = $minFresh; + } + } + + // If cache => return new FulfilledPromise(...) with response ------------------------------------------------- + $cacheEntry = $this->cacheStrategy->fetch($request); + + if($cacheEntry instanceof CacheEntry){ + $body = $cacheEntry->getResponse()->getBody(); + if($body->tell() > 0){ + $body->rewind(); + } + + if( + $cacheEntry->isFresh() && + ($minFreshCache === null || $cacheEntry->getStaleAge() + (int)$minFreshCache <= 0) + ){ + // Cache HIT! + return new FulfilledPromise( + static::addDebugHeader($cacheEntry->getResponse(), self::DEFAULT_CACHE_DEBUG_HEADER_HIT, $options) + ); + }elseif( + $staleResponse || + ($maxStaleCache !== null && $cacheEntry->getStaleAge() <= $maxStaleCache) + ){ + // Staled cache! + return new FulfilledPromise( + static::addDebugHeader($cacheEntry->getResponse(), self::DEFAULT_CACHE_DEBUG_HEADER_HIT, $options) + ); + }elseif($cacheEntry->hasValidationInformation() && !$onlyFromCache){ + // Re-validation header + $request = static::getRequestWithReValidationHeader($request, $cacheEntry); + + if($cacheEntry->staleWhileValidate()){ + static::addReValidationRequest($request, $this->cacheStrategy, $cacheEntry); + + return new FulfilledPromise( + static::addDebugHeader($cacheEntry->getResponse(), self::DEFAULT_CACHE_DEBUG_HEADER_STALE, $options) + ); + } + } + }else{ + $cacheEntry = null; + } + + // explicit asking of a cached response -> 504 ---------------------------------------------------------------- + if(is_null($cacheEntry) && $onlyFromCache){ + return new FulfilledPromise( + new Response(504) + ); + } + + return $next($request, $options)->then( + $this->onFulfilled($request, $cacheEntry, $options), + $this->onRejected($cacheEntry, $options) + ); + } + + /** + * No exceptions were thrown during processing + * @param RequestInterface $request + * @param CacheEntry $cacheEntry + * @param array $options + * @return \Closure + */ + protected function onFulfilled(RequestInterface $request, ?CacheEntry $cacheEntry, array $options) : \Closure { + return function (ResponseInterface $response) use ($request, $cacheEntry, $options) { + // Check if error and looking for a staled content -------------------------------------------------------- + if($response->getStatusCode() >= 500){ + $responseStale = static::getStaleResponse($cacheEntry, $options); + if($responseStale instanceof ResponseInterface){ + return $responseStale; + } + } + + $update = false; + + // check for "Not modified" -> cache entry is re-validate ------------------------------------------------- + if($response->getStatusCode() == 304 && $cacheEntry instanceof CacheEntry){ + $response = $response->withStatus($cacheEntry->getResponse()->getStatusCode()); + $response = $response->withBody($cacheEntry->getResponse()->getBody()); + $response = static::addDebugHeader($response, self::DEFAULT_CACHE_DEBUG_HEADER_HIT, $options); + + /** + * Merge headers of the "304 Not Modified" and the cache entry + * @var string $headerName + * @var string[] $headerValue + */ + foreach($cacheEntry->getOriginalResponse()->getHeaders() as $headerName => $headerValue){ + if(!$response->hasHeader($headerName) && $headerName !== $options['cache_debug_header']){ + $response = $response->withHeader($headerName, $headerValue); + } + } + + $update = true; + }else{ + $response = static::addDebugHeader($response, self::DEFAULT_CACHE_DEBUG_HEADER_MISS, $options); + } + + return static::addToCache($this->cacheStrategy, $request, $response, $update); + }; + } + + /** + * An exception or error was thrown during processing + * @param CacheEntry|null $cacheEntry + * @param array $options + * @return \Closure + */ + protected function onRejected(?CacheEntry $cacheEntry, array $options) : \Closure { + return function ($reason) use ($cacheEntry, $options){ + + if($reason instanceof TransferException){ + $response = static::getStaleResponse($cacheEntry, $options); + if(!is_null($response)){ + return $response; + } + } + + return new RejectedPromise($reason); + }; + } + + /** + * add debug HTTP header to $response + * -> Header can be checked whether a $response was cached or not + * @param ResponseInterface $response + * @param string $value + * @param array $options + * @return ResponseInterface + */ + protected static function addDebugHeader(ResponseInterface $response, string $value, array $options) : ResponseInterface { + if($options['cache_enabled'] && $options['cache_debug']){ + $response = $response->withHeader($options['cache_debug_header'], $value); + } + return $response; + } + + /** + * @param CacheStrategyInterface $cacheStrategy + * @param RequestInterface $request + * @param ResponseInterface $response + * @param bool $update + * @return ResponseInterface + */ + protected static function addToCache(CacheStrategyInterface $cacheStrategy, RequestInterface $request, ResponseInterface $response, $update = false) : ResponseInterface { + // If the body is not seekable, we have to replace it by a seekable one + if(!$response->getBody()->isSeekable()){ + $response = $response->withBody(\GuzzleHttp\Psr7\stream_for($response->getBody()->getContents())); + } + + if($update){ + $cacheStrategy->update($request, $response); + }else{ + $cacheStrategy->cache($request, $response); + } + + return $response; + } + + /** + * @param RequestInterface $request + * @param CacheStrategyInterface $cacheStrategy + * @param CacheEntry $cacheEntry + * @return bool if added + */ + protected function addReValidationRequest(RequestInterface $request, CacheStrategyInterface &$cacheStrategy, CacheEntry $cacheEntry) : bool { + // Add the promise for revalidate + if(!is_null($this->client)){ + $request = $request->withHeader(self::DEFAULT_CACHE_RE_VALIDATION_HEADER, '1'); + $this->waitingRevalidate[] = $this->client + ->sendAsync($request) + ->then(function(ResponseInterface $response) use ($request, &$cacheStrategy, $cacheEntry){ + $update = false; + if($response->getStatusCode() == 304){ + // Not modified => cache entry is re-validate + $response = $response->withStatus($cacheEntry->getResponse()->getStatusCode()); + $response = $response->withBody($cacheEntry->getResponse()->getBody()); + // Merge headers of the "304 Not Modified" and the cache entry + foreach($cacheEntry->getResponse()->getHeaders() as $headerName => $headerValue){ + if(!$response->hasHeader($headerName)){ + $response = $response->withHeader($headerName, $headerValue); + } + } + $update = true; + } + static::addToCache($cacheStrategy, $request, $response, $update); + }); + return true; + } + return false; + } + + /** + * @param CacheEntry|null $cacheEntry + * @param array $options + * @return ResponseInterface|null + * @throws \Exception + */ + protected static function getStaleResponse(?CacheEntry $cacheEntry, array $options) : ?ResponseInterface { + // Return staled cache entry if we can + if(!is_null($cacheEntry) && $cacheEntry->serveStaleIfError()){ + return static::addDebugHeader($cacheEntry->getResponse(), self::DEFAULT_CACHE_DEBUG_HEADER_STALE, $options); + } + return null; + } + + /** + * @param RequestInterface $request + * @param CacheEntry $cacheEntry + * @return RequestInterface + */ + protected static function getRequestWithReValidationHeader(RequestInterface $request, CacheEntry $cacheEntry) : RequestInterface { + if($cacheEntry->getResponse()->hasHeader('Last-Modified')){ + $request = $request->withHeader( + 'If-Modified-Since', + $cacheEntry->getResponse()->getHeader('Last-Modified') + ); + } + if($cacheEntry->getResponse()->hasHeader('Etag')){ + $request = $request->withHeader( + 'If-None-Match', + $cacheEntry->getResponse()->getHeader('Etag') + ); + } + return $request; + } + + /** + * check if $search value exists in "deep" nested Array + * @param array $array + * @param string $search + * @return bool + */ + public static function inArrayDeep(array $array, string $search) : bool { + $found = false; + array_walk($array, function($value, /** @noinspection PhpUnusedParameterInspection */ + $key, $search) use (&$found) { + if(!$found && is_array($value) && in_array($search, $value)){ + $found = true; + } + }, $search); + return $found; + } + + /** + * + * @param array $array + * @param string $searchKey + * @return string + */ + public static function arrayKeyDeep(array $array, string $searchKey) : string { + $found = ''; + array_walk($array, function($value, /** @noinspection PhpUnusedParameterInspection */ + $key, $searchKey) use (&$found) { + if(empty($found) && is_array($value) && array_key_exists($searchKey, $value)){ + $found = (string)$value[$searchKey]; + } + }, $searchKey); + return $found; + } + + /** + * flatten multidimensional array ignore keys + * @param array $array + * @return array + */ + public static function arrayFlattenByValue(array $array) : array { + $return = []; + array_walk_recursive($array, function($value) use (&$return) {$return[] = $value;}); + return $return; + } + + /** + * @param array $defaultOptions + * @param CacheStrategyInterface|null $cacheStrategy + * @return \Closure + */ + public static function factory(array $defaultOptions = [], ?CacheStrategyInterface $cacheStrategy = null) : \Closure { + return function(callable $handler) use ($defaultOptions, $cacheStrategy){ + return new static($handler, $defaultOptions, $cacheStrategy); + }; + } +} \ No newline at end of file diff --git a/app/Lib/Middleware/GuzzleCcpErrorLimitMiddleware.php b/app/Lib/Middleware/GuzzleCcpErrorLimitMiddleware.php new file mode 100644 index 0000000..d894f6a --- /dev/null +++ b/app/Lib/Middleware/GuzzleCcpErrorLimitMiddleware.php @@ -0,0 +1,306 @@ + CCP blocks endpoint -> after 100 error responses within 60s + * we log warnings for endpoints -> after 80 error responses within 60s + */ + const DEFAULT_LIMIT_COUNT_MAX = 80; + + /** + * default for: log error and block endpoint if + * -> less then 10 errors remain left in current error window + */ + const DEFAULT_LIMIT_COUNT_REMAIN = 10; + + /** + * default for: callback function for logging + */ + const DEFAULT_LOG_CALLBACK = null; + + /** + * default for: name for log file width "critical" error limit warnings + */ + const DEFAULT_LOG_FILE_CRITICAL = 'esi_resource_critical'; + + /** + * default for: name for log file with "blocked" errors + */ + const DEFAULT_LOG_FILE_BLOCKED = 'esi_resource_blocked'; + + /** + * error message for response HTTP header "x-esi-error-limited" - Blocked endpoint + */ + const ERROR_RESPONSE_BLOCKED = "Response error: Blocked for (%ss)"; + + /** + * error message for response HTTP header "x-esi-error-limit-remain" that: + * -> falls below "critical" DEFAULT_LIMIT_COUNT_REMAIN limit + */ + const ERROR_RESPONSE_LIMIT_BELOW = 'Response error: [%2s < %2s] Rate falls below critical limit. Blocked for (%ss)'; + + /** + * error message for response HTTP header "x-esi-error-limit-remain" that: + * -> exceed "critical" DEFAULT_LIMIT_COUNT_MAX limit + */ + const ERROR_RESPONSE_LIMIT_ABOVE = 'Response error: [%2s > %2s] Rate exceeded critical limit. Blocked for (%ss)'; + + /** + * default options can go here for middleware + * @var array + */ + private $defaultOptions = [ + 'ccp_limit_enabled' => self::DEFAULT_LIMIT_ENABLED, + 'ccp_limit_http_status' => self::DEFAULT_LIMIT_HTTP_STATUS, + 'ccp_limit_http_phrase' => self::DEFAULT_LIMIT_HTTP_PHRASE, + 'ccp_limit_error_count_max' => self::DEFAULT_LIMIT_COUNT_MAX, + 'ccp_limit_error_count_remain' => self::DEFAULT_LIMIT_COUNT_REMAIN, + 'ccp_limit_log_callback' => self::DEFAULT_LOG_CALLBACK, + 'ccp_limit_log_file_critical' => self::DEFAULT_LOG_FILE_CRITICAL, + 'ccp_limit_log_file_blocked' => self::DEFAULT_LOG_FILE_BLOCKED + ]; + + /** + * @var callable + */ + private $nextHandler; + + /** + * GuzzleCcpErrorLimitMiddleware constructor. + * @param callable $nextHandler + * @param array $defaultOptions + */ + public function __construct(callable $nextHandler, array $defaultOptions = []){ + $this->nextHandler = $nextHandler; + $this->defaultOptions = array_replace($this->defaultOptions, $defaultOptions); + } + + /** + * check error limits for requested URL (ESI specific response headers) + * @see https://developers.eveonline.com/blog/article/esi-error-limits-go-live + * @param RequestInterface $request + * @param array $options + * @throws \Psr\Cache\InvalidArgumentException + * @return mixed + */ + public function __invoke(RequestInterface $request, array $options){ + // Combine options with defaults specified by this middleware + $options = array_replace($this->defaultOptions, $options); + + $next = $this->nextHandler; + + if(!$options['ccp_limit_enabled']){ + // middleware disabled -> skip + return $next($request, $options); + } + + parent::__invoke($request, $options); + + // check if Request Endpoint is blocked + if(!is_null($blockedUntil = $this->isBlockedUntil($request))){ + + return new FulfilledPromise( + new Response( + $options['ccp_limit_http_status'], + [], + null, + '1.1', + $options['ccp_limit_http_phrase'] + ) + ); + } + + return $next($request, $options)->then( + $this->onFulfilled($request, $options) + ); + } + + /** + * No exceptions were thrown during processing + * @param RequestInterface $request + * @param array $options + * @return \Closure + */ + protected function onFulfilled(RequestInterface $request, array $options) : \Closure{ + return function (ResponseInterface $response) use ($request, $options) { + $statusCode = $response->getStatusCode(); + + // client or server error responses are relevant for error limits + // check for existing x-esi-error headers + if( + $statusCode >= 400 && $statusCode <= 599 && + $response->hasHeader('x-esi-error-limit-reset') + ){ + $esiErrorLimitReset = (int)$response->getHeaderLine('x-esi-error-limit-reset'); + + // get cache key from request URL + $cacheKey = $this->cacheKeyFromRequestUrl($request, self::CACHE_TAG_ERROR_LIMIT); + $cacheItem = $this->cache()->getItem($cacheKey); + $esiErrorRate = (array)$cacheItem->get(); + + // increase error count for this $url + $errorCount = (int)$esiErrorRate['count'] + 1; + $esiErrorRate['count'] = $errorCount; + + // default log data + $action = $level = $tag = $message = ''; + $esiErrorLimitRemain = 0; + $blockUrl = false; + + // check blocked HTTP Header -------------------------------------------------------------------------- + if($response->hasHeader('x-esi-error-limited')){ + // request url is blocked until new error limit becomes reset + // -> this should never happen + $blockUrl = true; + + $action = $options['ccp_limit_log_file_blocked']; + $level = 'alert'; + $tag = 'danger'; + $message = sprintf(self::ERROR_RESPONSE_BLOCKED, $esiErrorLimitReset); + + // the expected response HTTP status 420 is "unofficial", add proper phrase + $response = $response->withStatus($response->getStatusCode(), $options['ccp_limit_http_phrase']); + } + + // check limits HTTP Header --------------------------------------------------------------------------- + if( !$blockUrl && $response->hasHeader('x-esi-error-limit-remain')){ + // remaining errors left until reset/clear + $esiErrorLimitRemain = (int)$response->getHeaderLine('x-esi-error-limit-remain'); + + $belowCriticalLimit = $esiErrorLimitRemain < (int)$options['ccp_limit_error_count_remain']; + $aboveCriticalLimit = $errorCount > (int)$options['ccp_limit_error_count_max']; + + if($belowCriticalLimit){ + // ... falls below critical limit + // requests to this endpoint might be blocked soon! + // -> pre-block future requests to this endpoint on our side + // this should help to block requests for e.g. specific user + $blockUrl = true; + + $action = $options['ccp_limit_log_file_blocked']; + $level = 'alert'; + $tag = 'danger'; + $message = sprintf(self::ERROR_RESPONSE_LIMIT_BELOW, + $esiErrorLimitRemain, + $options['ccp_limit_error_count_remain'], + $esiErrorLimitReset + ); + }elseif($aboveCriticalLimit){ + // ... above critical limit + + $action = $options['ccp_limit_log_file_critical']; + $level = 'critical'; + $tag = 'warning'; + $message = sprintf(self::ERROR_RESPONSE_LIMIT_ABOVE, + $errorCount, + $options['ccp_limit_error_count_max'], + $esiErrorLimitReset + ); + } + } + + // log ------------------------------------------------------------------------------------------------ + if( + !empty($action) && + is_callable($log = $options['ccp_limit_log_callback']) + ){ + $logData = [ + 'url' => $request->getUri()->__toString(), + 'errorCount' => $errorCount, + 'esiLimitReset' => $esiErrorLimitReset, + 'esiLimitRemain' => $esiErrorLimitRemain + ]; + + $log($action, $level, $message, $logData, $tag); + } + + // update cache --------------------------------------------------------------------------------------- + if($blockUrl){ + // to many error, block uri until error limit reset + $esiErrorRate['blocked'] = true; + } + + $expiresAt = new \DateTime('+' . $esiErrorLimitReset . 'seconds'); + + // add expire time to cache item + // -> used to get left ttl for item + // and/or for throttle write logs + $esiErrorRate['expiresAt'] = $expiresAt; + + $cacheItem->set($esiErrorRate); + $cacheItem->expiresAt($expiresAt); + $this->cache()->save($cacheItem); + } + + return $response; + }; + } + + /** + * @param RequestInterface $request + * @return \DateTime|null + * @throws \Psr\Cache\InvalidArgumentException + */ + protected function isBlockedUntil(RequestInterface $request) : ?\DateTime { + $blockedUntil = null; + + $cacheKey = $this->cacheKeyFromRequestUrl($request, self::CACHE_TAG_ERROR_LIMIT); + $cacheItem = $this->cache()->getItem($cacheKey); + if($cacheItem->isHit()){ + // check if it is blocked + $esiErrorRate = (array)$cacheItem->get(); + if($esiErrorRate['blocked']){ + $blockedUntil = $esiErrorRate['expiresAt']; + } + } + + return $blockedUntil; + } + + /** + * @param array $defaultOptions + * @return \Closure + */ + public static function factory(array $defaultOptions = []) : \Closure { + return function(callable $handler) use ($defaultOptions){ + return new static($handler, $defaultOptions); + }; + } +} \ No newline at end of file diff --git a/app/Lib/Middleware/GuzzleCcpLogMiddleware.php b/app/Lib/Middleware/GuzzleCcpLogMiddleware.php new file mode 100644 index 0000000..1c5f2d0 --- /dev/null +++ b/app/Lib/Middleware/GuzzleCcpLogMiddleware.php @@ -0,0 +1,227 @@ + can be used to "exclude" some requests from been logged (e.g. on expected downtime) + */ + const DEFAULT_LOG_LOGGABLE_CALLBACK = null; + + /** + * default for: callback function for logging + */ + const DEFAULT_LOG_CALLBACK = null; + + /** + * default for: name for log file with endpoints marked as "legacy" in response Headers + */ + const DEFAULT_LOG_FILE_LEGACY = 'esi_resource_legacy'; + + /** + * default for: name for log file with endpoints marked as "deprecated" in response Headers + */ + const DEFAULT_LOG_FILE_DEPRECATED = 'esi_resource_deprecated'; + + /** + * error message for legacy endpoints + */ + const ERROR_RESOURCE_LEGACY = 'Resource has been marked as legacy'; + + /** + * error message for deprecated endpoints + */ + const ERROR_RESOURCE_DEPRECATED = 'Resource has been marked as deprecated'; + + /** + * default options can go here for middleware + * @var array + */ + private $defaultOptions = [ + 'ccp_log_enabled' => self::DEFAULT_LOG_ENABLED, + 'ccp_log_count_max' => self::DEFAULT_LOG_COUNT_MAX, + 'ccp_log_limit_count_ttl' => self::DEFAULT_LOG_LIMIT_COUNT_TTL, + 'ccp_log_loggable_callback' => self::DEFAULT_LOG_LOGGABLE_CALLBACK, + 'ccp_log_callback' => self::DEFAULT_LOG_CALLBACK, + 'ccp_log_file_legacy' => self::DEFAULT_LOG_FILE_LEGACY, + 'ccp_log_file_deprecated' => self::DEFAULT_LOG_FILE_DEPRECATED + ]; + + /** + * @var callable + */ + private $nextHandler; + + /** + * GuzzleCcpLogMiddleware constructor. + * @param callable $nextHandler + * @param array $defaultOptions + */ + public function __construct(callable $nextHandler, array $defaultOptions = []){ + $this->nextHandler = $nextHandler; + $this->defaultOptions = array_replace($this->defaultOptions, $defaultOptions); + } + + /** + * log warnings for some ESI specific response headers + * @param RequestInterface $request + * @param array $options + * @return mixed + */ + public function __invoke(RequestInterface $request, array $options){ + // Combine options with defaults specified by this middleware + $options = array_replace($this->defaultOptions, $options); + + $next = $this->nextHandler; + + if(!$options['ccp_log_enabled']){ + // middleware disabled -> skip + return $next($request, $options); + } + + parent::__invoke($request, $options); + + return $next($request, $options) + ->then( + $this->onFulfilled($request, $options) + ); + } + + /** + * No exceptions were thrown during processing + * + * @param RequestInterface $request + * @param array $options + * @return \Closure + */ + protected function onFulfilled(RequestInterface $request, array $options) : \Closure { + return function (ResponseInterface $response) use ($request, $options) { + + // check response for "warning" headers + if(!empty($value = $response->getHeaderLine('warning'))){ + // check header value for 199 code + if(preg_match('/199/i', $value)){ + // "legacy" warning found in response headers + if(is_callable($loggable = $options['ccp_log_loggable_callback']) ? $loggable($request) : (bool)$loggable){ + // warning for legacy endpoint -> check log limit (throttle) + if($this->isLoggableRequest($request, self::CACHE_TAG_LEGACY_LIMIT, $options)){ + if(is_callable($log = $options['ccp_log_callback'])){ + $logData = [ + 'url' => $request->getUri()->__toString() + ]; + + $log($options['ccp_log_file_legacy'], 'info', $value ? : self::ERROR_RESOURCE_LEGACY, $logData, 'information'); + } + } + } + } + + // check header value for 299 code + if(preg_match('/299/i', $value)){ + // "deprecated" warning found in response headers + if(is_callable($loggable = $options['ccp_log_loggable_callback']) ? $loggable($request) : (bool)$loggable){ + // warning for deprecated -> check log limit (throttle) + if($this->isLoggableRequest($request, self::CACHE_TAG_DEPRECATED_LIMIT, $options)){ + if(is_callable($log = $options['ccp_log_callback'])){ + $logData = [ + 'url' => $request->getUri()->__toString() + ]; + + $log($options['ccp_log_file_deprecated'], 'warning', $value ? : self::ERROR_RESOURCE_DEPRECATED, $logData, 'warning'); + } + } + } + } + } + + return $response; + }; + } + + /** + * checks whether a request should be logged or not + * -> if a request url is already logged with a certain $type, + * it will not get logged the next time until self::DEFAULT_LOG_LIMIT_COUNT_TTL + * expires (this helps to reduce log file I/O) + * @param RequestInterface $request + * @param string $tag + * @param array $options + * @return bool + * @throws \Psr\Cache\InvalidArgumentException + */ + protected function isLoggableRequest(RequestInterface $request, string $tag, array $options) : bool { + $loggable = false; + + $cacheKey = $this->cacheKeyFromRequestUrl($request, $tag); + $cacheItem = $this->cache()->getItem($cacheKey); + $legacyLimit = (array)$cacheItem->get(); + $count = (int)$legacyLimit['count']++; + + if($count < $options['ccp_log_count_max']){ + // loggable error count exceeded.. + $loggable = true; + + if(!$cacheItem->isHit()){ + $cacheItem->expiresAfter($options['ccp_log_limit_count_ttl']); + } + $cacheItem->set($legacyLimit); + $this->cache()->save($cacheItem); + } + + return $loggable; + } + + /** + * @param array $defaultOptions + * @return \Closure + */ + public static function factory(array $defaultOptions = []) : \Closure { + return function(callable $handler) use ($defaultOptions){ + return new static($handler, $defaultOptions); + }; + } +} \ No newline at end of file diff --git a/app/Lib/Middleware/GuzzleJsonMiddleware.php b/app/Lib/Middleware/GuzzleJsonMiddleware.php new file mode 100644 index 0000000..715bf5b --- /dev/null +++ b/app/Lib/Middleware/GuzzleJsonMiddleware.php @@ -0,0 +1,95 @@ + self::DEFAULT_JSON_ENABLED + ]; + + /** + * @var callable + */ + private $nextHandler; + + /** + * GuzzleJsonMiddleware constructor. + * @param callable $nextHandler + * @param array $defaultOptions + */ + public function __construct(callable $nextHandler, array $defaultOptions = []){ + $this->nextHandler = $nextHandler; + $this->defaultOptions = array_replace($this->defaultOptions, $defaultOptions); + } + + /** + * add "JSON support" for request + * -> add "Accept" header for requests + * -> wrap response in response with JsonStream body + * @param RequestInterface $request + * @param array $options + * @return mixed + */ + public function __invoke(RequestInterface $request, array $options){ + // Combine options with defaults specified by this middleware + $options = array_replace($this->defaultOptions, $options); + + $next = $this->nextHandler; + + // set "Accept" header json + if($options['json_enabled']){ + $request = $request->withHeader('Accept', 'application/json'); + } + + return $next($request, $options)->then( + $this->onFulfilled($request, $options) + ); + } + + /** + * No exceptions were thrown during processing + * @param RequestInterface $request + * @param array $options + * @return \Closure + */ + protected function onFulfilled(RequestInterface $request, array $options) : \Closure{ + return function (ResponseInterface $response) use ($request, $options){ + // decode Json response body + if($options['json_enabled']){ + $jsonStream = new JsonStream($response->getBody()); + $response = $response->withBody($jsonStream); + } + return $response; + }; + } + + /** + * @param array $defaultOptions + * @return \Closure + */ + public static function factory(array $defaultOptions = []) : \Closure { + return function(callable $handler) use ($defaultOptions){ + return new static($handler, $defaultOptions); + }; + } +} \ No newline at end of file diff --git a/app/Lib/Middleware/GuzzleLogMiddleware.php b/app/Lib/Middleware/GuzzleLogMiddleware.php new file mode 100644 index 0000000..1294b60 --- /dev/null +++ b/app/Lib/Middleware/GuzzleLogMiddleware.php @@ -0,0 +1,521 @@ + can be used to "exclude" some requests from been logged (e.g. on expected downtime) + */ + const DEFAULT_LOG_LOGGABLE_CALLBACK = null; + + /** + * default for: callback function for logging + */ + const DEFAULT_LOG_CALLBACK = null; + + /** + * default for: name for log file + */ + const DEFAULT_LOG_FILE = 'requests'; + + /** + * default options can go here for middleware + * @var array + */ + private $defaultOptions = [ + 'log_enabled' => self::DEFAULT_LOG_ENABLED, + 'log_format' => self::DEFAULT_LOG_FORMAT, + 'log_error' => self::DEFAULT_LOG_ERROR, + 'log_stats' => self::DEFAULT_LOG_STATS, + 'log_cache' => self::DEFAULT_LOG_CACHE, + 'log_cache_header' => self::DEFAULT_LOG_CACHE_HEADER, + 'log_5xx' => self::DEFAULT_LOG_5XX, + 'log_4xx' => self::DEFAULT_LOG_4XX, + 'log_3xx' => self::DEFAULT_LOG_3XX, + 'log_2xx' => self::DEFAULT_LOG_2XX, + 'log_1xx' => self::DEFAULT_LOG_1XX, + 'log_all_status' => self::DEFAULT_LOG_ALL_STATUS, + 'log_on_status' => self::DEFAULT_LOG_ON_STATUS, + 'log_off_status' => self::DEFAULT_LOG_OFF_STATUS, + 'log_loggable_callback' => self::DEFAULT_LOG_LOGGABLE_CALLBACK, + 'log_callback' => self::DEFAULT_LOG_CALLBACK, + 'log_file' => self::DEFAULT_LOG_FILE + ]; + + /** + * @var callable + */ + private $nextHandler; + + /** + * @var TransferStats|null + */ + private $stats = null; + + /** + * GuzzleLogMiddleware constructor. + * @param callable $nextHandler + * @param array $defaultOptions + */ + public function __construct(callable $nextHandler, array $defaultOptions = []){ + $this->nextHandler = $nextHandler; + $this->defaultOptions = $this->mergeOptions($this->defaultOptions, $defaultOptions); + } + + /** + * log errors for requested URL + * @param RequestInterface $request + * @param array $options + * @return mixed + */ + public function __invoke(RequestInterface $request, array $options){ + // Combine options with defaults specified by this middleware + $options = $this->mergeOptions($this->defaultOptions, $options); + + // deactivate this middleware a callback function is provided with response false + if( + $options['log_enabled'] && + is_callable($loggable = $options['log_loggable_callback']) + ){ + $options['log_enabled'] = $loggable($request); + } + + $next = $this->nextHandler; + + // reset TransferStats + $this->stats = null; + + // TransferStats can only be accessed through a callback -> 'on_stats' Core Guzzle option + if($options['log_enabled'] && $options['log_stats'] && !isset($options['on_stats'])){ + $options['on_stats'] = function(TransferStats $stats){ + $this->stats = $stats; + }; + } + + return $next($request, $options)->then( + $this->onFulfilled($request, $options), + $this->onRejected($request, $options) + ); + } + + /** + * No exceptions were thrown during processing + * @param RequestInterface $request + * @param array $options + * @return \Closure + */ + protected function onFulfilled(RequestInterface $request, array $options) : \Closure { + return function (ResponseInterface $response) use ($request, $options) { + + if($options['log_enabled']){ + $this->log($options, $request, $response); + } + + return $response; + }; + } + + /** + * An exception or error was thrown during processing + * @param RequestInterface $request + * @param array $options + * @return \Closure + */ + protected function onRejected(RequestInterface $request, array $options) : \Closure { + return function ($reason) use ($request, $options) { + if( + $options['log_enabled'] && + $reason instanceof \Exception + ){ + $response = null; + if(($reason instanceof RequestException) && $reason->hasResponse()){ + $response = $reason->getResponse(); + } + + $this->log($options, $request, $response, $reason); + } + + return \GuzzleHttp\Promise\rejection_for($reason); + }; + } + + /** + * log request and response data based on $option flags + * @param array $options + * @param RequestInterface $request + * @param ResponseInterface|null $response + * @param \Exception|null $exception + */ + protected function log(array $options, RequestInterface $request, ?ResponseInterface $response, ?\Exception $exception = null) : void { + $logData = []; + + $action = $options['log_file']; + $level = 'info'; + $tag = 'information'; + + $logError = $options['log_error'] && $exception instanceof \Exception; + $logRequestData = false; + + if($logError){ + // Either Guzzle Exception -> ConnectException or RequestException + // of any other Exception + $reasonData = $this->logReason($exception); + $logData['reason'] = $reasonData; + $logRequestData = true; + $level = 'critical'; + $tag = 'danger'; + } + + if(!is_null($response)){ + $statusCode = $response->getStatusCode(); + if($logError || $this->checkStatusCode($options, $statusCode)){ + $logData['response'] = $this->logResponse($response); + + if($options['log_cache']){ + $logData['cache'] = $this->logCache($response, $options); + } + + $logRequestData = true; + + // if Error -> do not change log $level and $tag + if(!$logError){ + if($this->is2xx($statusCode)){ + $level = 'info'; + $tag = 'success'; + }elseif($this->is4xx($statusCode)){ + $level = 'error'; + $tag = 'warning'; + }elseif($this->is5xx($statusCode)){ + $level = 'critical'; + $tag = 'warning'; + } + } + } + } + + if($logRequestData){ + $logData['request'] = $this->logRequest($request); + } + + // log stats in case other logData should be logged + if(!is_null($this->stats) && !empty($logData)){ + $logData['stats'] = $this->logStats($this->stats); + } + + if(!empty($logData) && is_callable($log = $options['log_callback'])){ + $log($action, $level, $this->getLogMessage($options['log_format'], $logData), $logData, $tag); + } + } + + /** + * log request + * @param RequestInterface $request + * @return array + */ + protected function logRequest(RequestInterface $request) : array { + return [ + 'method' => $request->getMethod(), + 'url' => $request->getUri()->__toString(), + 'host' => $request->getUri()->getHost(), + 'path' => $request->getUri()->getPath(), + 'target' => $request->getRequestTarget(), + 'version' => $request->getProtocolVersion() + ]; + } + + /** + * log response -> this might be a HTTP 1xx up to 5xx response + * @param ResponseInterface $response + * @return array + */ + protected function logResponse(ResponseInterface $response) : array { + // response body might contain additional error message + $errorMessage = $this->getErrorMessageFromResponseBody($response); + + return [ + 'code' => $response->getStatusCode(), + 'phrase' => $response->getReasonPhrase(), + 'version' => $response->getProtocolVersion(), + 'res_header_content-length' => $response->getHeaderLine('content-length'), + 'error_msg' => $errorMessage + ]; + } + + /** + * log reason -> rejected promise + * ConnectException or parent of type RequestException -> get error from HandlerContext + * any other Exception (no idea when this can happen) -> get error from Exception + * @param \Exception|null $exception + * @return array + */ + protected function logReason(?\Exception $exception) : array { + if( + ($exception instanceof RequestException) && + !empty($handlerContext = $exception->getHandlerContext()) + ){ + return [ + 'errno' => $handlerContext['errno'], + 'error' => $handlerContext['error'] + ]; + }else{ + // other Exception OR RequestException without handlerContext data + return [ + 'errno' => 'NULL', + 'error' => $exception->getMessage() + ]; + } + } + + /** + * log Cache information + * -> read from HTTP response headers -> set by GuzzleCacheMiddleware + * @param ResponseInterface $response + * @param array $options + * @return array + */ + protected function logCache(ResponseInterface $response, array $options) : array { + $cacheStatusHeader = 'NULL'; + + if($response->hasHeader($options['log_cache_header'])){ + $cacheStatusHeader = $response->getHeaderLine($options['log_cache_header']); + } + + return [ + 'status' => $cacheStatusHeader + ]; + } + + /** + * log transfer stats + * For debugging purpose + * @param TransferStats $stats + * @return array + */ + protected function logStats(TransferStats $stats) : array { + return [ + 'time' => (string)$stats->getTransferTime() + ]; + } + + /** + * Some APIs provide additional error information in response body + * E.g. is there is a HTTP 4xx/5xx $response, the body might have: + * -> A: JSON response: {"error":"Some error message"} + * -> B: TEXT response: 'Some error message' + * @param ResponseInterface $response + * @return string + */ + protected function getErrorMessageFromResponseBody(ResponseInterface $response) : string { + $error = ''; + + $body = $response->getBody(); + if($body->isReadable()){ + $contentTypeHeader = strtolower($response->getHeaderLine('Content-Type')); + if(strpos($contentTypeHeader, 'application/json') !== false){ + // we expect json encoded content + // -> check if $body is already wrapped in JsonStream (e.g. from previous Middlewares,..) + if(!($body instanceof JsonStreamInterface)){ + // ... create temp JsonStream + $jsonBody = new JsonStream($body); + $content = $jsonBody->getContents(); + }else{ + // ... already JsonStream -> get content + $content = $body->getContents(); + } + + // ... check if "error" key exists in content, with error message + if(is_string($content->error)){ + $error = $content->error; + } + }else{ + // no Json encoded content expected -> simple text + $error = $body->getContents(); + } + + // rewind $body for next access. !important! + $body->rewind(); + } + + return $error; + } + + /** + * check response HTTP Status code for logging + * @param array $options + * @param int $statusCode + * @return bool + */ + protected function checkStatusCode(array $options, int $statusCode) : bool { + if($options['log_all_status']){ + return true; + } + if(in_array($statusCode, (array)$options['log_off_status'])){ + return false; + } + if(in_array($statusCode, (array)$options['log_on_status'])){ + return true; + } + $statusLevel = (int)substr($statusCode, 0, 1); + return (bool)$options['log_' . $statusLevel . 'xx']; + } + + /** + * check HTTP Status for 2xx response + * @param int $statusCode + * @return bool + */ + protected function is2xx(int $statusCode) : bool { + return (int)substr($statusCode, 0, 1) === 2; + } + + /** + * check HTTP Status for 4xx response + * @param int $statusCode + * @return bool + */ + protected function is4xx(int $statusCode) : bool { + return (int)substr($statusCode, 0, 1) === 4; + } + + /** + * check HTTP Status for 5xx response + * @param int $statusCode + * @return bool + */ + protected function is5xx(int $statusCode) : bool { + return (int)substr($statusCode, 0, 1) === 5; + } + + /** + * get formatted log message from $logData + * @param string $message + * @param array $logData + * @return string + */ + protected function getLogMessage(string $message, array $logData = []) : string { + $replace = [ + '{method}' => $logData['request']['method'], + '{url}' => $logData['request']['url'], + '{host}' => $logData['request']['host'], + '{path}' => $logData['request']['path'], + '{target}' => $logData['request']['target'], + '{version}' => $logData['request']['version'], + + '{code}' => $logData['response']['code'] ? : 'NULL', + '{phrase}' => $logData['response']['phrase'] ? : '', + '{res_header_content-length}' => $logData['response']['res_header_content-length'] ? : 0 + ]; + + return str_replace(array_keys($replace), array_values($replace), $message); + } + + /** + * merge middleware options + * @param array $options + * @param array $optionsNew + * @return array + */ + protected function mergeOptions(array $options = [], array $optionsNew = []) : array { + // array options must be merged rather than replaced + $optionsNew['log_on_status'] = array_unique(array_merge((array)$options['log_on_status'], (array)$optionsNew['log_on_status'])); + $optionsNew['log_off_status'] = array_unique(array_merge((array)$options['log_off_status'], (array)$optionsNew['log_off_status'])); + + return array_replace($options, $optionsNew); + } + + /** + * @param array $defaultOptions + * @return \Closure + */ + public static function factory(array $defaultOptions = []) : \Closure { + return function(callable $handler) use ($defaultOptions){ + return new static($handler, $defaultOptions); + }; + } +} \ No newline at end of file diff --git a/app/Lib/Middleware/GuzzleRetryMiddleware.php b/app/Lib/Middleware/GuzzleRetryMiddleware.php new file mode 100644 index 0000000..dbf3cbe --- /dev/null +++ b/app/Lib/Middleware/GuzzleRetryMiddleware.php @@ -0,0 +1,171 @@ + can be used to "exclude" some requests from been logged (e.g. on expected downtime) + */ + const DEFAULT_RETRY_LOGGABLE_CALLBACK = null; + + /** + * default for: callback function for logging + */ + const DEFAULT_RETRY_LOG_CALLBACK = null; + + /** + * default for: name for log file + */ + const DEFAULT_RETRY_LOG_FILE = 'retry_requests'; + + /** + * default for: log message format + */ + const DEFAULT_RETRY_LOG_FORMAT = '[{attempt}/{maxRetry}] RETRY FAILED {method} {target} HTTP/{version} → {code} {phrase}'; + + /** + * default options can go here for middleware + * @var array + */ + private $defaultOptions = [ + 'retry_enabled' => self::DEFAULT_RETRY_ENABLED, + 'max_retry_attempts' => self::DEFAULT_RETRY_MAX_ATTEMPTS, + 'default_retry_multiplier' => self::DEFAULT_RETRY_MULTIPLIER, + 'retry_on_status' => self::DEFAULT_RETRY_ON_STATUS, + 'retry_on_timeout' => self::DEFAULT_RETRY_ON_TIMEOUT, + 'expose_retry_header' => self::DEFAULT_RETRY_EXPOSE_RETRY_HEADER, + + 'retry_log_error' => self::DEFAULT_RETRY_LOG_ERROR, + 'retry_loggable_callback' => self::DEFAULT_RETRY_LOGGABLE_CALLBACK, + 'retry_log_callback' => self::DEFAULT_RETRY_LOG_CALLBACK, + 'retry_log_file' => self::DEFAULT_RETRY_LOG_FILE, + 'retry_log_format' => self::DEFAULT_RETRY_LOG_FORMAT + ]; + + /** + * GuzzleRetryMiddleware constructor. + * @param callable $nextHandler + * @param array $defaultOptions + */ + public function __construct(callable $nextHandler, array $defaultOptions = []){ + if($defaultOptions['retry_log_error']){ + // add callback function for error logging + $defaultOptions['on_retry_callback'] = $this->retryCallback(); + } + + $this->defaultOptions = array_replace($this->defaultOptions, $defaultOptions); + + parent::__construct($nextHandler, $this->defaultOptions); + } + + /** + * get callback function for 'on_retry_callback' option + * @see https://packagist.org/packages/caseyamcl/guzzle_retry_middleware + * @return callable + */ + protected function retryCallback() : callable { + return function( + int $attemptNumber, + float $delay, + RequestInterface $request, + array $options, + ?ResponseInterface $response = null + ) : void { + if( + $options['retry_log_error'] && // log retry errors + ($attemptNumber >= $options['max_retry_attempts']) // retry limit reached + ){ + if( + (is_callable($isLoggable = $options['retry_loggable_callback']) ? $isLoggable($request) : true) && + is_callable($log = $options['retry_log_callback']) + ){ + $logData = [ + 'url' => $request->getUri()->__toString(), + 'retryAttempt' => $attemptNumber, + 'maxRetryAttempts' => $options['max_retry_attempts'], + 'delay' => $delay + ]; + + $message = $this->getLogMessage($options['retry_log_format'], $request, $attemptNumber, $options['max_retry_attempts'], $response); + + $log($options['retry_log_file'], 'critical', $message, $logData, 'warning'); + } + } + }; + } + + /** + * @param string $message + * @param RequestInterface $request + * @param int $attemptNumber + * @param int $maxRetryAttempts + * @param ResponseInterface|null $response + * @return string + */ + protected function getLogMessage(string $message, RequestInterface $request, int $attemptNumber, int $maxRetryAttempts, ?ResponseInterface $response = null) : string { + $replace = [ + '{attempt}' => $attemptNumber, + '{maxRetry}' => $maxRetryAttempts, + '{method}' => $request->getMethod(), + '{target}' => $request->getRequestTarget(), + '{version}' => $request->getProtocolVersion(), + + '{code}' => $response ? $response->getStatusCode() : 'NULL', + '{phrase}' => $response ? $response->getReasonPhrase() : '' + ]; + + return str_replace(array_keys($replace), array_values($replace), $message); + } +} \ No newline at end of file diff --git a/app/Lib/Stream/JsonStream.php b/app/Lib/Stream/JsonStream.php new file mode 100644 index 0000000..cdde65b --- /dev/null +++ b/app/Lib/Stream/JsonStream.php @@ -0,0 +1,58 @@ + therefore we make it accessible as traitGetContents() and call it from + // the new getContents() method + use StreamDecoratorTrait { + StreamDecoratorTrait::getContents as traitGetContents; + } + + /** + * @return mixed|string|null + */ + public function getContents(){ + $contents = $this->traitGetContents(); + + if($contents === ''){ + return null; + } + $decodedContents = \GuzzleHttp\json_decode($contents); + + if(json_last_error() !== JSON_ERROR_NONE){ + throw new \RuntimeException('Error trying to decode response: ' . json_last_error_msg()); + } + + return $decodedContents; + } + + /** + * we need to overwrite this because of Trait __toString() calls $this->Contents() which no longer returns a string + * @return string + */ + public function __toString(){ + try { + if($this->isSeekable()){ + $this->seek(0); + } + return $this->traitGetContents(); + }catch (\Exception $e){ + // Really, PHP? https://bugs.php.net/bug.php?id=53648 + trigger_error('StreamDecorator::__toString exception: ' + . (string) $e, E_USER_ERROR); + return ''; + } + } +} \ No newline at end of file diff --git a/app/Lib/Stream/JsonStreamInterface.php b/app/Lib/Stream/JsonStreamInterface.php new file mode 100644 index 0000000..9660db5 --- /dev/null +++ b/app/Lib/Stream/JsonStreamInterface.php @@ -0,0 +1,24 @@ +this is because CREST is not very stable - const RETRY_COUNT_MAX = 2; - - // loggable limits ------------------------------------------------------------------------------------------------ - // ESI endpoints that return warning headers (e.g. "resource_legacy", "resource_deprecated") will get logged - // To prevent big file I/O on these log files, errors get "throttled" and not all of them get logged - - // Time interval used for error inspection (seconds) - const LOGGABLE_COUNT_INTERVAL = 60; - - // Log first "2" errors that occur for an endpoint within "60" (LOGGABLE_COUNT_INTERVAL) seconds interval - const LOGGABLE_COUNT_MAX_URL = 2; - - - /** - * debugLevel used for internal error/warning logging - * @var int - */ - protected $debugLevel = ESI::DEFAULT_DEBUG_LEVEL; +/** + * Class WebClient + * @package Exodus4D\ESI\Lib + * @method Client send(RequestInterface $request, array $options = []) + */ +class WebClient { /** - * if true any ESI requests gets logged in log file - * @var bool + * @var Client|null */ - protected $debugLogRequests = ESI::DEFAULT_DEBUG_LOG_REQUESTS; - - public function __construct(int $debugLevel = ESI::DEFAULT_DEBUG_LEVEL, bool $debugLogRequests = ESI::DEFAULT_DEBUG_LOG_REQUESTS){ - $this->debugLevel = $debugLevel; - $this->debugLogRequests = $debugLogRequests; - } + private $client = null; /** - * parse array with HTTP header data - * @param array $headers - * @return array + * WebClient constructor. + * @param string $baseUri + * @param array $config + * @param \Closure|null $initStack modify handler Stack by ref */ - protected function parseHeaders(array $headers = []): array { - $parsedHeaders = []; - foreach($headers as $header){ - $parts = explode(':', $header, 2); - $parsedHeaders[strtolower(trim($parts[0]))] = isset($parts[1]) ? trim($parts[1]) : ''; + public function __construct(string $baseUri, array $config = [], ?\Closure $initStack = null){ + // use cURLHandler for all requests + $handler = new CurlHandler(); + // new Stack for the Handler, manages Middleware for requests + $stack = HandlerStack::create($handler); + + // init stack by reference + if(is_callable($initStack)){ + $initStack($stack); } - return $parsedHeaders; - } - /** - * @param array $headers - * @return int - */ - protected function getStatusCodeFromHeaders(array $headers = []): int { - $statusCode = 0; - foreach($headers as $key => $value){ - if(preg_match('/http\/1\.\d (\d{3}?)/i', $key, $matches)){ - $statusCode = (int)$matches[1]; - break; - } - } - return $statusCode; - } - - /** - * get HTTP status type from HTTP status code (e.g. 404 )> 'err_client') - * @param int $statusCode - * @return string - */ - protected function getStatusType(int $statusCode): string{ - $typeLevel = (int)substr($statusCode, 0, 1); - switch($typeLevel){ - case 1: - $statusType = 'info'; - break; - case 2: - $statusType = 'ok'; - break; - case 3: - $statusType = 'redirect'; - break; - case 4: - $statusType = 'err_client'; - break; - case 5: - $statusType = 'err_server'; - break; - default: - $statusType = 'unknown'; - } + // Client default configuration + $config['handler'] = $stack; + $config['base_uri'] = $baseUri; - return $statusType; + // init client + $this->client = new Client($config); } /** - * @param int $code * @param string $method - * @param string $url - * @param null $responseBody - * @return string - */ - protected function getErrorMessageFromJsonResponse(int $code, string $method, string $url, $responseBody = null):string { - $message = empty($responseBody->message) ? @constant('Base::HTTP_' . $code) : $responseBody->message; - $body = !is_null($responseBody) ? ' | body: ' . print_r($responseBody, true) : ''; - - return sprintf(self::ERROR_STATUS_LOG, $code, $message, $method, $url, $body); - } - - /** - * get Logger obj for given status type - * @param string $statusType - * @return \Log + * @param string $uri + * @return Request */ - public function getLogger(string $statusType): \Log{ - switch($statusType){ - case 'err_server': - $logfile = 'esi_error_server'; - break; - case 'err_client': - $logfile = 'esi_error_client'; - break; - case 'resource_legacy': - $logfile = 'esi_resource_legacy'; - break; - case 'resource_deprecated': - $logfile = 'esi_resource_deprecated'; - break; - case 'debug_request': - $logfile = 'esi_debug_request'; - break; - default: - $logfile = 'esi_error_unknown'; - } - return new \Log($logfile . '.log'); + public function newRequest(string $method, string $uri) : Request { + return new Request($method, $uri); } /** - * check response headers for warnings/errors and log them + * get new Response object + * @param int $status * @param array $headers - * @param string $url - */ - protected function checkResponseHeaders(array $headers, string $url){ - $statusCode = $this->getStatusCodeFromHeaders($headers); - - // check ESI warnings ----------------------------------------------------------------------------------------- - // extract ESI related headers - $warningHeaders = array_filter($headers, function($key){ - return preg_match('/^warning/i', $key); - }, ARRAY_FILTER_USE_KEY); - - if(count($warningHeaders)){ - // get "normalized" url path without params/placeholders - $urlPath = $this->getNormalizedUrlPath($url); - foreach($warningHeaders as $key => $value){ - if( preg_match('/^199/i', $value) && $this->isLoggable('legacy', $url) ){ - $this->getLogger('resource_legacy')->write(sprintf(self::ERROR_RESOURCE_LEGACY, $urlPath, $url, $key . ': ' . $value)); - } - if( preg_match('/^299/i', $value) && $this->isLoggable('deprecated', $url) ){ - $this->getLogger('resource_deprecated')->write(sprintf(self::ERROR_RESOURCE_DEPRECATED, $urlPath, $url, $key . ': ' . $value)); - } - } - } - - // check ESI error limits ------------------------------------------------------------------------------------- - if($statusCode >= 400 && $statusCode <= 599){ - // extract ESI related headers - $esiHeaders = array_filter($headers, function($key){ - return preg_match('/^x-esi-/i', $key); - }, ARRAY_FILTER_USE_KEY); - - if(array_key_exists('x-esi-error-limit-reset', $esiHeaders)){ - // time in seconds until current error limit "windows" reset - $esiErrorLimitReset = (int)$esiHeaders['x-esi-error-limit-reset']; - - // block further api calls for this URL until error limit is reset/clear - $blockUrl = false; - - // get "normalized" url path without params/placeholders - $urlPath = $this->getNormalizedUrlPath($url); - - $f3 = \Base::instance(); - if(!$f3->exists(self::CACHE_KEY_ERROR_LIMIT, $esiErrorRate)){ - $esiErrorRate = []; - } - // increase error count for this $url - $errorCount = (int)$esiErrorRate[$urlPath]['count'] + 1; - $esiErrorRate[$urlPath]['count'] = $errorCount; - - // sort by error count desc - uasort($esiErrorRate, function($a, $b) { - return $b['count'] <=> $a['count']; - }); - - if(array_key_exists('x-esi-error-limited', $esiHeaders)){ - // we are blocked until new error limit window opens this should never happen - $blockUrl = true; - $this->getLogger('err_server')->write(sprintf(self::ERROR_LIMIT_EXCEEDED, $esiErrorLimitReset)); - } - - if(array_key_exists('x-esi-error-limit-remain', $esiHeaders)){ - // remaining errors left until reset/clear - $esiErrorLimitRemain = (int)$esiHeaders['x-esi-error-limit-remain']; - - if( - $errorCount > self::ERROR_COUNT_MAX_URL || - $esiErrorLimitRemain < self::ERROR_COUNT_REMAIN_TOTAL - ){ - $blockUrl = true; - $this->getLogger('err_server')->write(sprintf(self::ERROR_LIMIT_CRITICAL, $urlPath, $errorCount, $esiErrorLimitRemain)); - } - } - - if($blockUrl){ - // to many error, block uri until error limit reset - $esiErrorRate[$urlPath]['blocked'] = true; - } - - $f3->set(self::CACHE_KEY_ERROR_LIMIT, $esiErrorRate, $esiErrorLimitReset); - } - } - } - - /** - * get URL path from $url, removes path IDs, parameters, scheme and domain - * @param $url - * @return string + * @param null $body + * @param string $version + * @param string|null $reason + * @return Response */ - protected function getNormalizedUrlPath($url): string { - return parse_url(strtok(preg_replace('/\/(\d+)\//', '/{x}/', $url), '?'), PHP_URL_PATH); + public function newResponse(int $status = 200, array $headers = [], $body = null, string $version = '1.1', ?string $reason = null) : Response { + return new Response($status, $headers, $body, $version, $reason); } /** - * @param string $type - * @param string $urlPath - * @return bool + * get error response with error message in body + * -> wraps a GuzzleException (or any other Exception) into an error response + * -> this class should handle any Exception thrown within Guzzle Context + * @see http://docs.guzzlephp.org/en/stable/quickstart.html#exceptions + * @param \Exception $e + * @param bool $json + * @return Response */ - protected function isLoggable(string $type, string $urlPath) : bool { - $loggable = false; - - $f3 = \Base::instance(); - if(!$f3->exists(self::CACHE_KEY_LOGGABLE_LIMIT, $loggableLimit)){ - $loggableLimit = []; + public function newErrorResponse(\Exception $e, bool $json = true) : Response { + $message = [get_class($e)]; + + if($e instanceof ConnectException){ + // hard fail! e.g. cURL connect error + $message[] = $e->getMessage(); + }elseif($e instanceof ClientException){ + // 4xx response (e.g. 404 URL not found) + $message[] = 'HTTP ' . $e->getCode(); + $message[] = $e->getMessage(); + }elseif($e instanceof ServerException){ + // 5xx response + $message[] = 'HTTP ' . $e->getCode(); + $message[] = $e->getMessage(); + }elseif($e instanceof RequestException){ + // hard fail! e.g. cURL errors (connection timeout, DNS errors, etc.) + $message[] = $e->getMessage(); + }elseif($e instanceof \Exception){ + // any other Exception type + $message[] = $e->getMessage(); } - // increase counter - $count = (int)$loggableLimit[$urlPath][$type]['count']++; + $body = (object)[]; + $body->error = implode(', ', $message); - // check counter for given $urlPath - if($count < self::LOGGABLE_COUNT_MAX_URL){ - // loggable error count exceeded... - $loggable = true; - $f3->set(self::CACHE_KEY_LOGGABLE_LIMIT, $loggableLimit, self::LOGGABLE_COUNT_INTERVAL); - } - - return $loggable; - } + $bodyStream = \GuzzleHttp\Psr7\stream_for(\GuzzleHttp\json_encode($body)); - /** - * check whether a HTTP request method is valid/given - * @param $method - * @return bool - */ - public function checkRequestMethod($method): bool { - $valid = false; - if( in_array($method, self::REQUEST_METHODS) ){ - $valid = true; - } - return $valid; - } - - /** - * check API url against blocked API endpoints blacklist - * @param string $url - * @return bool - */ - public function isBlockedUrl(string $url): bool { - $isBlocked = false; - $f3 = \Base::instance(); - if($ttlData = $f3->exists(self::CACHE_KEY_ERROR_LIMIT, $esiErrorRate)){ - // check url path if blocked - $urlPath = $this->getNormalizedUrlPath($url); - $esiErrorData = array_filter($esiErrorRate, function($value, $key) use (&$urlPath){ - return ($key === $urlPath && $value['blocked']); - }, ARRAY_FILTER_USE_BOTH); - - if(!empty($esiErrorData)){ - $isBlocked = true; - if($this->debugLevel === 3){ - // log debug information - $this->getLogger('err_server')->write(sprintf( - self::DEBUG_URI_BLOCKED, - $urlPath, - round($ttlData[0] + $ttlData[1] - time()) - )); - } - } + if($json){ + // use JsonStream for as body + $bodyStream = new JsonStream($bodyStream); } - return $isBlocked; - } + $response = $this->newResponse(); + $response = $response->withStatus(200, 'Error Response'); + $response = $response->withBody($bodyStream); - /** - * write request information into logfile - * @param string $url - * @param $response - */ - protected function logRequest(string $url, $response){ - if($this->debugLogRequests){ - $this->getLogger('debug_request')->write(sprintf(self::DEBUG_REQUEST, $url, print_r($response, true))); - } + return $response; } /** - * @param string $url - * @param array|null $options - * @param array $additionalOptions - * @param int $retryCount - * @return mixed|null + * pipe all functions right into the Client + * @param string $name + * @param array $arguments + * @return array|mixed */ - public function request( $url, array $options = null, $additionalOptions = [], $retryCount = 0){ - // retry same request until request limit is reached - $retry = false; - - $response = parent::request($url, $options); - - $this->logRequest($url, $response); - - $responseHeaders = (array)$response['headers']; - $responseBody = json_decode($response['body']); - - // make sure return type is correct - if( - !is_array($responseBody) && - !is_bool($responseBody) && - !($responseBody instanceof \stdClass) - ){ - $responseBody = null; - } - - if( !empty($responseHeaders)){ - $parsedResponseHeaders = $this->parseHeaders($responseHeaders); - // check response headers - $this->checkResponseHeaders($parsedResponseHeaders, $url); - $statusCode = $this->getStatusCodeFromHeaders($parsedResponseHeaders); - $statusType = $this->getStatusType($statusCode); - - switch($statusType){ - case 'info': // HTTP 1xx - case 'ok': // HTTP 2xx - break; - case 'err_client': // HTTP 4xx - if( !in_array($statusCode, (array)$additionalOptions['suppressHTTPLogging']) ){ - $errorMsg = $this->getErrorMessageFromJsonResponse( - $statusCode, - $options['method'], - $url, - $responseBody - ); - $this->getLogger($statusType)->write($errorMsg); - } - break; - case 'err_server': // HTTP 5xx - $retry = true; - - if( $retryCount == self::RETRY_COUNT_MAX ){ - $errorMsg = $this->getErrorMessageFromJsonResponse( - $statusCode, - $options['method'], - $url, - $responseBody - ); - $this->getLogger($statusType)->write($errorMsg); - - // trigger error - if($additionalOptions['suppressHTTPErrors'] !== true){ - $f3 = \Base::instance(); - $f3->error($statusCode, $errorMsg); - } - } - break; - default: - } + public function __call(string $name, array $arguments = []){ + $return = []; - if( - $retry && - $retryCount < self::RETRY_COUNT_MAX - ){ - $retryCount++; - $this->request($url, $options, $additionalOptions, $retryCount); + if(is_object($this->client)){ + if( method_exists($this->client, $name) ){ + $return = call_user_func_array([$this->client, $name], $arguments); } } - return $responseBody; + return $return; } } \ No newline at end of file diff --git a/app/Mapper/Alliance.php b/app/Mapper/Alliance.php index 7def7e5..9cbb5a2 100644 --- a/app/Mapper/Alliance.php +++ b/app/Mapper/Alliance.php @@ -12,6 +12,9 @@ class Alliance extends mapper\AbstractIterator { + /** + * @var array + */ protected static $map = [ 'id' => 'id', 'name' => 'name', diff --git a/app/Mapper/Character.php b/app/Mapper/Character.php index 3a666ba..5c3a661 100644 --- a/app/Mapper/Character.php +++ b/app/Mapper/Character.php @@ -13,6 +13,9 @@ class Character extends mapper\AbstractIterator { + /** + * @var array + */ protected static $map = [ 'id' => 'id', 'name' => 'name', diff --git a/app/Mapper/CharacterAffiliation.php b/app/Mapper/CharacterAffiliation.php index df69e31..3752daf 100644 --- a/app/Mapper/CharacterAffiliation.php +++ b/app/Mapper/CharacterAffiliation.php @@ -12,6 +12,9 @@ class CharacterAffiliation extends mapper\AbstractIterator { + /** + * @var array + */ protected static $map = [ 'character_id' => ['character' => 'id'], diff --git a/app/Mapper/Constellation.php b/app/Mapper/Constellation.php index 33005a3..a47057c 100644 --- a/app/Mapper/Constellation.php +++ b/app/Mapper/Constellation.php @@ -12,6 +12,9 @@ class Constellation extends mapper\AbstractIterator { + /** + * @var array + */ protected static $map = [ 'constellation_id' => 'id', 'name' => 'name', diff --git a/app/Mapper/Corporation.php b/app/Mapper/Corporation.php index 943f38c..f031bdc 100644 --- a/app/Mapper/Corporation.php +++ b/app/Mapper/Corporation.php @@ -12,6 +12,9 @@ class Corporation extends mapper\AbstractIterator { + /** + * @var array + */ protected static $map = [ 'id' => 'id', 'name' => 'name', diff --git a/app/Mapper/EsiStatus.php b/app/Mapper/EsiStatus.php new file mode 100644 index 0000000..66451cc --- /dev/null +++ b/app/Mapper/EsiStatus.php @@ -0,0 +1,41 @@ + 'endpoint', + 'method' => 'method', + 'route' => 'route', + 'status' => 'status', + 'tags' => 'tags' + ]; + + /** + * map iterator + * @return array + */ + public function getData(){ + + $normalize = function(\Iterator $iterator){ + return preg_replace('/\/\{(\w+)\}/', '/{x}', $iterator->current()); + }; + + self::$map['route'] = $normalize; + + return parent::getData(); + } + +} \ No newline at end of file diff --git a/app/Mapper/GitHub/Release.php b/app/Mapper/GitHub/Release.php new file mode 100644 index 0000000..296db4c --- /dev/null +++ b/app/Mapper/GitHub/Release.php @@ -0,0 +1,28 @@ + 'id', + 'name' => 'name', + 'prerelease' => 'prerelease', + 'published_at' => 'publishedAt', + 'html_url' => 'url', + 'tarball_url' => 'urlTarBall', + 'zipball_url' => 'urlZipBall', + 'body' => 'body' + ]; +} \ No newline at end of file diff --git a/app/Mapper/InventoryType.php b/app/Mapper/InventoryType.php index 97196c4..c3b329e 100644 --- a/app/Mapper/InventoryType.php +++ b/app/Mapper/InventoryType.php @@ -12,6 +12,9 @@ class InventoryType extends mapper\AbstractIterator { + /** + * @var array + */ protected static $map = [ 'id' => 'id', 'name' => 'name' diff --git a/app/Mapper/Location.php b/app/Mapper/Location.php index b689161..e62ff93 100644 --- a/app/Mapper/Location.php +++ b/app/Mapper/Location.php @@ -12,6 +12,9 @@ class Location extends mapper\AbstractIterator { + /** + * @var array + */ protected static $map = [ 'solar_system_id' => ['system' => 'id'], diff --git a/app/Mapper/Online.php b/app/Mapper/Online.php index 34a743a..584b3bb 100644 --- a/app/Mapper/Online.php +++ b/app/Mapper/Online.php @@ -1,7 +1,7 @@ 'online', 'logins' => 'logins' @@ -23,7 +26,7 @@ class Online extends mapper\AbstractIterator { */ public function getData(){ - $convertTime = function($iterator){ + $convertTime = function(\Iterator $iterator){ return (new \DateTime($iterator->current()))->format('Y-m-d H:i:s'); }; diff --git a/app/Mapper/Region.php b/app/Mapper/Region.php index 039f8ad..64fc8b5 100644 --- a/app/Mapper/Region.php +++ b/app/Mapper/Region.php @@ -1,7 +1,7 @@ 'id', 'name' => 'name', diff --git a/app/Mapper/Search.php b/app/Mapper/Search.php index 4a41de9..93135e8 100644 --- a/app/Mapper/Search.php +++ b/app/Mapper/Search.php @@ -1,7 +1,7 @@ 'agent', 'alliance' => 'alliance', diff --git a/app/Mapper/ServerStatus.php b/app/Mapper/ServerStatus.php index b4df324..000b284 100644 --- a/app/Mapper/ServerStatus.php +++ b/app/Mapper/ServerStatus.php @@ -12,6 +12,9 @@ class ServerStatus extends mapper\AbstractIterator { + /** + * @var array + */ protected static $map = [ 'start_time' => 'startTime', 'players' => 'playerCount', diff --git a/app/Mapper/Ship.php b/app/Mapper/Ship.php index b80cf90..0f679a1 100644 --- a/app/Mapper/Ship.php +++ b/app/Mapper/Ship.php @@ -12,6 +12,9 @@ class Ship extends mapper\AbstractIterator { + /** + * @var array + */ protected static $map = [ 'ship_type_id' => ['ship' => 'typeId'], diff --git a/app/Mapper/Sso/Access.php b/app/Mapper/Sso/Access.php new file mode 100644 index 0000000..4830332 --- /dev/null +++ b/app/Mapper/Sso/Access.php @@ -0,0 +1,24 @@ + 'accessToken', + 'token_type' => 'tokenType', + 'expires_in' => 'expiresIn', + 'refresh_token' => 'refreshToken' + ]; +} \ No newline at end of file diff --git a/app/Mapper/Sso/Character.php b/app/Mapper/Sso/Character.php new file mode 100644 index 0000000..2d1e46f --- /dev/null +++ b/app/Mapper/Sso/Character.php @@ -0,0 +1,27 @@ + 'characterId', + 'CharacterName' => 'characterName', + 'CharacterOwnerHash' => 'characterOwnerHash', + 'ExpiresOn' => 'expiresOn', + 'Scopes' => 'scopes', + 'TokenType' => 'tokenType', + 'IntellectualProperty' => 'intellectualProperty' + ]; +} \ No newline at end of file diff --git a/app/Mapper/Station.php b/app/Mapper/Station.php index a3580e3..255736e 100644 --- a/app/Mapper/Station.php +++ b/app/Mapper/Station.php @@ -12,6 +12,9 @@ class Station extends mapper\AbstractIterator { + /** + * @var array + */ protected static $map = [ 'id' => 'id', 'name' => 'name' diff --git a/app/Mapper/System.php b/app/Mapper/System.php index b77d6ea..aaf87ec 100644 --- a/app/Mapper/System.php +++ b/app/Mapper/System.php @@ -12,6 +12,9 @@ class System extends mapper\AbstractIterator { + /** + * @var array + */ protected static $map = [ 'id' => 'id', 'system_id' => 'id', diff --git a/app/Mapper/Universe/Category.php b/app/Mapper/Universe/Category.php index 00bd141..55afbaf 100644 --- a/app/Mapper/Universe/Category.php +++ b/app/Mapper/Universe/Category.php @@ -1,7 +1,7 @@ 'name', 'published' => 'published', diff --git a/app/Mapper/Universe/Group.php b/app/Mapper/Universe/Group.php index 9bbd021..a4cb035 100644 --- a/app/Mapper/Universe/Group.php +++ b/app/Mapper/Universe/Group.php @@ -1,7 +1,7 @@ 'name', 'published' => 'published', diff --git a/app/Mapper/Universe/Planet.php b/app/Mapper/Universe/Planet.php index ea1366e..23b69be 100644 --- a/app/Mapper/Universe/Planet.php +++ b/app/Mapper/Universe/Planet.php @@ -1,7 +1,7 @@ 'id', 'name' => 'name', diff --git a/app/Mapper/Universe/Star.php b/app/Mapper/Universe/Star.php index df25658..1263f7a 100644 --- a/app/Mapper/Universe/Star.php +++ b/app/Mapper/Universe/Star.php @@ -1,7 +1,7 @@ 'name', 'type_id' => 'typeId', diff --git a/app/Mapper/Universe/Stargate.php b/app/Mapper/Universe/Stargate.php index b2a4938..84475e4 100644 --- a/app/Mapper/Universe/Stargate.php +++ b/app/Mapper/Universe/Stargate.php @@ -1,7 +1,7 @@ 'id', 'name' => 'name', diff --git a/app/Mapper/Universe/Structure.php b/app/Mapper/Universe/Structure.php index 85cd59b..8f3f483 100644 --- a/app/Mapper/Universe/Structure.php +++ b/app/Mapper/Universe/Structure.php @@ -1,7 +1,7 @@ 'name', 'solar_system_id' => 'systemId', diff --git a/app/Mapper/Universe/Type.php b/app/Mapper/Universe/Type.php index 722eb84..a8ce1e9 100644 --- a/app/Mapper/Universe/Type.php +++ b/app/Mapper/Universe/Type.php @@ -1,7 +1,7 @@ 'id', 'name' => 'name', diff --git a/composer.json b/composer.json index 893d05b..869c84c 100644 --- a/composer.json +++ b/composer.json @@ -21,6 +21,9 @@ } }, "require": { - "php-64bit": ">=7.0" + "php-64bit": ">=7.1", + "guzzlehttp/guzzle": "6.3.*", + "caseyamcl/guzzle_retry_middleware": "2.2.*", + "cache/void-adapter": "1.0.*" } } \ No newline at end of file