Completed
Push — main ( d84a15...26c7b4 )
by
unknown
04:34
created

MediawikiApi::getToken()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 3
c 0
b 0
f 0
rs 10
cc 1
nc 1
nop 1
1
<?php
2
3
namespace Addwiki\Mediawiki\Api\Client;
4
5
use Addwiki\Mediawiki\Api\Guzzle\ClientFactory;
6
use DOMDocument;
7
use DOMXPath;
8
use GuzzleHttp\Client;
9
use GuzzleHttp\ClientInterface;
10
use GuzzleHttp\Exception\RequestException;
11
use GuzzleHttp\Promise\PromiseInterface;
12
use InvalidArgumentException;
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;
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
	public static function newFromPage( $url ) {
82
		// Set up HTTP client and HTML document.
83
		$tempClient = new Client( [ 'headers' => [ 'User-Agent' => 'addwiki-mediawiki-client' ] ] );
84
		$pageHtml = $tempClient->get( $url )->getBody();
85
		$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
		$internalErrors = libxml_use_internal_errors( true );
90
		$pageDoc->loadHTML( $pageHtml );
91
		$libXmlErrors = libxml_get_errors();
92
		libxml_use_internal_errors( $internalErrors );
93
94
		// Extract the RSD link.
95
		$xpath = 'head/link[@type="application/rsd+xml"][@href]';
96
		$link = ( new DOMXpath( $pageDoc ) )->query( $xpath );
97
		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
			} );
102
			if ( $libXmlErrorStr ) {
103
				$libXmlErrorStr = sprintf( 'In addition, libxml had the following errors: %s', $libXmlErrorStr );
104
			}
105
			throw new RsdException( sprintf( 'Unable to find RSD URL in page: %s %s', $url, $libXmlErrorStr ) );
106
		}
107
		$linkItem = $link->item( 0 );
108
		if ( ( $linkItem->attributes ) === null ) {
109
			throw new RsdException( 'Unexpected RSD fetch error' );
110
		}
111
		/** @psalm-suppress NullReference */
112
		$rsdUrl = $linkItem->attributes->getNamedItem( 'href' )->nodeValue;
113
114
		// Then get the RSD XML, and return the API link.
115
		$rsdXml = new SimpleXMLElement( $tempClient->get( $rsdUrl )->getBody() );
116
		return self::newFromApiEndpoint( (string)$rsdXml->service->apis->api->attributes()->apiLink );
117
	}
118
119
	/**
120
	 * @param string $apiUrl The API Url
121
	 * @param ClientInterface|null $client Guzzle Client
122
	 * @param MediawikiSession|null $session Inject a custom session here
123
	 */
124
	public function __construct( $apiUrl, ClientInterface $client = null,
125
								 MediawikiSession $session = null ) {
126
		if ( !is_string( $apiUrl ) ) {
127
			throw new InvalidArgumentException( '$apiUrl must be a string' );
128
		}
129
		if ( $session === null ) {
130
			$session = new MediawikiSession( $this );
131
		}
132
133
		$this->apiUrl = $apiUrl;
134
		$this->client = $client;
135
		$this->session = $session;
136
137
		$this->logger = new NullLogger();
138
	}
139
140
	/**
141
	 * Get the API URL (the URL to which API requests are sent, usually ending in api.php).
142
	 * This is useful if you've created this object via MediawikiApi::newFromPage().
143
	 *
144
	 * @since 2.3
145
	 *
146
	 * @return string The API URL.
147
	 */
148
	public function getApiUrl() {
149
		return $this->apiUrl;
150
	}
151
152
	/**
153
	 * @return ClientInterface
154
	 */
155
	private function getClient() {
156
		if ( $this->client === null ) {
157
			$clientFactory = new ClientFactory();
158
			$clientFactory->setLogger( $this->logger );
159
			$this->client = $clientFactory->getClient();
160
		}
161
		return $this->client;
162
	}
163
164
	/**
165
	 * Sets a logger instance on the object
166
	 *
167
	 * @since 1.1
168
	 *
169
	 * @param LoggerInterface $logger The new Logger object.
170
	 *
171
	 * @return null
172
	 */
