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