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