173
	public function setLogger( LoggerInterface $logger ) {
174
		$this->logger = $logger;
175
		$this->session->setLogger( $logger );
176
	}
177
178
	/**
179
	 * @since 2.0
180
	 *
181
	 * @param Request $request The GET request to send.
182
	 *
183
	 * @return PromiseInterface
184
	 *         Normally promising an array, though can be mixed (json_decode result)
185
	 *         Can throw UsageExceptions or RejectionExceptions
186
	 */
187 View Code Duplication
	public function getRequestAsync( Request $request ) {
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
188
		$promise = $this->getClient()->requestAsync(
189
			'GET',
190
			$this->apiUrl,
191
			$this->getClientRequestOptions( $request, 'query' )
192
		);
193
194
		return $promise->then( function ( ResponseInterface $response ) {
195
			return call_user_func( function ( ResponseInterface $response ) {
196
				return $this->decodeResponse( $response );
197
			}, $response );
198
		} );
199
	}
200
201
	/**
202
	 * @since 2.0
203
	 *
204
	 * @param Request $request The POST request to send.
205
	 *
206
	 * @return PromiseInterface
207
	 *         Normally promising an array, though can be mixed (json_decode result)
208
	 *         Can throw UsageExceptions or RejectionExceptions
209
	 */
210 View Code Duplication
	public function postRequestAsync( Request $request ) {
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
211
		$promise = $this->getClient()->requestAsync(
212
			'POST',
213
			$this->apiUrl,
214
			$this->getClientRequestOptions( $request, $this->getPostRequestEncoding( $request ) )
215
		);
216
217
		return $promise->then( function ( ResponseInterface $response ) {
218
			return call_user_func( function ( ResponseInterface $response ) {
219
				return $this->decodeResponse( $response );
220
			}, $response );
221
		} );
222
	}
223
224
	/**
225
	 * @since 0.2
226
	 *
227
	 * @param Request $request The GET request to send.
228
	 *
229
	 * @return mixed Normally an array
230
	 */
231 View Code Duplication
	public function getRequest( Request $request ) {
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
232
		$response = $this->getClient()->request(
233
			'GET',
234
			$this->apiUrl,
235
			$this->getClientRequestOptions( $request, 'query' )
236
		);
237
238
		return $this->decodeResponse( $response );
239
	}
240
241
	/**
242
	 * @since 0.2
243
	 *
244
	 * @param Request $request The POST request to send.
245
	 *
246
	 * @return mixed Normally an array
247
	 */
248 View Code Duplication
	public function postRequest( Request $request ) {
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
249
		$response = $this->getClient()->request(
250
			'POST',
251
			$this->apiUrl,
252
			$this->getClientRequestOptions( $request, $this->getPostRequestEncoding( $request ) )
253
		);
254
255
		return $this->decodeResponse( $response );
256
	}
257
258
	/**
259
	 * @param ResponseInterface $response
260
	 *
261
	 * @return mixed
262
	 * @throws UsageException
263
	 */
264
	private function decodeResponse( ResponseInterface $response ) {
265
		$resultArray = json_decode( $response->getBody(), true );
266
267
		$this->logWarnings( $resultArray );
268
		$this->throwUsageExceptions( $resultArray );
269
270
		return $resultArray;
271
	}
272
273
	/**
274
	 * @param Request $request
275
	 *
276
	 * @return string
277
	 */
278
	private function getPostRequestEncoding( Request $request ) {
279
		if ( $request instanceof MultipartRequest ) {
280
			return 'multipart';
281
		}
282
		foreach ( $request->getParams() as $value ) {
283
			if ( is_resource( $value ) ) {
284
				return 'multipart';
285
			}
286
		}
287
		return 'form_params';
288
	}
289
290
	/**
291
	 * @param Request $request
292
	 * @param string $paramsKey either 'query' or 'multipart'
293
	 *
294
	 * @throws RequestException
295
	 *
296
	 * @return array as needed by ClientInterface::get and ClientInterface::post
297
	 */
