Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
81.78% covered (warning)
81.78%
193 / 236
42.86% covered (danger)
42.86%
3 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
WikifunctionsClientRequestJob
81.78% covered (warning)
81.78%
193 / 236
42.86% covered (danger)
42.86%
3 / 7
52.67
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
1
 ignoreDuplicates
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDeduplicationInfo
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 run
100.00% covered (success)
100.00%
47 / 47
100.00% covered (success)
100.00%
1 / 1
3
 remoteCall
77.03% covered (warning)
77.03%
114 / 148
0.00% covered (danger)
0.00%
0 / 1
46.20
 buildRequest
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 getClientTargetUrl
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
1<?php
2
3/**
4 * @file
5 * @ingroup Extensions
6 * @copyright 2020– Abstract Wikipedia team; see AUTHORS.txt
7 * @license MIT
8 */
9
10namespace MediaWiki\Extension\WikiLambda\Jobs;
11
12use Exception;
13use MediaWiki\Config\Config;
14use MediaWiki\Extension\WikiLambda\ClientStorage\WikifunctionsFragmentStore;
15use MediaWiki\Extension\WikiLambda\HttpStatus;
16use MediaWiki\Extension\WikiLambda\OrchestratorRequest;
17use MediaWiki\Extension\WikiLambda\Registry\ZErrorTypeRegistry;
18use MediaWiki\Extension\WikiLambda\Registry\ZTypeRegistry;
19use MediaWiki\Extension\WikiLambda\WikifunctionCallException;
20use MediaWiki\Extension\WikiLambda\WikiLambdaServices;
21use MediaWiki\Extension\WikiLambda\ZObjectUtils;
22use MediaWiki\Http\HttpRequestFactory;
23use MediaWiki\Http\MWHttpRequest;
24use MediaWiki\JobQueue\GenericParameterJob;
25use MediaWiki\JobQueue\Job;
26use MediaWiki\Logger\LoggerFactory;
27use MediaWiki\MediaWikiServices;
28use Psr\Log\LoggerInterface;
29use Wikimedia\Timestamp\ConvertibleTimestamp;
30use Wikimedia\Timestamp\TimestampFormat as TS;
31
32/**
33 * Asynchronous job run on the client wiki to request a function call from the repo, turning
34 * that into a fragment rendering for the page.
35 */
36class WikifunctionsClientRequestJob extends Job implements GenericParameterJob {
37
38    private WikifunctionsFragmentStore $clientFragmentStore;
39
40    private Config $config;
41    private HttpRequestFactory $httpRequestFactory;
42    private LoggerInterface $logger;
43
44    private array $logContext;
45
46    private array $functionCall;
47    private string $targetFunction;
48    private array $functionArguments;
49    private string $parseLang;
50    private string $renderLang;
51
52    /**
53     * @inheritDoc
54     */
55    public function __construct( array $params ) {
56        // This job, triggered by the Parsoid callback for rendering a function,
57        // tries to make a network request for the content.
58
59        // Note: This will set $this->params.
60        parent::__construct( 'wikifunctionsClientRequest', $params );
61
62        // Non-injected items
63        $this->clientFragmentStore = WikiLambdaServices::getWikifunctionsFragmentStore();
64        $this->logger = LoggerFactory::getInstance( 'WikiLambdaClient' );
65        $this->config = MediaWikiServices::getInstance()->getConfigFactory()->makeConfig( 'WikiLambda' );
66        $this->httpRequestFactory = MediaWikiServices::getInstance()->getHttpRequestFactory();
67
68        // These are the user input from the wikitext, as relayed from Parsoid
69        $this->functionCall = $params['request'];
70        $this->targetFunction = $params['request']['target'] ?? '';
71        $this->functionArguments = $params['request']['arguments'] ?? [];
72        $this->parseLang = $params['request']['parseLang'] ?? '';
73        $this->renderLang = $params['request']['renderLang'] ?? '';
74
75        // Store general log context
76        $this->logContext = [
77            'targetFunction' => $this->targetFunction,
78            'params' => json_encode( $params['request'] ?? null ),
79        ];
80        $this->logger->debug( __CLASS__ . ' created for {targetFunction} with {params}', $this->logContext );
81    }
82
83    /** @inheritDoc */
84    public function ignoreDuplicates() {
85        // We've carefully chosen the parameters so this Job is shared across multiple uses, so don't run it
86        // in parallel and have MediaWiki de-duplicate requests.
87        return true;
88    }
89
90    /** @inheritDoc */
91    public function getDeduplicationInfo() {
92        return [
93            'type' => 'wikifunctionsClientRequest',
94            'target' => $this->targetFunction,
95            'arguments' => $this->functionArguments,
96            'parseLang' => $this->parseLang,
97            'renderLang' => $this->renderLang
98        ];
99    }
100
101    /**
102     * @return bool
103     */
104    public function run() {
105        $this->logger->debug( __CLASS__ . ' initiated for {targetFunction}', $this->logContext );
106
107        $datetime = ConvertibleTimestamp::now( TS::MW );
108
109        try {
110            $output = $this->remoteCall(
111                $this->targetFunction,
112                $this->functionArguments,
113                $this->parseLang,
114                $this->renderLang
115            );
116
117            $this->clientFragmentStore->setRenderedFragment(
118                $this->functionCall,
119                [
120                    'success' => true,
121                    'value' => $output['value'],
122                    'type' => $output['type'],
123                    'renderDate' => $datetime,
124                ],
125                HttpStatus::OK
126            );
127
128            $this->logger->debug( __CLASS__ . ' success for {targetFunction}', $this->logContext );
129            return true;
130
131        } catch ( WikifunctionCallException $callException ) {
132            // WikifunctionCallException: we know details of the error
133            $errorMessageKey = $callException->getMessageKey();
134            $httpStatusCode = $callException->getHttpStatusCode();
135        } catch ( Exception $e ) {
136            // Unhandled exception: we have no details on how the error happened
137            $this->logger->error(
138                __CLASS__ . '::remoteCall threw an unhandled Exception: {error}',
139                $this->logContext + [
140                    'error' => $e->getMessage(),
141                    'exception' => $e,
142                ]
143            );
144            // Show unclear error or system failure
145            $errorMessageKey = 'wikilambda-functioncall-error-unclear';
146            $httpStatusCode = HttpStatus::INTERNAL_SERVER_ERROR;
147        }
148
149        $this->clientFragmentStore->setRenderedFragment(
150            $this->functionCall,
151            [
152                'success' => false,
153                'errorMessageKey' => $errorMessageKey,
154                'renderDate' => $datetime,
155            ],
156            $httpStatusCode
157        );
158
159        $this->logger->debug(
160            __CLASS__ . ' failure for {targetFunction}, error: {errorMessageKey}',
161            $this->logContext + [ 'errorMessageKey' => $errorMessageKey ]
162        );
163
164        // Our call has been triggered and has run, so return true so that our job isn't re-tried.
165        return true;
166    }
167
168    /**
169     * @param string $target The ZID of the function to call
170     * @param string[] $arguments The function call parameters
171     * @param string $parseLanguageCode The language code in which to parse inputs, e.g. 'de'
172     * @param string $renderLanguageCode The language code in which to render outputs, e.g. 'fr'
173     *
174     * @throws WikifunctionCallException A known error happened
175     * @throws Exception An unknown error happened
176     */
177    private function remoteCall(
178        string $target,
179        array $arguments,
180        string $parseLanguageCode,
181        string $renderLanguageCode
182    ): array {
183        if ( count( $arguments ) === 0 ) {
184            // We structurally cannot support calls without arguments (the REST API errors about the arguments key being
185            // unset); instead of spending resources and worrying the developers, throw specifically
186
187            // Triggers use of messages:
188            // * wikilambda-functioncall-error-bad-inputs-category
189            // * wikilambda-functioncall-error-bad-inputs-category-desc
190            throw new WikifunctionCallException(
191                'wikilambda-functioncall-error-bad-inputs',
192                HttpStatus::BAD_REQUEST
193            );
194        }
195
196        $request = $this->buildRequest( $target, $arguments, $parseLanguageCode, $renderLanguageCode );
197
198        $responseStatus = $request->execute();
199        $httpStatusCode = $request->getStatus();
200
201        // Http 0: Request didn't fly
202        if ( $httpStatusCode === 0 ) {
203            // Triggers use of messages:
204            // * wikilambda-functioncall-error-unclear-category
205            // * wikilambda-functioncall-error-unclear-category-desc
206            throw new WikifunctionCallException(
207                'wikilambda-functioncall-error-unclear',
208                HttpStatus::INTERNAL_SERVER_ERROR
209            );
210        }
211
212        // Http 200: Response successful
213        $response = json_decode( $request->getContent() );
214
215        if ( $response && $responseStatus->isOK() ) {
216            return [
217                'value' => $response->value,
218                'type' => $response->type,
219            ];
220        }
221
222        // If not OK, process error responses:
223        // If errorKey is 'wikilambda-zerror', extract ZError and ZError code
224        $zerrorCode = null;
225        $zerror = null;
226        if ( $response && property_exists( $response, 'errorKey' ) && $response->errorKey === 'wikilambda-zerror' ) {
227            $zerrorCode = $response->errorData->zerror->{ ZTypeRegistry::Z_ERROR_TYPE } ?: null;
228            $zerror = $response->errorData->zerror ?: null;
229        } else {
230            $this->logger->warning(
231                __METHOD__ . ' encountered an error response {httpStatusCode} with a broken ZError: {response}',
232                $this->logContext + [
233                    'httpStatusCode' => $httpStatusCode,
234                    'response' => $request->getContent(),
235                ]
236            );
237            // Triggers use of messages:
238            // * wikilambda-functioncall-error-unclear-category
239            // * wikilambda-functioncall-error-unclear-category-desc
240            throw new WikifunctionCallException(
241                'wikilambda-functioncall-error-unclear',
242                HttpStatus::INTERNAL_SERVER_ERROR
243            );
244        }
245
246        $this->logger->debug(
247            __METHOD__ . ' encountered an error response {httpStatusCode}: {zerrorCode}',
248            $this->logContext + [
249                'httpStatusCode' => $httpStatusCode,
250                'zerrorCode' => $zerrorCode,
251            ]
252        );
253
254        switch ( $httpStatusCode ) {
255            // HTTP 400: Bad Request
256            // Something is wrong with the content (e.g. in the user request or the on-wiki content on WF.org)
257            case HttpStatus::BAD_REQUEST:
258            case HttpStatus::NOT_FOUND:
259                switch ( $zerrorCode ) {
260                    case ZErrorTypeRegistry::Z_ERROR_ZID_NOT_FOUND:
261                        // Error cases:
262                        // * Function not found
263                        // * Input reference not found
264                        // Triggers use of messages:
265                        // * wikilambda-functioncall-error-unknown-zid-category
266                        // * wikilambda-functioncall-error-unknown-zid-category-desc
267                        throw new WikifunctionCallException(
268                            'wikilambda-functioncall-error-unknown-zid',
269                            $httpStatusCode,
270                            $zerror
271                        );
272
273                    case ZErrorTypeRegistry::Z_ERROR_NOT_WELLFORMED:
274                        // Error cases:
275                        // * Function object found but not valid
276                        // Triggers use of messages:
277                        // * wikilambda-functioncall-error-invalid-zobject-category
278                        // * wikilambda-functioncall-error-invalid-zobject-category-desc
279                        throw new WikifunctionCallException(
280                            'wikilambda-functioncall-error-invalid-zobject',
281                            $httpStatusCode,
282                            $zerror
283                        );
284
285                    case ZErrorTypeRegistry::Z_ERROR_ARGUMENT_TYPE_MISMATCH:
286                        switch ( $response->mode ) {
287                            case 'function':
288                                // Error cases:
289                                // * Function Zid belongs to an object of a different type
290                                // Triggers use of messages:
291                                // * wikilambda-functioncall-error-nonfunction-category
292                                // * wikilambda-functioncall-error-nonfunction-category-desc
293                                throw new WikifunctionCallException(
294                                    'wikilambda-functioncall-error-nonfunction',
295                                    $httpStatusCode,
296                                    $zerror
297                                );
298
299                            case 'input':
300                                // Error cases:
301                                // * Input reference belongs to an object of an unexpected type
302                                // Triggers use of messages:
303                                // * wikilambda-functioncall-error-bad-input-type-category
304                                // * wikilambda-functioncall-error-bad-input-type-category-desc
305                                throw new WikifunctionCallException(
306                                    'wikilambda-functioncall-error-bad-input-type',
307                                    $httpStatusCode,
308                                    $zerror
309                                );
310
311                            default:
312                                break;
313                        }
314                        // Fall-back to default handling, below.
315                        break;
316
317                    case ZErrorTypeRegistry::Z_ERROR_LANG_NOT_FOUND:
318                        // Error cases:
319                        // * parser lang code not found
320                        // * renderer lang code not found
321                        // Triggers use of messages:
322                        // * wikilambda-functioncall-error-bad-langs-category
323                        // * wikilambda-functioncall-error-bad-langs-category-desc
324                        throw new WikifunctionCallException(
325                            'wikilambda-functioncall-error-bad-langs',
326                            $httpStatusCode,
327                            $zerror
328                        );
329
330                    case ZErrorTypeRegistry::Z_ERROR_ARGUMENT_COUNT_MISMATCH:
331                        // Error cases:
332                        // * wrong number of arguments
333                        // Triggers use of messages:
334                        // * wikilambda-functioncall-error-bad-inputs-category
335                        // * wikilambda-functioncall-error-bad-inputs-category-desc
336                        throw new WikifunctionCallException(
337                            'wikilambda-functioncall-error-bad-inputs',
338                            $httpStatusCode,
339                            $zerror
340                        );
341
342                    case ZErrorTypeRegistry::Z_ERROR_NOT_IMPLEMENTED_YET:
343                        switch ( $response->mode ) {
344                            case 'input':
345                                // Error cases:
346                                // * input type is generic
347                                // * input type has no parser
348                                // Triggers use of messages:
349                                // * wikilambda-functioncall-error-nonstringinput-category
350                                // * wikilambda-functioncall-error-nonstringinput-category-desc
351                                throw new WikifunctionCallException(
352                                    'wikilambda-functioncall-error-nonstringinput',
353                                    $httpStatusCode,
354                                    $zerror
355                                );
356
357                            case 'output':
358                                // Error cases:
359                                // * output type is generic
360                                // * output type has no renderer
361                                // Triggers use of messages:
362                                // * wikilambda-functioncall-error-nonstringoutput-category
363                                // * wikilambda-functioncall-error-nonstringoutput-category-desc
364                                throw new WikifunctionCallException(
365                                    'wikilambda-functioncall-error-nonstringoutput',
366                                    $httpStatusCode,
367                                    $zerror
368                                );
369
370                            default:
371                                break;
372                        }
373                        // Fall-back to default handling, below.
374                        break;
375
376                    case ZErrorTypeRegistry::Z_ERROR_API_FAILURE:
377                        // Error cases:
378                        // * some error happened trying to make the request to the orchestrator
379                        // Triggers use of messages:
380                        // * wikilambda-functioncall-error-unclear-category
381                        // * wikilambda-functioncall-error-unclear-category-desc
382                        throw new WikifunctionCallException(
383                            'wikilambda-functioncall-error-unclear',
384                            $httpStatusCode,
385                            $zerror
386                        );
387
388                    case ZErrorTypeRegistry::Z_ERROR_EVALUATION:
389                        // Error cases:
390                        // * some error happened in the orchestrator
391                        // Triggers use of messages:
392                        // * wikilambda-functioncall-error-evaluation-category
393                        // * wikilambda-functioncall-error-evaluation-category-desc
394                        throw new WikifunctionCallException(
395                            'wikilambda-functioncall-error-evaluation',
396                            $httpStatusCode,
397                            $zerror
398                        );
399
400                    case ZErrorTypeRegistry::Z_ERROR_INVALID_EVALUATION_RESULT:
401                        // Error cases:
402                        // * orchestrator returned a non-error output but of wrong type
403                        // Triggers use of messages:
404                        // * wikilambda-functioncall-error-bad-output-category
405                        // * wikilambda-functioncall-error-bad-output-category-desc
406                        throw new WikifunctionCallException(
407                            'wikilambda-functioncall-error-bad-output',
408                            $httpStatusCode,
409                            $zerror
410                        );
411
412                    default:
413                        // Non zerror, or Unknown zerror:
414                        $this->logger->error(
415                            __METHOD__ . ' encountered a {httpStatusCode} HTTP error with an unknown zerror',
416                            $this->logContext + [
417                                'zerror' => $zerror,
418                                'zerrorCode' => $zerrorCode,
419                                'httpStatusCode' => $httpStatusCode,
420                                'response' => $request->getContent(),
421                            ]
422                        );
423                }
424                // Fall-back to default handling, below.
425                break;
426
427            // HTTP 500: Internal Server Error
428            // Something went wrong in the server's code (not user-written code or user error)
429            case HttpStatus::INTERNAL_SERVER_ERROR:
430            case HttpStatus::NOT_IMPLEMENTED:
431                switch ( $zerrorCode ) {
432                    case ZErrorTypeRegistry::Z_ERROR_NOT_IMPLEMENTED_YET:
433                        // Error cases:
434                        // * Wikifunctions Repo service is disabled
435                        // Triggers use of messages:
436                        // * wikilambda-functioncall-error-disabled-category
437                        // * wikilambda-functioncall-error-disabled-category-desc
438                        throw new WikifunctionCallException(
439                            'wikilambda-functioncall-error-disabled',
440                            $httpStatusCode,
441                            $zerror
442                        );
443
444                    default:
445                        $this->logger->error(
446                            __METHOD__ . ' encountered a {httpStatusCode} HTTP error with an unknown zerror',
447                            $this->logContext + [
448                                'zerror' => $zerror,
449                                'zerrorCode' => $zerrorCode,
450                                'httpStatusCode' => $httpStatusCode,
451                                'response' => $request->getContent(),
452                            ]
453                        );
454                        // Fall-back to default handling, below.
455                        break;
456                }
457                break;
458
459            default:
460                $this->logger->warning(
461                    __METHOD__ . ' encountered an unknown HTTP error code',
462                    $this->logContext + [
463                        'zerror' => $zerror,
464                        'zerrorCode' => $zerrorCode,
465                        'httpStatusCode' => $httpStatusCode,
466                        'response' => $request->getContent(),
467                    ]
468                );
469                // Fall-back to default handling, below.
470                break;
471        }
472
473        // Default handling:
474        // Triggers use of messages:
475        // * wikilambda-functioncall-error-category
476        // * wikilambda-functioncall-error-category-desc
477        throw new WikifunctionCallException(
478            'wikilambda-functioncall-error',
479            $httpStatusCode,
480            $zerror
481        );
482    }
483
484    /**
485     * Returns the HTTP request to the function call REST API with the given wikifunctions call parameters.
486     *
487     * @param string $target
488     * @param array $args
489     * @param string $parseLang
490     * @param string $renderLang
491     * @return MWHttpRequest
492     */
493    private function buildRequest(
494        string $target,
495        array $args,
496        string $parseLang,
497        string $renderLang
498    ): MWHttpRequest {
499        // This is a slightly hacky way to ensure that user inputs are transmit-safe, and that e.g.
500        // inputs with '|'s in them can be ferried across the network without
501        $encodedArguments = implode(
502            '|',
503            array_map( static fn ( $val ): string => ZObjectUtils::encodeStringParamForNetwork( $val ), $args )
504        );
505
506        $requestUri = self::getClientTargetUrl( $this->config, $this->logger )
507            . $this->config->get( 'RestPath' )
508            . '/wikifunctions/v0/call'
509            . '/' . $target
510            . '/' . $encodedArguments
511            . '/' . $parseLang
512            . '/' . $renderLang;
513
514        // HttpRequestFactory->create() returns GuzzleHttpRequest (extends MWHttpRequest):
515        // https://doc.wikimedia.org/mediawiki-core/master/php/classGuzzleHttpRequest.html
516        // https://doc.wikimedia.org/mediawiki-core/master/php/classMWHttpRequest.html
517        $request = $this->httpRequestFactory->create( $requestUri, [ 'method' => 'GET' ], __METHOD__ );
518
519        // Set request origin header
520        $request->setHeader( 'X-WikiLambda-Request-Origin', OrchestratorRequest::WF_CLIENT_ORIGIN_HEADER );
521
522        return $request;
523    }
524
525    /**
526     * Returns the Url of the Wikilambda server instance,
527     * and if not available in the configuration variables,
528     * returns an empty string and logs an error.
529     *
530     * @param Config $config
531     * @param LoggerInterface $logger
532     * @return string
533     */
534    public static function getClientTargetUrl( $config, $logger ): string {
535        $targetUrl = $config->get( 'WikiLambdaClientTargetAPI' );
536        if ( !$targetUrl ) {
537            $logger->error( __METHOD__ . ': missing configuration variable WikiLambdaClientTargetAPI' );
538        }
539        return $targetUrl ?? '';
540    }
541}