Skip to content

Commit c2bc36b

Browse files
authored
Merge pull request #25 from VitexSoftware/5-support-rate-limiting-headers
Adds rate limiting mechanism with configurable storage
2 parents d882928 + 7340a0f commit c2bc36b

34 files changed

+1421
-145
lines changed

.openapi-generator/config.yaml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,33 @@ files:
2626
destinationFilename: Transactor.php
2727
bootstrap.php:
2828
folder: test
29+
RateLimiter.mustache:
30+
folder: lib/RateLimit
31+
destinationFilename: RateLimiter.php
32+
RateLimitStoreInterface.mustache:
33+
folder: lib/RateLimit
34+
destinationFilename: RateLimitStoreInterface.php
35+
SqlDialect.mustache:
36+
folder: lib/RateLimit
37+
destinationFilename: SqlDialect.php
38+
RateLimitMode.mustache:
39+
folder: lib/RateLimit
40+
destinationFilename: RateLimitMode.php
41+
RateLimitExceededException.mustache:
42+
folder: lib/RateLimit
43+
destinationFilename: RateLimitExceededException.php
44+
PdoRateLimitStore.mustache:
45+
folder: lib/RateLimit
46+
destinationFilename: PdoRateLimitStore.php
47+
JsonRateLimitStore.mustache:
48+
folder: lib/RateLimit
49+
destinationFilename: JsonRateLimitStore.php
50+
RateLimiterTest.mustache:
51+
folder: test/RateLimit
52+
destinationFilename: RateLimiterTest.php
53+
RateLimitStoreInterfaceTest.mustache:
54+
folder: test/RateLimit
55+
destinationFilename: RateLimitStoreInterfaceTest.php
56+
SqlDialectTest.mustache:
57+
folder: test/RateLimit
58+
destinationFilename: SqlDialectTest.php
Lines changed: 161 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,95 +1,120 @@
11
<?php
22

3+
declare(strict_types=1);
4+
35
/**
4-
*
6+
* This file is part of the MultiFlexi package
7+
*
8+
* https://github.com/VitexSoftware/php-vitexsoftware-rbczpremiumapi
59
*
6-
* @author Vitex <vitex@hippy.cz>
7-
* @copyright 2023 Vitex@hippy.cz (G)
8-
*
9-
* PHP 8
10+
* (c) Vítězslav Dvořák <http://vitexsoftware.com>
11+
*
12+
* For the full copyright and license information, please view the LICENSE
13+
* file that was distributed with this source code.
1014
*/
1115

1216
namespace VitexSoftware\Raiffeisenbank;
1317