298
	private function getClientRequestOptions( Request $request, $paramsKey ) {
299
		$params = array_merge( $request->getParams(), [ 'format' => 'json' ] );
300
		if ( $paramsKey === 'multipart' ) {
301
			$params = $this->encodeMultipartParams( $request, $params );
302
		}
303
304
		return [
305
			$paramsKey => $params,
306
			'headers' => array_merge( $this->getDefaultHeaders(), $request->getHeaders() ),
307
		];
308
	}
309
310
	/**
311
	 * Turn the normal key-value array of request parameters into a multipart array where each
312
	 * parameter is a new array with a 'name' and 'contents' elements (and optionally more, if the
313
	 * request is a MultipartRequest).
314
	 *
315
	 * @param Request $request The request to which the parameters belong.
316
	 * @param string[] $params The existing parameters. Not the same as $request->getParams().
317
	 *
318
	 * @return array
319
	 */
320
	private function encodeMultipartParams( Request $request, $params ) {
321
		// See if there are any multipart parameters in this request.
322
		$multipartParams = ( $request instanceof MultipartRequest )
323
			? $request->getMultipartParams()
324
			: [];
325
		return array_map(
326
			function ( $name, $value ) use ( $multipartParams ) {
327
				$partParams = [
328
					'name' => $name,
329
					'contents' => $value,
330
				];
331
				if ( isset( $multipartParams[ $name ] ) ) {
332
					// If extra parameters have been set for this part, use them.
333
					$partParams = array_merge( $multipartParams[ $name ], $partParams );
334
				}
335
				return $partParams;
336
			},
337
			array_keys( $params ),
338
			$params
339
		);
340
	}
341
342
	/**
343
	 * @return array
344
	 */
345
	private function getDefaultHeaders() {
346
		return [
347
			'User-Agent' => $this->getUserAgent(),
348
		];
349
	}
350
351
	private function getUserAgent() {
352
		$loggedIn = $this->isLoggedin();
353
		if ( $loggedIn ) {
354
			return 'addwiki-mediawiki-client/' . $loggedIn;
355
		}
356
		return 'addwiki-mediawiki-client';
357
	}
358
359
	/**
360
	 * @param array $result
361
	 */
362
	private function logWarnings( $result ) {
363
		if ( is_array( $result ) ) {
364
			// Let's see if there is 'warnings' key on the first level of the array...
365
			if ( $this->logWarning( $result ) ) {
366
				return;
367
			}
368
369
			// ...if no then go one level deeper and check there for it.
370
			foreach ( $result as $value ) {
371
				if ( !is_array( $value ) ) {
372
					continue;
373
				}
374
375
				$this->logWarning( $value );
376
			}
377
		}
378
	}
379
380
	/**
381
	 * @param array $array Array response to look for warning in.
382
	 *
383
	 * @return bool Whether any warning has been logged or not.
384
	 */
385
	protected function logWarning( $array ) {
386
		$found = false;
387
388
		if ( !array_key_exists( 'warnings', $array ) ) {
389
			return false;
390
		}
391
392
		foreach ( $array['warnings'] as $module => $warningData ) {
393
			// Accommodate both formatversion=2 and old-style API results
394
			$logPrefix = $module . ': ';
395
			if ( isset( $warningData['*'] ) ) {
396
				$this->logger->warning( $logPrefix . $warningData['*'], [ 'data' => $warningData ] );
397
			} elseif ( isset( $warningData['warnings'] ) ) {
398
				$this->logger->warning( $logPrefix . $warningData['warnings'], [ 'data' => $warningData ] );
399
			} else {
400
				$this->logger->warning( $logPrefix, [ 'data' => $warningData ] );
401
			}
402
403
			$found = true;
404
		}
405
406
		return $found;
407
	}
408
409
	/**
410
	 * @param array $result
411
	 *
412
	 * @throws UsageException
413
	 */
