Passed
Push — master ( c76028...d1b6b8 )
by Daimona
01:47
created

RequestBase::countResults()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 6
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

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