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