Passed
Push — master ( 7ac7b3...59f1bb )
by Daimona
02:20
created

RequestBase::executeSingle()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 4
c 0
b 0
f 0
nc 1
nop 0
dl 0
loc 5
rs 10
1
<?php declare( strict_types=1 );
2
3
namespace BotRiconferme\Request;
4
5
use BadMethodCallException;
6
use BotRiconferme\Exception\APIRequestException;
7
use BotRiconferme\Exception\BlockedException;
8
use BotRiconferme\Exception\MissingPageException;
9
use BotRiconferme\Exception\PermissionDeniedException;
10
use BotRiconferme\Exception\ProtectedPageException;
11
use BotRiconferme\Exception\TimeoutException;
12
use Generator;
13
use Psr\Log\LoggerInterface;
14
use stdClass;
15
16
/**
17
 * Core wrapper for an API request. Current implementations use either cURL or file_get_contents
18
 */
19
abstract class RequestBase {
20
	protected const USER_AGENT = 'Daimona - BotRiconferme ' . BOT_VERSION .
21
		' (https://github.com/Daimona/BotRiconferme)';
22
	protected const HEADERS = [
23
		'Content-Type: application/x-www-form-urlencoded',
24
		'User-Agent: ' . self::USER_AGENT
25
	];
26
	// In seconds
27
	protected const MAXLAG = 5;
28
29
	protected const METHOD_GET = 'GET';
30
	protected const METHOD_POST = 'POST';
31
32
	/** @var string */
33
	protected $url;
34
	/** @var string[] */
35
	protected static $cookiesToSet;
36
	/**
37
	 * @var array
38
	 * @phan-var array<int|string|bool>
39
	 */
40
	protected $params;
41
	/** @var string */
42
	protected $method = self::METHOD_GET;
43
	/** @var string[] */
44
	protected $newCookies = [];
45
46
	/** @var LoggerInterface */
47
	protected $logger;
48
49
	/**
50
	 * @private Use RequestFactory
51
	 *
52
	 * @param LoggerInterface $logger
53
	 * @param array $params
54
	 * @phan-param array<int|string|bool> $params
55
	 * @param string $domain
56
	 */
57
	public function __construct( LoggerInterface $logger, array $params, string $domain ) {
58
		$this->logger = $logger;
59
		$this->params = [ 'format' => 'json' ] + $params;
60
		$this->url = $domain;
61
	}
62
63
	/**
64
	 * Set the method to POST
65
	 *
66
	 * @return self For chaining
67
	 */
68
	public function setPost() : self {
69
		$this->method = self::METHOD_POST;
70
		return $this;
71
	}
72
73
	/**
74
	 * Execute a query request
75
	 * @return Generator
76
	 */
77
	public function executeAsQuery() : Generator {
78
		if ( ( $this->params['action'] ?? false ) !== 'query' ) {
79
			throw new BadMethodCallException( 'Not an ApiQuery!' );
80
		}
81
		// TODO Is this always correct?
82
		$key = $this->params['list'] ?? 'pages';
83
		$curParams = $this->params;
84
		$lim = $this->parseLimit();
85
		do {
86
			$res = $this->makeRequestInternal( $curParams );
87
			$this->handleErrorAndWarnings( $res );
88
			yield from $key === 'pages' ? get_object_vars( $res->query->pages ) : $res->query->$key;
89
90
			// Assume that we have finished
91
			$finished = true;
92
			if ( isset( $res->continue ) ) {
93
				// This may indicate that we're not done...
94
				$curParams = get_object_vars( $res->continue ) + $curParams;
95
				$finished = false;
96
			}
97
			if ( $lim !== -1 ) {
98
				$count = $this->countQueryResults( $res, $key );
99
				if ( $count !== null && $count >= $lim ) {
100
					// Unless we're able to use a limit, and that limit was passed.
101
					$finished = true;
102
				}
103
			}
104
		} while ( !$finished );
105
	}
106
107
	/**
108
	 * Execute a request that doesn't need any continuation.
109
	 * @return stdClass
110
	 */
111
	public function executeSingle() : stdClass {
112
		$curParams = $this->params;
113
		$res = $this->makeRequestInternal( $curParams );
114
		$this->handleErrorAndWarnings( $res );
115
		return $res;
116
	}
117
118
	/**
119
	 * @return int
120
	 */
121
	private function parseLimit() : int {
122
		foreach ( $this->params as $name => $val ) {
123
			if ( substr( $name, -strlen( 'limit' ) ) === 'limit' ) {
124
				return $val === 'max' ? -1 : (int)$val;
125
			}
126
		}
127
		// Assume no limit
128
		return -1;
129
	}
130
131
	/**
132
	 * Try to count the amount of entries in a result.
133
	 *
134
	 * @param stdClass $res
135
	 * @param string $resKey
136
	 * @return int|null
137
	 */
138
	private function countQueryResults( stdClass $res, string $resKey ) : ?int {
139
		if ( !isset( $res->query->$resKey ) ) {
140
			return null;
141
		}
142
		if ( $resKey === 'pages' ) {
143
			if ( count( get_object_vars( $res->query->pages ) ) !== 1 ) {
144
				return null;
145
			}
146
			$pages = $res->query->pages;
147
			$firstPage = reset( $pages );
148
			// TODO Avoid special-casing this.
149
			if ( !isset( $firstPage->revisions ) ) {
150
				return null;
151
			}
152
			$actualList = $firstPage->revisions;
153
		} else {
154
			$actualList = $res->query->$resKey;
155
		}
156
		return count( $actualList );
157
	}
158
159
	/**
160
	 * Process parameters and call the actual request method
161
	 *
162
	 * @param array $params
163
	 * @phan-param array<int|string|bool> $params
164
	 * @return stdClass
165
	 */
166
	private function makeRequestInternal( array $params ) : stdClass {
167
		if ( $this->method === self::METHOD_POST ) {
168
			$params['maxlag'] = self::MAXLAG;
169
		}
170
		$query = http_build_query( $params );
171
172
		try {
173
			$body = $this->reallyMakeRequest( $query );
174
		} catch ( TimeoutException $e ) {
175
			$this->logger->warning( 'Retrying request after timeout' );
176
			$body = $this->reallyMakeRequest( $query );
177
		}
178
179
		$this->setCookies( $this->newCookies );
180
		return json_decode( $body );
181
	}
182
183
	/**
184
	 * Actual method which will make the request
185
	 *
186
	 * @param string $params
187
	 * @return string
188
	 */
189
	abstract protected function reallyMakeRequest( string $params ) : string;
190
191
	/**
192
	 * After a request, set cookies for the next ones
193
	 *
194
	 * @param string[] $cookies
195
	 */
196
	protected function setCookies( array $cookies ) : void {
197
		foreach ( $cookies as $cookie ) {
198
			$bits = explode( ';', $cookie );
199
			[ $name, $value ] = explode( '=', $bits[0] );
200
			self::$cookiesToSet[ $name ] = $value;
201
		}
202
	}
203
204
	/**
205
	 * Get a specific exception class depending on the error code
206
	 *
207
	 * @param stdClass $res
208
	 * @return APIRequestException
209
	 */
210
	private function getException( stdClass $res ) : APIRequestException {
211
		switch ( $res->error->code ) {
212
			case 'missingtitle':
213
				$ex = new MissingPageException;
214
				break;
215
			case 'protectedpage':
216
				$ex = new ProtectedPageException;
217
				break;
218
			case 'permissiondenied':
219
				$ex = new PermissionDeniedException( $res->error->info );
220
				break;
221
			case 'blocked':
222
				$ex = new BlockedException( $res->error->info );
223
				break;
224
			default:
225
				$ex = new APIRequestException( $res->error->code . ' - ' . $res->error->info );
226
		}
227
		return $ex;
228
	}
229
230
	/**
231
	 * Handle known warning and errors from an API request
232
	 *
233
	 * @param stdClass $res
234
	 * @throws APIRequestException
235
	 */
236
	protected function handleErrorAndWarnings( stdClass $res ) : void {
237
		if ( isset( $res->error ) ) {
238
			throw $this->getException( $res );
239
		}
240
		if ( isset( $res->warnings ) ) {
241
			$act = $this->params[ 'action' ];
242
			$warning = $res->warnings->$act ?? $res->warnings->main;
243
			throw new APIRequestException( reset( $warning ) );
244
		}
245
	}
246
247
	/**
248
	 * Get the headers to use for a new request
249
	 *
250
	 * @return string[]
251
	 */
252
	protected function getHeaders() : array {
253
		$ret = self::HEADERS;
254
		if ( self::$cookiesToSet ) {
255
			$cookies = [];
256
			foreach ( self::$cookiesToSet as $cname => $cval ) {
257
				$cookies[] = trim( "$cname=$cval" );
258
			}
259
			$ret[] = 'Cookie: ' . implode( '; ', $cookies );
260
		}
261
		return $ret;
262
	}
263
264
	/**
265
	 * Utility function to implode headers
266
	 *
267
	 * @param string[] $headers
268
	 * @return string
269
	 */
270
	protected function buildHeadersString( array $headers ) : string {
271
		$ret = '';
272
		foreach ( $headers as $header ) {
273
			$ret .= "$header\r\n";
274
		}
275
		return $ret;
276
	}
277
278
	/**
279
	 * @param string $actualParams
280
	 * @return string
281
	 */
282
	protected function getDebugURL( string $actualParams ) : string {
283
		return strpos( $this->url, 'login' ) !== false
284
			? '[Login request]'
285
			: "{$this->url}?$actualParams";
286
	}
287
}
288