Completed
Push — main ( 26c7b4...5ab5c5 )
by
unknown
04:18
created

MediawikiApi::logWarnings()   A

Complexity

Conditions 5
Paths 5

Size

Total Lines 17

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 17
c 0
b 0
f 0
rs 9.3888
cc 5
nc 5
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
	 * Should be accessed through getClient
31
	 * @var ClientInterface|null
32
	 */
33
	private ?ClientInterface $client = null;
0 ignored issues
show
Bug introduced by
This code did not parse for me. Apparently, there is an error somewhere around this line:

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