18+
use VitexSoftware\Raiffeisenbank\RateLimit\RateLimiter;
19+
use VitexSoftware\Raiffeisenbank\RateLimit\RateLimitExceededException;
20+
1421
/**
15-
* Description of ApiClient
22+
* Description of ApiClient.
1623
*
1724
* @author vitex
1825
*/
1926
class ApiClient extends \GuzzleHttp\Client
2027
{
21-
2228
/**
2329
* ClientID obtained from Developer Portal - when you registered your app with us.
24-
* @var string
2530
*/
2631
protected string $xIBMClientId = '';
2732
2833
/**
29-
* the end IP address of the client application (no server) in IPv4 or IPv6
30-
* format. If the bank client (your user) uses a browser by which he
31-
* accesses your server app, we need to know the IP address of his browser.
32-
* Always provide the closest IP address to the real end-user possible.
33-
* (optional)
34-
*
35-
* @var string
34+
* the end IP address of the client application (no server) in IPv4 or IPv6
35+
* format. If the bank client (your user) uses a browser by which he
36+
* accesses your server app, we need to know the IP address of his browser.
37+
* Always provide the closest IP address to the real end-user possible.
38+
* (optional).
3639
*/
3740
protected string $pSUIPAddress = '';
3841
3942
/**
4043
* Use mocking for api calls ?
41-
* @var boolean
4244
*/
4345
protected bool $mockMode = false;
46+
private RateLimiter $rateLimiter;
4447
4548
/**
46-
* @inheritDoc
47-
*
48-
* $config['clientid'] - obtained from Developer Portal - when you registered your app with us.
49-
* $config['cert'] = ['/path/to/cert.p12','certificat password']
50-
* $config['clientpubip'] = the closest IP address to the real end-user
51-
* $config['mocking'] = true to use /rbcz/premium/mock/* endpoints
52-
*
53-
* @param array $config
54-
* @throws \Exception CERT_FILE is not set
55-
* @throws \Exception CERT_PASS is not set
49+
* Initialize the API client with configuration, certificate validation, and a rate limiter.
50+
*
51+
* Accepted $config keys:
52+
* - 'clientid': client ID from the Developer Portal.
53+
* - 'cert': array with [pathToP12, password]; when omitted, CERT_FILE and CERT_PASS configuration values are used.
54+
* - 'clientpubip': client public IP (nearest to the end user).
55+
* - 'mocking': bool to enable mock endpoints.
56+
* - 'debug': debug flag.
57+
*
58+
* @param array $config client configuration
59+
* - 'clientid': client ID from Developer Portal
60+
* - 'cert': [path, password]
61+
* - 'clientpubip': client public IP
62+
* - 'mocking': bool
63+
* - 'debug': bool
64+
* - 'rate_limit_store': RateLimitStoreInterface instance (optional)
65+
* - 'rate_limit_wait': bool - wait when limited (default true)
66+
*
67+
* @throws \Exception if certificate file path (CERT_FILE) is not provided
68+
* @throws \Exception if certificate password (CERT_PASS) is not provided
5669
*/
5770
public function __construct(array $config = [])
5871
{
59-
if (array_key_exists('clientid', $config) === false) {
72+
if (\array_key_exists('clientid', $config) === false) {
6073
$this->xIBMClientId = \Ease\Shared::cfg('XIBMCLIENTID');
6174
} else {
6275
$this->xIBMClientId = $config['clientid'];
6376
}
6477

65-
if (array_key_exists('cert', $config) === false) {
78+
if (\array_key_exists('cert', $config) === false) {
6679
$config['cert'] = [\Ease\Shared::cfg('CERT_FILE'), \Ease\Shared::cfg('CERT_PASS')];
80+
6781
if (empty($config['cert'][0])) {
6882
throw new \Exception('Certificate (CERT_FILE) not specified');
6983
}
84+
7085
if (empty($config['cert'][1])) {
7186
throw new \Exception('Certificate password (CERT_PASS) not specified');
7287
}
7388
}
7489

75-
if (array_key_exists('debug', $config) === false) {
90+
if (\array_key_exists('debug', $config) === false) {
7691
$config['debug'] = \Ease\Shared::cfg('API_DEBUG', false);
77-
}
78-
79-
if (array_key_exists('clientpubip', $config)){
92+
}
93+
94+
if (\array_key_exists('clientpubip', $config)) {
8095
$this->pSUIPAddress = $config['clientpubip'];
8196
}
8297

83-
if (array_key_exists('mocking', $config)){
84-
$this->mockMode = boolval($config['mocking']);
98+
if (\array_key_exists('mocking', $config)) {
99+
$this->mockMode = (bool) $config['mocking'];
100+
}
101+
102+
if (isset($config['rate_limit_store'])) {
103+
$limitStore = $config['rate_limit_store'];
104+
} else {
105+
$path = $config['rate_limit_path'] ?? sys_get_temp_dir().'/rbczpremiumapi_rates.json';
106+
$limitStore = new RateLimit\JsonRateLimitStore($path);
85107
}
86-
108+
109+
$waitMode = $config['rate_limit_wait'] ?? true;
110+
$this->rateLimiter = new RateLimiter($limitStore, $waitMode);
111+
87112
parent::__construct($config);
88113
}
89114

90115
/**
91-
* ClientID obtained from Developer Portal
92-
*
116+
* ClientID obtained from Developer Portal.
117+
*
93118
* @return string
94119
*/
95120
public function getXIBMClientId()
@@ -98,102 +123,158 @@ class ApiClient extends \GuzzleHttp\Client
98123
}
99124

100125
/**
101-
* Keep user public IP here
102-
*
126+
* Keep user public IP here.
127+
*
103128
* @return string
104129
*/
105130
public function getpSUIPAddress()
106131
{
107132
return $this->pSUIPAddress;
108-
}
133+
}
109134

110135
/**
111136
* Use mocking uri for api calls ?
112-
*
113-
* @return boolean
137+
*
138+
* @return bool
114139
*/
115140
public function getMockMode()
116141
{
117142
return $this->mockMode;
118143
}
119-
144+
120145
/**
121-
* Obtain Your current Public IP
122-
*
146+
* Obtain Your current Public IP.
147+
*
123148
* @deprecated since version 0.1 - Do not use in production Environment!
124-
*
149+
*
125150
* @return string
126151
*/
127152
public static function getPublicIP()
128153
{
129154
$curl = curl_init();
130-
curl_setopt($curl, CURLOPT_URL, "http://httpbin.org/ip");
131-
curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);
155+
curl_setopt($curl, \CURLOPT_URL, 'http://httpbin.org/ip');
156+
curl_setopt($curl, \CURLOPT_RETURNTRANSFER, 1);
132157
$output = curl_exec($curl);
133158
curl_close($curl);
134159
$ip = json_decode($output, true);
160+
135161
return $ip['origin'];
136162
}
137-
163+
138164
/**
139-
* Source Identifier
140-
*
165+
* Source Identifier.
166+
*
141167
* @deprecated since version 0.1 - Do not use in production Environment!
142-
*
168+
*
143169
* @return string
144170
*/
145171
public static function sourceString()
146172
{
147-
return substr(__FILE__ . '@' . gethostname(), -50);
173+
return substr(__FILE__.'@'.gethostname(), -50);
148174
}
149175

150176
/**
151-
* Try to check certificate readibilty
152-
*
153-
* @throws Exception - Certificate file not found
154-
*
155-
* @param string $certFile path to certificate
156-
* @param boolean $die throw exception or return false ?
157-
*
158-
* @return boolean certificate file
177+
* Try to check certificate readibilty.
178+
*
179+
* @param string $certFile path to certificate
180+
* @param bool $die throw exception or return false ?
181+
*
182+
* @throws \Exception - Certificate file not found
183+
*
184+
* @return bool certificate file
159185
*/
160186
public static function checkCertificatePresence(string $certFile, bool $die = false): bool
161187
{
162188
$found = false;
189+
163190
if ((file_exists($certFile) === false) || (is_readable($certFile) === false)) {
164-
$errMsg = 'Cannot read specified certificate file: ' . $certFile;
165-
fwrite(STDERR, $errMsg . PHP_EOL);
166-
if ($die){
191+
$errMsg = 'Cannot read specified certificate file: '.$certFile;
192+
fwrite(\STDERR, $errMsg.\PHP_EOL);
193+
194+
if ($die) {
167195
throw new \Exception($errMsg);
168196
}
169197
} else {
170198
$found = true;
171199
}
172-
return $found;
200+
201+
return $found;
173202
}
174-
175-
public static function checkCertificate($certFile,$password): bool {
176-
return self::checkCertificatePresence($certFile) && self::checkCertificatePassword($certFile,$password);
203+
204+
public static function checkCertificate(string $certFile, string $password): bool
205+
{
206+
return self::checkCertificatePresence($certFile) && self::checkCertificatePassword($certFile, $password);
177207
}
178-
179-
public static function checkCertificatePassword(string $certFile, string $password): bool {
208+
209+
public static function checkCertificatePassword(string $certFile, string $password): bool
210+
{
180211
$certContent = file_get_contents($certFile);
212+
181213
if (openssl_pkcs12_read($certContent, $certs, $password) === false) {
182214
fwrite(\STDERR, 'Cannot read PKCS12 certificate file: '.$certFile.\PHP_EOL);
183-
exit(1);
215+
216+
throw new \Exception('Cannot read PKCS12 certificate file: '.$certFile);
184217
}
218+
185219
return true;
186220
}
187-
188-
221+
189222
/**
190-
* Request Identifier
191-
*
192-
* @deprecated since version 0.1 - Do not use in production Environment!
193-
*
194-
* @return string
223+
* Produce a short request identifier used for diagnostics and testing.
224+
*
225+
* @deprecated since version 0.1 Do not use in production environments.
226+
*
227+
* @return string the generated request identifier composed from a source token and the current timestamp, truncated to at most 59 characters
195228
*/
196-
public static function getxRequestId() {
197-
return substr(self::sourceString() . '#' . time(),-59);
229+
public static function getxRequestId()
230+
{
231+
return substr(self::sourceString().'#'.time(), -59);
232+
}
233+
234+
/**
235+
* Send an HTTP request while enforcing and updating client rate limits.
236+
*
237+
* @param \Psr\Http\Message\RequestInterface $request the HTTP request to send
238+
* @param array $options Request options to apply to the transfer. See \GuzzleHttp\RequestOptions.
239+
*
240+
* @throws \GuzzleHttp\Exception\GuzzleException
241+
* @throws RateLimitExceededException if the client is rate limited and wait mode is disabled
242+
*
243+
* @return \Psr\Http\Message\ResponseInterface the HTTP response
244+
*/
245+
public function send(\Psr\Http\Message\RequestInterface $request, array $options = []): \Psr\Http\Message\ResponseInterface
246+
{
247+
$this->rateLimiter->checkBeforeRequest($this->xIBMClientId);
248+
249+
$response = parent::send($request, $options);
250+
251+
$this->updateRateLimitsFromResponse($response);
252+
253+
$statusCode = $response->getStatusCode();
254+
255+
if ($statusCode === 429) { // 429 Too Many Requests
256+
if ($this->rateLimiter->isWaitMode()) {
257+
$this->rateLimiter->checkBeforeRequest($this->xIBMClientId);
258+
$response = parent::send($request, $options);
259+
260+
$this->updateRateLimitsFromResponse($response);
261+
262+
$statusCode = $response->getStatusCode();
263+
} else {
264+
throw new RateLimitExceededException('Rate limit exceeded (HTTP 429)');
265+
}
266+
}
267+
268+
return $response;
269+
}
270+
271+
private function updateRateLimitsFromResponse(\Psr\Http\Message\ResponseInterface $response): void
272+
{
273+
if ($response->hasHeader('x-ratelimit-remaining-second') && $response->hasHeader('x-ratelimit-remaining-day')) {
274+
$remainingSecond = (int) $response->getHeaderLine('x-ratelimit-remaining-second');
275+
$remainingDay = (int) $response->getHeaderLine('x-ratelimit-remaining-day');
276+
$timestamp = time();
277+
$this->rateLimiter->handleRateLimits($this->xIBMClientId, $remainingSecond, $remainingDay, $timestamp);
278+
}
198279
}
199280
}

0 commit comments

Comments
 (0)