Completed
Pull Request — master (#41)
by Albert
05:40
created

MediawikiApi   C

Complexity

Total Complexity 55

Size/Duplication

Total Lines 512
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 14

Test Coverage

Coverage 87.5%

Importance

Changes 19
Bugs 0 Features 0
Metric Value
wmc 55
c 19
b 0
f 0
lcom 1
cbo 14
dl 0
loc 512
ccs 154
cts 176
cp 0.875
rs 6.8

27 Methods

Rating   Name   Duplication   Size   Complexity  
A newFromApiEndpoint() 0 3 1
A getApiUrl() 0 3 1
A setLogger() 0 4 1
A decodeResponse() 0 8 1
A __construct() 0 15 3
A getClient() 0 8 2
A getRequestAsync() 0 11 1
A postRequestAsync() 0 11 1
A getRequest() 0 9 1
A postRequest() 0 9 1
B newFromPage() 0 32 3
A getPostRequestEncoding() 0 11 4
A getClientRequestOptions() 0 11 2
A encodeMultipartParams() 0 21 3
A getDefaultHeaders() 0 5 1
A getUserAgent() 0 7 2
B logWarnings() 0 17 6
A logWarning() 0 21 4
A throwUsageExceptions() 0 9 3
A isLoggedin() 0 3 1
A login() 0 18 3
A getLoginParams() 0 11 2
A throwLoginUsageException() 0 11 2
A logout() 0 10 2
A getToken() 0 3 1
A clearTokens() 0 3 1
A getVersion() 0 15 2

How to fix   Complexity   

Complex Class

Complex classes like MediawikiApi often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use MediawikiApi, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace Mediawiki\Api;
4
5
use DOMDocument;
6
use DOMXPath;
7
use GuzzleHttp\Client;
8
use GuzzleHttp\ClientInterface;
9
use GuzzleHttp\Exception\RequestException;
10
use GuzzleHttp\Promise\PromiseInterface;
11
use InvalidArgumentException;
12
use Mediawiki\Api\Guzzle\ClientFactory;
13
use Psr\Http\Message\ResponseInterface;
14
use Psr\Log\LoggerAwareInterface;
15
use Psr\Log\LoggerInterface;
16
use Psr\Log\LogLevel;
17
use Psr\Log\NullLogger;
18
use SimpleXMLElement;
19
20
/**
21
 * Main class for this library
22
 *
23
 * @since 0.1
24
 *
25
 * @author Addshore
26
 */
27
class MediawikiApi implements MediawikiApiInterface, LoggerAwareInterface {
28
29
	/**
30
	 * @var ClientInterface|null Should be accessed through getClient
31
	 */
32
	private $client = null;
33
34
	/**
35
	 * @var bool|string
36
	 */
37
	private $isLoggedIn;
38
39
	/**
40
	 * @var MediawikiSession
41
	 */
42
	private $session;
43
44
	/**
45
	 * @var string
46
	 */
47
	private $version;
48
49
	/**
50
	 * @var LoggerInterface
51
	 */
52
	private $logger;
53
54
	/**
55
	 * @var string
56
	 */
57
	private $apiUrl;
58
59
	/**
60
	 * @since 2.0
61
	 *
62
	 * @param string $apiEndpoint e.g. https://en.wikipedia.org/w/api.php
63
	 *
64
	 * @return self returns a MediawikiApi instance using $apiEndpoint
65
	 */
66
	public static function newFromApiEndpoint( $apiEndpoint ) {
67
		return new self( $apiEndpoint );
68
	}
69
70
	/**
71
	 * Create a new MediawikiApi object from a URL to any page in a MediaWiki website.
72
	 *
73
	 * @since 2.0
74
	 * @see https://en.wikipedia.org/wiki/Really_Simple_Discovery
75
	 *
76
	 * @param string $url e.g. https://en.wikipedia.org OR https://de.wikipedia.org/wiki/Berlin
77
	 * @return self returns a MediawikiApi instance using the apiEndpoint provided by the RSD
78
	 *              file accessible on all Mediawiki pages
79
	 * @throws RsdException If the RSD URL could not be found in the page's HTML.
80
	 */
81 3
	public static function newFromPage( $url ) {
82
		// Set up HTTP client and HTML document.
83 3
		$tempClient = new Client( [ 'headers' => [ 'User-Agent' => 'addwiki-mediawiki-client' ] ] );
84 3
		$pageHtml = $tempClient->get( $url )->getBody();
85 3
		$pageDoc = new DOMDocument();
86
87
		// Try to load the HTML (turn off errors temporarily; most don't matter, and if they do get
88
		// in the way of finding the API URL, will be reported in the RsdException below).
89 3
		$internalErrors = libxml_use_internal_errors( true );
90 3
		$pageDoc->loadHTML( $pageHtml );
91 3
		$libXmlErrors = libxml_get_errors();
92 3
		libxml_use_internal_errors( $internalErrors );
93
94
		// Extract the RSD link.
95 3
		$xpath = 'head/link[@type="application/rsd+xml"][@href]';
96 3
		$link = ( new DOMXpath( $pageDoc ) )->query( $xpath );
97 3
		if ( $link->length === 0 ) {
98
			// Format libxml errors for display.
99
			$libXmlErrorStr = array_reduce( $libXmlErrors, function ( $prevErr, $err ) {
100
				return $prevErr . ', ' . $err->message . ' (line '.$err->line . ')';
101 1
			} );
102 1
			if ( $libXmlErrorStr ) {
103
				$libXmlErrorStr = "In addition, libxml had the following errors: $libXmlErrorStr";
104
			}
105 1
			throw new RsdException( "Unable to find RSD URL in page: $url $libXmlErrorStr" );
106
		}
107 2
		$rsdUrl = $link->item( 0 )->attributes->getnamedItem( 'href' )->nodeValue;
108
109
		// Then get the RSD XML, and return the API link.
110 2
		$rsdXml = new SimpleXMLElement( $tempClient->get( $rsdUrl )->getBody() );
111 2
		return self::newFromApiEndpoint( (string)$rsdXml->service->apis->api->attributes()->apiLink );
112
	}
113
114
	/**
115
	 * @param string $apiUrl The API Url
116
	 * @param ClientInterface|null $client Guzzle Client
117
	 * @param MediawikiSession|null $session Inject a custom session here
118
	 */
119 24
	public function __construct( $apiUrl, ClientInterface $client = null,
120
								 MediawikiSession $session = null ) {
121 24
		if ( !is_string( $apiUrl ) ) {
122 4
			throw new InvalidArgumentException( '$apiUrl must be a string' );
123
		}
124 20
		if ( $session === null ) {
125 20
			$session = new MediawikiSession( $this );
126
		}
127
128 20
		$this->apiUrl = $apiUrl;
129 20
		$this->client = $client;
130 20
		$this->session = $session;
131
132 20
		$this->logger = new NullLogger();
133 20
	}
134
135
	/**
136
	 * Get the API URL (the URL to which API requests are sent, usually ending in api.php).
137
	 * This is useful if you've created this object via MediawikiApi::newFromPage().
138
	 *
139
	 * @since 2.3
140
	 *
141
	 * @return string The API URL.
142
	 */
143
	public function getApiUrl() {
144
		return $this->apiUrl;
145
	}
146
147
	/**
148
	 * @return ClientInterface
149
	 */
150 21
	private function getClient() {
151 21
		if ( $this->client === null ) {
152 4
			$clientFactory = new ClientFactory();
153 4
			$clientFactory->setLogger( $this->logger );
154 4
			$this->client = $clientFactory->getClient();
155
		}
156 21
		return $this->client;
157
	}
158
159
	/**
160
	 * Sets a logger instance on the object
161
	 *
162
	 * @since 1.1
163
	 *
164
	 * @param LoggerInterface $logger The new Logger object.
165
	 *
166
	 * @return null
167
	 */
168
	public function setLogger( LoggerInterface $logger ) {
169
		$this->logger = $logger;
170
		$this->session->setLogger( $logger );
171
	}
172
173
	/**
174
	 * @since 2.0
175
	 *
176
	 * @param Request $request The GET request to send.
177
	 *
178
	 * @return PromiseInterface
179
	 *         Normally promising an array, though can be mixed (json_decode result)
180
	 *         Can throw UsageExceptions or RejectionExceptions
181
	 */
182 1
	public function getRequestAsync( Request $request ) {
183 1
		$promise = $this->getClient()->requestAsync(
184 1
			'GET',
185 1
			$this->apiUrl,
186 1
			$this->getClientRequestOptions( $request, 'query' )
187
		);
188
189
		return $promise->then( function ( ResponseInterface $response ) {
190 1
			return call_user_func( [ $this, 'decodeResponse' ], $response );
191 1
		} );
192
	}
193
194
	/**
195
	 * @since 2.0
196
	 *
197
	 * @param Request $request The POST request to send.
198
	 *
199
	 * @return PromiseInterface
200
	 *         Normally promising an array, though can be mixed (json_decode result)
201
	 *         Can throw UsageExceptions or RejectionExceptions
202
	 */
203 1
	public function postRequestAsync( Request $request ) {
204 1
		$promise = $this->getClient()->requestAsync(
205 1
			'POST',
206 1
			$this->apiUrl,
207 1
			$this->getClientRequestOptions( $request, $this->getPostRequestEncoding( $request ) )
208
		);
209
210
		return $promise->then( function ( ResponseInterface $response ) {
211 1
			return call_user_func( [ $this, 'decodeResponse' ], $response );
212 1
		} );
213
	}
214
215
	/**
216
	 * @since 0.2
217
	 *
218
	 * @param Request $request The GET request to send.
219
	 *
220
	 * @return mixed Normally an array
221
	 */
222 9
	public function getRequest( Request $request ) {
223 9
		$response = $this->getClient()->request(
224 9
			'GET',
225 9
			$this->apiUrl,
226 9
			$this->getClientRequestOptions( $request, 'query' )
227
		);
228
229 9
		return $this->decodeResponse( $response );
230
	}
231
232
	/**
233
	 * @since 0.2
234
	 *
235
	 * @param Request $request The POST request to send.
236
	 *
237
	 * @return mixed Normally an array
238
	 */
239 10
	public function postRequest( Request $request ) {
240 10
		$response = $this->getClient()->request(
241 10
			'POST',
242 10
			$this->apiUrl,
243 10
			$this->getClientRequestOptions( $request, $this->getPostRequestEncoding( $request ) )
244
		);
245
246 10
		return $this->decodeResponse( $response );
247
	}
248
249
	/**
250
	 * @param ResponseInterface $response
251
	 *
252
	 * @return mixed
253
	 * @throws UsageException
254
	 */
255 21
	private function decodeResponse( ResponseInterface $response ) {
256 21
		$resultArray = json_decode( $response->getBody(), true );
257
258 21
		$this->logWarnings( $resultArray );
259 21
		$this->throwUsageExceptions( $resultArray );
260
261 19
		return $resultArray;
262
	}
263
264
	/**
265
	 * @param Request $request
266
	 *
267
	 * @return string
268
	 */
269 9
	private function getPostRequestEncoding( Request $request ) {
270 9
		if ( $request instanceof MultipartRequest ) {
271
			return 'multipart';
272
		}
273 9
		foreach ( $request->getParams() as $value ) {
274 9
			if ( is_resource( $value ) ) {
275 9
				return 'multipart';
276
			}
277
		}
278 8
		return 'form_params';
279
	}
280
281
	/**
282
	 * @param Request $request
283
	 * @param string $paramsKey either 'query' or 'multipart'
284
	 *
285
	 * @throws RequestException
286
	 *
287
	 * @return array as needed by ClientInterface::get and ClientInterface::post
288
	 */
289 21
	private function getClientRequestOptions( Request $request, $paramsKey ) {
290 21
		$params = array_merge( $request->getParams(), [ 'format' => 'json' ] );
291 21
		if ( $paramsKey === 'multipart' ) {
292 1
			$params = $this->encodeMultipartParams( $request, $params );
293
		}
294
295
		return [
296 21
			$paramsKey => $params,
297 21
			'headers' => array_merge( $this->getDefaultHeaders(), $request->getHeaders() ),
298
		];
299
	}
300
301
	/**
302
	 * Turn the normal key-value array of request parameters into a multipart array where each
303
	 * parameter is a new array with a 'name' and 'contents' elements (and optionally more, if the
304
	 * request is a MultipartRequest).
305
	 *
306
	 * @param Request $request The request to which the parameters belong.
307
	 * @param string[] $params The existing parameters. Not the same as $request->getParams().
308
	 *
309
	 * @return array
310
	 */
311 1
	private function encodeMultipartParams( Request $request, $params ) {
312
		// See if there are any multipart parameters in this request.
313 1
		$multipartParams = ( $request instanceof MultipartRequest )
314
			? $request->getMultipartParams()
315 1
			: [];
316 1
		return array_map(
317 1
			function ( $name, $value ) use ( $multipartParams ) {
318
				$partParams = [
319 1
					'name' => $name,
320 1
					'contents' => $value,
321
				];
322 1
				if ( isset( $multipartParams[ $name ] ) ) {
323
					// If extra parameters have been set for this part, use them.
324
					$partParams = array_merge( $multipartParams[ $name ], $partParams );
325
				}
326 1
				return $partParams;
327 1
			},
328 1
			array_keys( $params ),
329 1
			$params
330
		);
331
	}
332
333
	/**
334
	 * @return array
335
	 */
336 17
	private function getDefaultHeaders() {
337
		return [
338 17
			'User-Agent' => $this->getUserAgent(),
339
		];
340
	}
341
342 17
	private function getUserAgent() {
343 17
		$loggedIn = $this->isLoggedin();
344 17
		if ( $loggedIn ) {
345
			return 'addwiki-mediawiki-client/' . $loggedIn;
346
		}
347 17
		return 'addwiki-mediawiki-client';
348
	}
349
350
	/**
351
	 * @param $result
352
	 */
353 17
	private function logWarnings( $result ) {
354 17
		if ( is_array( $result ) ) {
355
			// Let's see if there is 'warnings' key on the first level of the array...
356
			if ( $this->logWarning( $result ) ) {
357
				return;
358
			}
359
360
			// ...if no then go one level deeper and check there for it.
361
			foreach ( $result as $value ) {
362
				if ( !is_array($value) || !array_key_exists( 'warnings', $value ) ) {
363
					return;
364
				}
365 17
366
				$this->logWarnings( $value );
367
			}
368
		}
369
	}
370
371
	/**
372 17
	 * @param $array
373 17
	 *
374 2
	 * @return bool Whether any warning has been logged or not.
375 2
	 */
376 2
	private function logWarning( $array ) {
377 2
		$found = false;
378
379
		if ( !array_key_exists( 'warnings', $array ) ) {
380 15
			return false;
381
		}
382
383
		foreach ( $array['warnings'] as $module => $warningData ) {
384
			// Accomodate both formatversion=2 and old-style API results
385
			$logPrefix = $module . ': ';
386
			if ( isset( $warningData['*'] ) ) {
387 17
				$this->logger->warning( $logPrefix . $warningData['*'], [ 'data' => $warningData ] );
388 17
			} else {
389
				$this->logger->warning( $logPrefix . $warningData['warnings'], [ 'data' => $warningData ] );
390
			}
391
392
			$found = true;
393
		}
394
395
		return $found;
396
	}
397
398
	/**
399 2
	 * @param array $result
400 2
	 *
401 2
	 * @throws UsageException
402 2
	 */
403 2
	private function throwUsageExceptions( $result ) {
404 2
		if ( is_array( $result ) && array_key_exists( 'error', $result ) ) {
405 2
			throw new UsageException(
406
				$result['error']['code'],
407 2
				$result['error']['info'],
408 1
				$result
409 1
			);
410
		}
411
	}
412 1
413 1
	/**
414 1
	 * @since 0.1
415
	 *
416
	 * @return bool|string false or the name of the current user
417
	 */
418
	public function isLoggedin() {
419
		return $this->isLoggedIn;
420
	}
421
422
	/**
423 2
	 * @since 0.1
424
	 *
425 2
	 * @param ApiUser $apiUser The ApiUser to log in as.
426 2
	 *
427
	 * @throws UsageException
428
	 * @return bool success
429 2
	 */
430
	public function login( ApiUser $apiUser ) {
431
		$this->logger->log( LogLevel::DEBUG, 'Logging in' );
432 2
		$credentials = $this->getLoginParams( $apiUser );
433
		$result = $this->postRequest( new SimpleRequest( 'login', $credentials ) );
434
		if ( $result['login']['result'] == "NeedToken" ) {
435
			$params = array_merge( [ 'lgtoken' => $result['login']['token'] ], $credentials );
436
			$result = $this->postRequest( new SimpleRequest( 'login', $params ) );
437
		}
438
		if ( $result['login']['result'] == "Success" ) {
439
			$this->isLoggedIn = $apiUser->getUsername();
440 1
			return true;
441 1
		}
442
443 1
		$this->isLoggedIn = false;
444 1
		$this->logger->log( LogLevel::DEBUG, 'Login failed.', $result );
445 1
		$this->throwLoginUsageException( $result );
446
		return false;
447 1
	}
448 1
449
	/**
450
	 * @param ApiUser $apiUser
451
	 *
452
	 * @return string[]
453
	 */
454
	private function getLoginParams( ApiUser $apiUser ) {
455
		$params = [
456
			'lgname' => $apiUser->getUsername(),
457 2
			'lgpassword' => $apiUser->getPassword(),
458 2
		];
459 2
460 2
		if ( !is_null( $apiUser->getDomain() ) ) {
461 1
			$params['lgdomain'] = $apiUser->getDomain();
462 1
		}
463 1
		return $params;
464
	}
465 1
466
	/**
467
	 * @param array $result
468
	 *
469
	 * @throws UsageException
470
	 */
471
	private function throwLoginUsageException( $result ) {
472
		$loginResult = $result['login']['result'];
473
474
		throw new UsageException(
475 2
			'login-' . $loginResult,
476 2
			array_key_exists( 'reason', $result['login'] )
477
				? $result['login']['reason']
478
				: 'No Reason given',
479
			$result
480
		);
481
	}
482
483
	/**
484 1
	 * @since 0.1
485 1
	 *
486 1
	 * @return bool success
487
	 */
488
	public function logout() {
489
		$this->logger->log( LogLevel::DEBUG, 'Logging out' );
490
		$result = $this->postRequest( new SimpleRequest( 'logout' ) );
491 4
		if ( $result === [] ) {
492 4
			$this->isLoggedIn = false;
493 4
			$this->clearTokens();
494 4
			return true;
495
		}
496
		return false;
497 4
	}
498 4
499 4
	/**
500 4
	 * @since 0.1
501
	 *
502 4
	 * @param string $type The token type to get.
503
	 *
504 4
	 * @return string
505
	 */
506
	public function getToken( $type = 'csrf' ) {
507
		return $this->session->getToken( $type );
508
	}
509
510
	/**
511
	 * Clear all tokens stored by the API.
512
	 *
513
	 * @since 0.1
514
	 */
515
	public function clearTokens() {
516
		$this->session->clearTokens();
517
	}
518
519
	/**
520
	 * @return string
521
	 */
522
	public function getVersion() {
523
		if ( !isset( $this->version ) ) {
524
			$result = $this->getRequest( new SimpleRequest( 'query', [
525
				'meta' => 'siteinfo',
526
				'continue' => '',
527
			] ) );
528
			preg_match(
529
				'/\d+(?:\.\d+)+/',
530
				$result['query']['general']['generator'],
531
				$versionParts
532
			);
533
			$this->version = $versionParts[0];
534
		}
535
		return $this->version;
536
	}
537
538
}
539