RequestBase::executeAsQuery()   B
last analyzed

Complexity

Conditions 8
Paths 13

Size

Total Lines 28
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Importance

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