414
	private function throwUsageExceptions( $result ) {
415
		if ( is_array( $result ) && array_key_exists( 'error', $result ) ) {
416
			throw new UsageException(
417
				$result['error']['code'],
418
				$result['error']['info'],
419
				$result
420
			);
421
		}
422
	}
423
424
	/**
425
	 * @since 0.1
426
	 *
427
	 * @return bool|string false or the name of the current user
428
	 */
429
	public function isLoggedin() {
430
		return $this->isLoggedIn;
431
	}
432
433
	/**
434
	 * @since 0.1
435
	 *
436
	 * @param ApiUser $apiUser The ApiUser to log in as.
437
	 *
438
	 * @throws UsageException
439
	 * @return bool success
440
	 */
441
	public function login( ApiUser $apiUser ) {
442
		$this->logger->log( LogLevel::DEBUG, 'Logging in' );
443
		$credentials = $this->getLoginParams( $apiUser );
444
		$result = $this->postRequest( new SimpleRequest( 'login', $credentials ) );
445
		if ( $result['login']['result'] == "NeedToken" ) {
446
			$params = array_merge( [ 'lgtoken' => $result['login']['token'] ], $credentials );
447
			$result = $this->postRequest( new SimpleRequest( 'login', $params ) );
448
		}
449
		if ( $result['login']['result'] == "Success" ) {
450
			$this->isLoggedIn = $apiUser->getUsername();
451
			return true;
452
		}
453
454
		$this->isLoggedIn = false;
455
		$this->logger->log( LogLevel::DEBUG, 'Login failed.', $result );
456
		$this->throwLoginUsageException( $result );
457
		return false;
458
	}
459
460
	/**
461
	 * @param ApiUser $apiUser
462
	 *
463
	 * @return string[]
464
	 */
465
	private function getLoginParams( ApiUser $apiUser ) {
466
		$params = [
467
			'lgname' => $apiUser->getUsername(),
468
			'lgpassword' => $apiUser->getPassword(),
469
		];
470
471
		if ( $apiUser->getDomain() !== null ) {
472
			$params['lgdomain'] = $apiUser->getDomain();
473
		}
474
		return $params;
475
	}
476
477
	/**
478
	 * @param array $result
479
	 *
480
	 * @throws UsageException
481
	 */
482
	private function throwLoginUsageException( $result ) {
483
		$loginResult = $result['login']['result'];
484
485
		throw new UsageException(
486
			'login-' . $loginResult,
487
			array_key_exists( 'reason', $result['login'] )
488
				? $result['login']['reason']
489
				: 'No Reason given',
490
			$result
491
		);
492
	}
493
494
	/**
495
	 * @since 0.1
496
	 *
497
	 * @return bool success
498
	 */
499
	public function logout() {
500
		$this->logger->log( LogLevel::DEBUG, 'Logging out' );
501
		$result = $this->postRequest( new SimpleRequest( 'logout', [
502
			'token' => $this->getToken()
503
		] ) );
504
		if ( $result === [] ) {
505
			$this->isLoggedIn = false;
506
			$this->clearTokens();
507
			return true;
508
		}
509
		return false;
510
	}
511
512
	/**
513
	 * @since 0.1
514
	 *
515
	 * @param string $type The token type to get.
516
	 *
517
	 * @return string
518
	 */
519
	public function getToken( $type = 'csrf' ) {
520
		return $this->session->getToken( $type );
521
	}
522
523
	/**
524
	 * Clear all tokens stored by the API.
525
	 *
526
	 * @since 0.1
527
	 */
528
	public function clearTokens() {
529
		$this->session->clearTokens();
530
	}
531
532
	/**
533
	 * @return string
534
	 */
535
	public function getVersion() {
536
		if ( $this->version === null ) {
537
			$result = $this->getRequest( new SimpleRequest( 'query', [
538
				'meta' => 'siteinfo',
539
				'continue' => '',
540
			] ) );
541
			preg_match(
542
				'#\d+(?:\.\d+)+#',
543
				$result['query']['general']['generator'],
544
				$versionParts
545
			);
546
			$this->version = $versionParts[0];
547
		}
548
		return $this->version;
549
	}
550
551
}
552