Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
80.95% covered (warning)
80.95%
527 / 651
58.33% covered (warning)
58.33%
7 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
FunctionCallHandler
80.95% covered (warning)
80.95%
527 / 651
58.33% covered (warning)
58.33%
7 / 12
67.28
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 run
85.39% covered (warning)
85.39%
76 / 89
0.00% covered (danger)
0.00%
0 / 1
5.08
 getTargetFunction
78.48% covered (warning)
78.48%
62 / 79
0.00% covered (danger)
0.00%
0 / 1
5.25
 getLanguageZid
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
2
 buildArgumentsForCall
100.00% covered (success)
100.00%
80 / 80
100.00% covered (success)
100.00%
1 / 1
8
 buildParsedArgument
100.00% covered (success)
100.00%
86 / 86
100.00% covered (success)
100.00%
1 / 1
6
 isArgumentValidReference
100.00% covered (success)
100.00%
69 / 69
100.00% covered (success)
100.00%
1 / 1
5
 buildRenderedOutput
100.00% covered (success)
100.00%
74 / 74
100.00% covered (success)
100.00%
1 / 1
4
 makeRequest
24.79% covered (danger)
24.79%
30 / 121
0.00% covered (danger)
0.00%
0 / 1
43.46
 applyCacheControl
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
12
 needsWriteAccess
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getParamSettings
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * WikiLambda ZObject function calling REST API
4 *
5 * @file
6 * @ingroup Extensions
7 * @copyright 2020– Abstract Wikipedia team; see AUTHORS.txt
8 * @license MIT
9 */
10
11namespace MediaWiki\Extension\WikiLambda\RESTAPI;
12
13use Exception;
14use JsonException;
15use MediaWiki\Api\ApiMain;
16use MediaWiki\Api\ApiUsageException;
17use MediaWiki\Context\DerivativeContext;
18use MediaWiki\Context\RequestContext;
19use MediaWiki\Extension\WikiLambda\HttpStatus;
20use MediaWiki\Extension\WikiLambda\Registry\ZErrorTypeRegistry;
21use MediaWiki\Extension\WikiLambda\Registry\ZLangRegistry;
22use MediaWiki\Extension\WikiLambda\Registry\ZTypeRegistry;
23use MediaWiki\Extension\WikiLambda\ZErrorException;
24use MediaWiki\Extension\WikiLambda\ZErrorFactory;
25use MediaWiki\Extension\WikiLambda\ZObjectFactory;
26use MediaWiki\Extension\WikiLambda\ZObjects\ZError;
27use MediaWiki\Extension\WikiLambda\ZObjects\ZFunction;
28use MediaWiki\Extension\WikiLambda\ZObjects\ZFunctionCall;
29use MediaWiki\Extension\WikiLambda\ZObjects\ZObject;
30use MediaWiki\Extension\WikiLambda\ZObjects\ZQuote;
31use MediaWiki\Extension\WikiLambda\ZObjects\ZReference;
32use MediaWiki\Extension\WikiLambda\ZObjects\ZResponseEnvelope;
33use MediaWiki\Extension\WikiLambda\ZObjects\ZString;
34use MediaWiki\Extension\WikiLambda\ZObjects\ZType;
35use MediaWiki\Extension\WikiLambda\ZObjectStore;
36use MediaWiki\Extension\WikiLambda\ZObjectUtils;
37use MediaWiki\Logger\LoggerFactory;
38use MediaWiki\MediaWikiServices;
39use MediaWiki\Request\FauxRequest;
40use MediaWiki\Rest\Handler;
41use MediaWiki\Rest\Response;
42use MediaWiki\Rest\ResponseInterface;
43use stdClass;
44use Wikimedia\ParamValidator\ParamValidator;
45use Wikimedia\Telemetry\SpanInterface;
46
47/**
48 * Simple REST API to call a ZFunction with text arguments for cross-wiki embedding
49 * in wikitext, via `GET /wikifunctions/v0/call/{zid}/{arguments}`, or more fully,
50 * `GET /wikifunctions/v0/call/{zid}/{arguments}/{parselang}/{renderlang}`
51 */
52class FunctionCallHandler extends WikiLambdaRESTHandler {
53
54    private ZLangRegistry $langRegistry;
55
56    public function __construct( private readonly ZObjectStore $zObjectStore ) {
57        $this->langRegistry = ZLangRegistry::singleton();
58        $this->logger = LoggerFactory::getInstance( 'WikiLambda' );
59    }
60
61    /**
62     * @param string $target
63     * @param string $arguments
64     * @param string $parseLang
65     * @param string $renderLang
66     * @return Response
67     */
68    public function run(
69        $target,
70        $arguments = '',
71        $parseLang = 'en',
72        $renderLang = 'en'
73    ) {
74        // Initial setup; logging and instrumentation
75        $tracer = MediaWikiServices::getInstance()->getTracer();
76        $span = $tracer->createSpan( 'WikiLambda FunctionCallHandler' )
77            ->setSpanKind( SpanInterface::SPAN_KIND_CLIENT )
78            ->start();
79        $span->activate();
80
81        $this->logger->debug(
82            __METHOD__ . ' triggered to evaluate a call to {target}',
83            [ 'target' => $target ]
84        );
85
86        // 0. Make sure that this call is not being run in a client instance
87        $config = MediaWikiServices::getInstance()->getConfigFactory()->makeConfig( 'WikiLambda' );
88        if ( !$config->get( 'WikiLambdaEnableRepoMode' ) ) {
89            $errorMessage = __METHOD__ . ' called repo mode is not enabled';
90            $this->logger->debug( $errorMessage );
91            $span->setAttributes( [
92                    'response.status_code' => HttpStatus::BAD_REQUEST,
93                    'exception.message' => $errorMessage
94                ] );
95            $span->end();
96            // WikiLambda repo code isn't loaded, so we can't use a ZError here sadly.
97            $this->dieRESTfully( 'wikilambda-restapi-disabled-repo-mode-only', [], HttpStatus::BAD_REQUEST );
98        }
99
100        // 1. Get the target function or die with ZError
101        $targetFunction = $this->getTargetFunction( $target, $span );
102
103        // 2. Validate parser and renderer language codes and get their Zids
104        $parseLanguageZid = $this->getLanguageZid( $parseLang, 'parselang', $span );
105        $renderLanguageZid = $this->getLanguageZid( $renderLang, 'renderlang', $span );
106
107        // 3. Build the arguments for the call given their expected types (or dies if something is wrong)
108        $argumentsForCall = $this->buildArgumentsForCall(
109            $target,
110            $arguments,
111            $parseLanguageZid,
112            $targetFunction,
113            $span
114        );
115
116        // 4. Set up the final, full call with all the above sub-calls embedded
117        $callObject = new ZFunctionCall( new ZReference( $target ), $argumentsForCall );
118
119        // 5. (T362252): Check if there's a renderer for this return type (if so, it will be used)
120        $targetReturnType = $targetFunction->getValueByKey( ZTypeRegistry::Z_FUNCTION_RETURN_TYPE )->getZValue();
121
122        // Types that can be directly rendered:
123        // * String/Z6
124        // * HTML Fragment/Z89
125        // * Object/Z1 (only when the function call returns a string)
126        $renderableOutputTypes = [
127            ZTypeRegistry::Z_STRING,
128            ZTypeRegistry::Z_HTML_FRAGMENT,
129            ZTypeRegistry::Z_OBJECT
130        ];
131
132        if ( !in_array( $targetReturnType, $renderableOutputTypes ) ) {
133            $callObject = $this->buildRenderedOutput(
134                $target,
135                $targetReturnType,
136                $renderLanguageZid,
137                $callObject,
138                $span
139            );
140        }
141
142        // 6. Down-convert the ZFunctionCall Object to a stdClass object
143        $call = $callObject->getSerialized();
144
145        // 7. Execute the call
146        try {
147            $requestCall = json_encode( $call, JSON_THROW_ON_ERROR );
148            $response = $this->makeRequest( $requestCall, $renderLang, $span );
149        } catch ( ZErrorException $e ) {
150            $errorMessage = __METHOD__ . ' called on {target} but got a ZErrorException, {error}';
151            $this->logger->debug(
152                $errorMessage,
153                [
154                    'target' => $target,
155                    'error' => $e->getMessage()
156                ]
157            );
158            $span->setAttributes( [
159                    'response.status_code' => HttpStatus::BAD_REQUEST,
160                    'exception.message' => $errorMessage
161                ] );
162            // Dies with one of these ZErrors:
163            // * Z_ERROR_API_FAILURE/Z530
164            // * Z_ERROR_EVALUATION/Z507
165            // * Z_ERROR_INVALID_EVALUATION_RESULT/Z560
166            $this->dieRESTfullyWithZError( $e->getZError(), HttpStatus::BAD_REQUEST, [ 'data' => $e->getZError() ] );
167        } catch ( JsonException $e ) {
168            $errorMessage = __METHOD__ . ' called on {target} but got a JsonException, {error}';
169            $this->logger->info(
170                $errorMessage,
171                [
172                    'target' => $target,
173                    'error' => $e->getMessage()
174                ]
175            );
176            $span->setAttributes( [
177                    'response.status_code' => HttpStatus::BAD_REQUEST,
178                    'exception.message' => $errorMessage
179                ] );
180            $this->dieRESTfullyWithZError(
181                ZErrorFactory::createZErrorInstance(
182                    ZErrorTypeRegistry::Z_ERROR_INVALID_SYNTAX, [
183                        'input' => var_export( $call, true ),
184                        'message' => $e->getMessage()
185                    ]
186                ),
187                HttpStatus::BAD_REQUEST,
188                [
189                    'target' => $target,
190                    'error' => $e->getMessage()
191                ]
192            );
193        } finally {
194            $span->end();
195        }
196
197        // Finally, return the values as JSON (if not already early-returned as an error)
198        return $this->getResponseFactory()->createJson( $response ?? '' );
199    }
200
201    /**
202     * Verifies the validity of the given target function Zid and returns its
203     * object (inner ZFunction object) if everything went well.
204     *
205     * Dies with ZError if:
206     * * Given function Id is not a valid Zid
207     * * Target function Zid does not exist
208     * * Target function cannot be fetched
209     * * Fetched target function is not valid
210     * * Fetched target Zid does not belong to a function
211     *
212     * @param string $target
213     * @param SpanInterface $span
214     * @return ZFunction
215     */
216    private function getTargetFunction( $target, $span ) {
217        // 1. Check if target function Id is a valid Zid
218        if ( !ZObjectUtils::isValidOrNullZObjectReference( $target ) ) {
219            $errorMessage = __METHOD__ . ' called on {target} which is a non-ZID, e.g. an inline function';
220            $this->logger->debug(
221                $errorMessage,
222                [ 'target' => $target ]
223            );
224            $span->setAttributes( [
225                'response.status_code' => HttpStatus::BAD_REQUEST,
226                'exception.message' => $errorMessage
227            ] )->end();
228
229            // Dies with Z_ERROR_ZID_NOT_FOUND
230            $this->dieRESTfullyWithZError(
231                ZErrorFactory::createZErrorInstance( ZErrorTypeRegistry::Z_ERROR_ZID_NOT_FOUND, [ 'data' => $target ] ),
232                HttpStatus::BAD_REQUEST,
233                [ 'target' => $target ]
234            );
235        }
236
237        // 2. Check if target function Zid can be successfully fetched; try cache first, then DB
238        $targetObject = $this->zObjectStore->fetchZObject( $target );
239        if ( !$targetObject ) {
240            $errorMessage = __METHOD__ . ' called on {target} which is somehow non-ZObject in our namespace';
241            $this->logger->debug(
242                $errorMessage,
243                [ 'target' => $target ]
244            );
245            $span->setAttributes( [
246                'response.status_code' => HttpStatus::BAD_REQUEST,
247                'exception.message' => $errorMessage
248            ] )->end();
249
250            // Dies with Z_ERROR_ZID_NOT_FOUND
251            $this->dieRESTfullyWithZError(
252                ZErrorFactory::createZErrorInstance( ZErrorTypeRegistry::Z_ERROR_ZID_NOT_FOUND, [ 'data' => $target ] ),
253                HttpStatus::BAD_REQUEST,
254                [ 'target' => $target ]
255            );
256        }
257
258        // 3. Check if target function is valid
259        if ( !$targetObject->isValid() ) {
260            $errorMessage = __METHOD__ . ' called on {target} which is an invalid ZObject';
261            $this->logger->info(
262                $errorMessage,
263                [
264                    'target' => $target,
265                    'childError' => $targetObject->getErrors()->getMessage(),
266                ]
267            );
268            $span->setAttributes( [
269                'response.status_code' => HttpStatus::BAD_REQUEST,
270                'exception.message' => $errorMessage
271            ] )->end();
272
273            // Dies with Z_ERROR_NOT_WELLFORMED
274            $this->dieRESTfullyWithZError(
275                ZErrorFactory::createValidationZError( $targetObject->getErrors() ),
276                HttpStatus::BAD_REQUEST,
277                [ 'target' => $target ]
278            );
279        }
280
281        // 5. Check if target function is a function
282        if ( $targetObject->getZType() !== ZTypeRegistry::Z_FUNCTION ) {
283            $errorMessage = __METHOD__ . ' called on {target} which is not a Function but a {type}';
284            $this->logger->debug(
285                $errorMessage,
286                [
287                    'target' => $target,
288                    'type' => $targetObject->getZType()
289                ]
290            );
291            $span->setAttributes( [
292                'response.status_code' => HttpStatus::BAD_REQUEST,
293                'exception.message' => $errorMessage
294            ] )->end();
295
296            // Dies with Z_ERROR_ARGUMENT_TYPE_MISMATCH
297            $this->dieRESTfullyWithZError(
298                ZErrorFactory::createZErrorInstance(
299                    ZErrorTypeRegistry::Z_ERROR_ARGUMENT_TYPE_MISMATCH,
300                    [
301                        'expected' => ZTypeRegistry::Z_FUNCTION,
302                        'actual' => $targetObject->getZType(),
303                        'argument' => $target
304                    ]
305                ),
306                HttpStatus::BAD_REQUEST,
307                [
308                    'target' => $target,
309                    'mode' => 'function'
310                ]
311            );
312        }
313
314        // Success! Return inner ZFunction object
315        $functionObject = $targetObject->getInnerZObject();
316        '@phan-var ZFunction $functionObject';
317        return $functionObject;
318    }
319
320    /**
321     * Read a language Bcp47 code argument and find its equivalents Natural Language/Z60 Zid.
322     * This is called for:
323     * * parseLang
324     * * renderLang
325     * * input arguments of type Z60
326     *
327     * @param string $langCode
328     * @param string $argKey
329     * @param SpanInterface $span
330     * @return string language zid
331     */
332    private function getLanguageZid( $langCode, $argKey, $span ): string {
333        // Check that the requested language code is one we know of and support
334        try {
335            $languageZid = $this->langRegistry->getLanguageZidFromCode( $langCode );
336        } catch ( ZErrorException $error ) {
337            $errorMessage =
338                __METHOD__ . ' called with {argKey} {langCode} which is not found / errored: {error}';
339            $this->logger->debug(
340                $errorMessage,
341                [
342                    'argKey' => $argKey,
343                    'langCode' => $langCode,
344                    'error' => $error->getMessage()
345                ]
346            );
347            $span->setAttributes( [
348                'response.status_code' => HttpStatus::BAD_REQUEST,
349                'exception.message' => $errorMessage
350            ] )->end();
351
352            // Die with Z_ERROR_LANG_NOT_FOUND
353            $this->dieRESTfullyWithZError(
354                $error->getZError(),
355                HttpStatus::BAD_REQUEST,
356                [ 'target' => $langCode ]
357            );
358        }
359
360        // Success! Return the langugae zids
361        return $languageZid;
362    }
363
364    /**
365     * @param string $target
366     * @param string $argumentsString
367     * @param string $parseLanguageZid
368     * @param ZFunction $targetFunction
369     * @param SpanInterface $span
370     * @return array
371     */
372    private function buildArgumentsForCall(
373        $target,
374        $argumentsString,
375        $parseLanguageZid,
376        $targetFunction,
377        $span
378    ): array {
379        // 1. Split and decode arguments
380        $encodedArguments = explode( '|', $argumentsString );
381        $arguments = array_map(
382            static fn ( $val ): string => ZObjectUtils::decodeStringParamFromNetwork( $val ),
383            $encodedArguments
384        );
385
386        // 2. Check that the given input count matches with the number of arguments in the function signature
387        $expectedArguments = $targetFunction->getArgumentDeclarations();
388        if ( count( $arguments ) !== count( $expectedArguments ) ) {
389            $errorMessage =
390                __METHOD__ . ' called on {target} with the wrong number of arguments, {givenCount} not {expectedCount}';
391            $this->logger->debug(
392                $errorMessage,
393                [
394                    'target' => $target,
395                    'givenCount' => count( $arguments ),
396                    'expectedCount' => count( $expectedArguments )
397                ]
398            );
399            $span->setAttributes( [
400                'response.status_code' => HttpStatus::BAD_REQUEST,
401                'exception.message' => $errorMessage
402            ] )->end();
403
404            // Die with Z_ERROR_ARGUMENT_COUNT_MISMATCH
405            $this->dieRESTfullyWithZError(
406                ZErrorFactory::createZErrorInstance(
407                    ZErrorTypeRegistry::Z_ERROR_ARGUMENT_COUNT_MISMATCH,
408                    [
409                        'expected' => strval( count( $expectedArguments ) ),
410                        'actual' => strval( count( $arguments ) ),
411                        'arguments' => $arguments
412                    ]
413                ),
414                HttpStatus::BAD_REQUEST,
415                [ 'target' => $target ]
416            );
417        }
418
419        $argumentsForCall = [];
420
421        // 3. For each argument in the function signature:
422        //    * check that the input type is supported, and
423        //    * build the appropriate input object from the string.
424        foreach ( $expectedArguments as $expectedArgumentIndex => $expectedArgument ) {
425            $argumentKey = $expectedArgument->getValueByKey( ZTypeRegistry::Z_ARGUMENTDECLARATION_ID )->getZValue();
426            $providedArgument = $arguments[array_keys( $arguments )[$expectedArgumentIndex]];
427            $targetTypeZid = $expectedArgument->getValueByKey( ZTypeRegistry::Z_ARGUMENTDECLARATION_TYPE )->getZValue();
428
429            // A) If expected type is String/Z6 or Object/Z1: build String object (canonical or normal if zid)
430            if ( ( $targetTypeZid === ZTypeRegistry::Z_STRING ) || ( $targetTypeZid === ZTypeRegistry::Z_OBJECT ) ) {
431                $argumentsForCall[$argumentKey] = new ZString( $providedArgument );
432                continue;
433            }
434
435            // B) For Wikidata input types:
436            //    * If expected type is Wikidata entity: build wikidata fetch function call object
437            //    * If expected type is Wikidata reference: build wikidata reference object
438            $allowedEntityTypes = [
439                ZTypeRegistry::Z_WIKIDATA_LEXEME,
440                ZTypeRegistry::Z_WIKIDATA_ITEM
441            ];
442            $allowedReferenceTypes = [
443                ZTypeRegistry::Z_WIKIDATA_REFERENCE_LEXEME,
444                ZTypeRegistry::Z_WIKIDATA_REFERENCE_ITEM
445            ];
446
447            // Handle Wikidata entity types (e.g., Z6001, Z6005, etc.) as function arguments:
448            // We build a ZFunctionCall to the appropriate Wikidata fetch function (e.g., Z6825 for lexeme),
449            // with the argument being a Wikidata reference type (e.g., { Z1K1: Z6095, Z6095K1: 'L123' })
450            if ( in_array( $targetTypeZid, $allowedEntityTypes ) ) {
451                $entityMap = ZTypeRegistry::WIKIDATA_ENTITY_TYPE_MAP[$targetTypeZid] ?? null;
452                if ( $entityMap ) {
453                    $referenceType = $entityMap['reference_type'];
454                    $referenceKey = $entityMap['reference_key'];
455                    $fetchFunction = $entityMap['fetch_function'];
456                    $fetchKey = $entityMap['fetch_key'];
457
458                    // Build the reference ZObject for the entity (e.g., { Z1K1: Z6095, Z6095K1: 'L123' })
459                    $referenceObject = new ZObject(
460                        new ZReference( $referenceType ),
461                        [ $referenceKey => new ZString( $providedArgument ) ]
462                    );
463                    // Build the ZFunctionCall to the fetch function, passing the reference object as the argument
464                    $argumentsForCall[$argumentKey] = new ZFunctionCall(
465                        new ZReference( $fetchFunction ),
466                        [ $fetchKey => $referenceObject ]
467                    );
468                    continue;
469                }
470            }
471
472            // Handle Wikidata reference types (e.g., Z6091, Z6095, etc.) as function arguments:
473            // We build a ZObject of the given Wikidata reference type.
474            if ( in_array( $targetTypeZid, $allowedReferenceTypes ) ) {
475                $argumentsForCall[$argumentKey] = new ZObject(
476                    new ZReference( $targetTypeZid ),
477                    [ $targetTypeZid . 'K1' => new ZString( $providedArgument ) ]
478                    );
479                continue;
480            }
481
482            // C) If any other type, build either parser function call or reference to enum instance
483            $argumentsForCall[$argumentKey] = $this->buildParsedArgument(
484                $target,
485                $targetTypeZid,
486                $argumentKey,
487                $parseLanguageZid,
488                $providedArgument,
489                $span
490            );
491        }
492
493        return $argumentsForCall;
494    }
495
496    /**
497     * Builds the argument for any other custom type, which means that we need to
498     * fetch the type first in order to figure out whether:
499     * * The type is an enum or any other type that can be referenced, and the arg is a reference
500     * * The type has a parser function so it can be parsed from an input string
501     *
502     * @param string $target - target function zid
503     * @param string $targetTypeZid - target function argument type zid
504     * @param string $argumentKey - target function argument key
505     * @param string $parseLanguageZid - zid of the parser language
506     * @param string $providedArgument - provided string value for the argument
507     * @param SpanInterface $span
508     * @return ZObject
509     */
510    private function buildParsedArgument(
511        $target,
512        $targetTypeZid,
513        $argumentKey,
514        $parseLanguageZid,
515        $providedArgument,
516        $span
517    ): ZObject {
518        // Fetch target input type to figure out if it has a parser function; try cache first, else DB
519        $targetType = $this->zObjectStore->fetchZObject( $targetTypeZid );
520        if ( !$targetType ) {
521            $errorMessage = __METHOD__ . ' called on {target} which has a not-found input type {typeZid} at key {key}';
522            $this->logger->debug(
523                $errorMessage,
524                [
525                    'target' => $target,
526                    'typeZid' => $targetTypeZid,
527                    'key' => $argumentKey
528                ]
529            );
530            $span->setAttributes( [
531                'response.status_code' => HttpStatus::BAD_REQUEST,
532                'exception.message' => $errorMessage
533            ] )->end();
534
535            // Dies with Z_ERROR_ZID_NOT_FOUND
536            $this->dieRESTfullyWithZError(
537                ZErrorFactory::createZErrorInstance(
538                    ZErrorTypeRegistry::Z_ERROR_ZID_NOT_FOUND, [ 'data' => $targetTypeZid ]
539                ),
540                HttpStatus::BAD_REQUEST,
541                [
542                    'target' => $target,
543                    'mode' => 'input'
544                ]
545            );
546        }
547
548        $targetTypeObject = $targetType->getInnerZObject();
549        if ( !( $targetTypeObject instanceof ZType ) ) {
550            // It's somehow not to a Type, because:
551            // * Argument is a generic type; the function input is not supported.
552            // * There is an error in the content; the function is not wellformed (not very likely).
553            $errorMessage =
554                __METHOD__ . ' called on {target} which has a non-Type argument, {typeZid} at key {key}';
555            $this->logger->debug(
556                $errorMessage,
557                [
558                    'target' => $target,
559                    'typeZid' => $targetTypeZid,
560                    'key' => $argumentKey
561                ]
562            );
563            $span->setAttributes( [
564                'response.status_code' => HttpStatus::BAD_REQUEST,
565                'exception.message' => $errorMessage
566            ] )->end();
567
568            // Die with Z_ERROR_NOT_IMPLEMENTED_YET
569            $this->dieRESTfullyWithZError(
570                ZErrorFactory::createZErrorInstance(
571                    ZErrorTypeRegistry::Z_ERROR_NOT_IMPLEMENTED_YET, [ 'data' => $target ]
572                ),
573                HttpStatus::BAD_REQUEST,
574                [
575                    'target' => $target,
576                    'mode' => 'input'
577                ]
578            );
579        }
580
581        // Check if the argument is a ZID to an instance of the right Type:
582        // * if so, builds a ZReference object
583        // * if not a reference, proceeds with building parser function
584        if ( $this->isArgumentValidReference( $providedArgument, $targetTypeObject, $target, $span ) ) {
585            return new ZReference( $providedArgument );
586        }
587
588        // If type is a language and value is not a zid, get language zid from Bcp47code
589        if ( $targetTypeZid === ZTypeRegistry::Z_LANGUAGE ) {
590            $languageZid = $this->getLanguageZid( $providedArgument, $argumentKey, $span );
591            return new ZReference( $languageZid );
592        }
593
594        // At this point, we know it's a string input to a non-string Type, so we need to parse it
595        $typeParser = $targetTypeObject->getParserFunction();
596
597        // Type has no parser
598        if ( $typeParser === false ) {
599            // User is trying to use a parameter that can't be parsed from text
600            $errorMessage =
601                __METHOD__ . ' called on {target} with an unparseable input, {targetTypeZid} in position {pos}';
602            $this->logger->debug(
603                $errorMessage,
604                [
605                    'target' => $target,
606                    'targetTypeZid' => $targetTypeZid,
607                    'key' => $argumentKey
608                ]
609            );
610            $span->setAttributes( [
611                'response.status_code' => HttpStatus::BAD_REQUEST,
612                'exception.message' => $errorMessage
613            ] )->end();
614
615            // Die with Z_ERROR_NOT_IMPLEMENTED_YET
616            $this->dieRESTfullyWithZError(
617                ZErrorFactory::createZErrorInstance(
618                    ZErrorTypeRegistry::Z_ERROR_NOT_IMPLEMENTED_YET, [ 'data' => $target ]
619                ),
620                HttpStatus::BAD_REQUEST,
621                [
622                    'target' => $target,
623                    'mode' => 'input'
624                ]
625            );
626        }
627
628        // At this point, we know the argument should be parsed and a parser is available, so let's schedule it
629        return new ZFunctionCall( new ZReference( $typeParser ), [
630            $typeParser . "K1" => new ZString( $providedArgument ),
631            $typeParser . "K2" => new ZReference( $parseLanguageZid )
632        ] );
633    }
634
635    /**
636     * Verifies the validity of a referenced argument with respect to the expected type:
637     * * if the string argument is a reference to a valid type, returns true
638     * * if the string argument is not a reference, returns false
639     * * if it's a wrong reference or a reference to a non-valid type, dies with error
640     *
641     * @param string $providedArgument
642     * @param ZType $targetTypeObject
643     * @param string $targetFunction
644     * @param SpanInterface $span
645     * @return bool
646     */
647    private function isArgumentValidReference(
648        $providedArgument,
649        $targetTypeObject,
650        $targetFunction,
651        $span
652    ): bool {
653        // The provided argument is a string value (not a reference): it should be parsed
654        // This excludes Z6s and Z1s as they have already been handled.
655        if ( !ZObjectUtils::isValidZObjectReference( $providedArgument ) ) {
656            return false;
657        }
658
659        // If the provided argument is a reference to a ZObject, we fetch it; try cache first, else DB
660        $referencedArgument = $this->zObjectStore->fetchZObject( $providedArgument );
661        if ( $referencedArgument === false ) {
662            // Fatal — it's a ZID but not to an extant Object.
663            $errorMessage =
664                __METHOD__ . ' called on {providedArgument} for function {targetFunction} but it doesn\'t exist';
665            $this->logger->debug(
666                $errorMessage,
667                [
668                    'providedArgument' => $providedArgument,
669                    'targetFunction' => $targetFunction
670                ]
671            );
672            $span->setAttributes( [
673                'response.status_code' => HttpStatus::BAD_REQUEST,
674                'exception.message' => $errorMessage
675            ] )->end();
676
677            // Die with Z_ERROR_ZID_NOT_FOUND
678            $this->dieRESTfullyWithZError(
679                ZErrorFactory::createZErrorInstance(
680                    ZErrorTypeRegistry::Z_ERROR_ZID_NOT_FOUND, [ 'data' => $providedArgument ]
681                ),
682                HttpStatus::BAD_REQUEST,
683                [
684                    'target' => $targetFunction,
685                    'mode' => 'input'
686                ]
687            );
688        }
689
690        $referencedArgumentType = $referencedArgument->getInnerZObject()->getZType();
691        $expectedArgumentType = $targetTypeObject->getTypeId()->getZValue();
692
693        if ( $referencedArgumentType !== $expectedArgumentType ) {
694            // Failure — it's a ZID but not to an instance of the right Type.
695            $errorMessage = __METHOD__ . ' called on {arg} for function {function} but it\'s not a {expectedType}';
696            $this->logger->debug(
697                $errorMessage,
698                [
699                    'arg' => $providedArgument,
700                    'function' => $targetFunction,
701                    'expectedType' => $expectedArgumentType
702                ]
703            );
704            $span->setAttributes( [
705                    'response.status_code' => HttpStatus::BAD_REQUEST,
706                    'exception.message' => $errorMessage
707                ] );
708            $span->end();
709            $this->dieRESTfullyWithZError(
710                ZErrorFactory::createZErrorInstance(
711                    ZErrorTypeRegistry::Z_ERROR_ARGUMENT_TYPE_MISMATCH,
712                    [
713                        'expected' => $expectedArgumentType,
714                        'actual' => $referencedArgumentType,
715                        'argument' => $providedArgument
716                    ]
717                ),
718                HttpStatus::BAD_REQUEST,
719                [
720                    'target' => $targetFunction,
721                    'mode' => 'input'
722                ]
723            );
724        }
725
726        // If the argument is passed as reference but is not an enum,
727        // we should probably flag this, as it's a weird use case:
728        if ( !$targetTypeObject->isEnumType() ) {
729            $this->logger->debug(
730                __METHOD__ . ' found reference {arg} of non-enum type {type} as input to {function}',
731                [
732                    'arg' => $providedArgument,
733                    'type' => $referencedArgumentType,
734                    'function' => $targetFunction
735                ]
736            );
737        }
738
739        // Given argument is a reference to an instance of the expected type: it should not be parsed
740        // * e.g. Expected type is enum Boolean/Z40, given arg is True/Z41
741        // * e.g. Expected type is String/Z6, given arg is reference to a persisted Z6
742        return true;
743    }
744
745    /**
746     * Builds a renderer function call that wraps the given function call if:
747     * * The expected output type is a valid type, and
748     * * The output type has a renderer function
749     *
750     * @param string $target - zid of the target function
751     * @param string $targetReturnType - zid of the return type of the target function
752     * @param string $renderLanguageZid - zid of the render language
753     * @param ZFunctionCall $callObject - function call object
754     * @param SpanInterface $span
755     */
756    private function buildRenderedOutput(
757        $target,
758        $targetReturnType,
759        $renderLanguageZid,
760        $callObject,
761        $span
762    ): ZFunctionCall {
763        // Fetch target output type to figure out if it has a renderer function; try cache first, else DB
764        $typeObject = $this->zObjectStore->fetchZObject( $targetReturnType );
765        if ( !$typeObject ) {
766            $errorMessage = __METHOD__ . ' called on {target} which has a not-found output type {typeZid}';
767            $this->logger->debug(
768                $errorMessage,
769                [
770                    'target' => $target,
771                    'typeZid' => $targetReturnType
772                ]
773            );
774            $span->setAttributes( [
775                'response.status_code' => HttpStatus::BAD_REQUEST,
776                'exception.message' => $errorMessage
777            ] )->end();
778
779            // Dies with Z_ERROR_ZID_NOT_FOUND
780            $this->dieRESTfullyWithZError(
781                ZErrorFactory::createZErrorInstance( ZErrorTypeRegistry::Z_ERROR_ZID_NOT_FOUND, [ 'data' => $target ] ),
782                HttpStatus::BAD_REQUEST,
783                [
784                    'target' => $target,
785                    'mode' => 'output'
786                ]
787            );
788        }
789
790        $targetReturnTypeObject = $typeObject->getInnerZObject();
791        if ( !( $targetReturnTypeObject instanceof ZType ) ) {
792            // It's somehow not to a Type, because:
793            // * Output is a generic type; the function input is not supported.
794            // * There is an error in the content; the function is not wellformed (not very likely).
795            $errorMessage = __METHOD__ . ' called on {target} which has a non-Type output, {targetReturnType}';
796            $this->logger->debug(
797                $errorMessage,
798                [
799                    'target' => $target,
800                    'targetReturnType' => $targetReturnType
801                ]
802            );
803            $span->setAttributes( [
804                'response.status_code' => HttpStatus::BAD_REQUEST,
805                'exception.message' => $errorMessage
806            ] )->end();
807
808            // Die with Z_ERROR_NOT_IMPLEMENTED_YET
809            $this->dieRESTfullyWithZError(
810                ZErrorFactory::createZErrorInstance(
811                    ZErrorTypeRegistry::Z_ERROR_NOT_IMPLEMENTED_YET, [ 'data' => $target ]
812                ),
813                HttpStatus::BAD_REQUEST,
814                [
815                    'target' => $target,
816                    'mode' => 'output'
817                ]
818            );
819        }
820
821        $rendererFunction = $targetReturnTypeObject->getRendererFunction();
822
823        if ( $rendererFunction === false ) {
824            // User is trying to use a ZFunction that returns something which doesn't have a renderer
825            $errorMessage = __METHOD__ . ' called on {target} with an unrenderable output, {targetReturnType}';
826            $this->logger->debug(
827                $errorMessage,
828                [
829                    'target' => $target,
830                    'targetReturnType' => $targetReturnType
831                ]
832            );
833            $span->setAttributes( [
834                'response.status_code' => HttpStatus::BAD_REQUEST,
835                'exception.message' => $errorMessage
836            ] )->end();
837
838            // Die with Z_ERROR_NOT_IMPLEMENTED_YET
839            $this->dieRESTfullyWithZError(
840                ZErrorFactory::createZErrorInstance(
841                    ZErrorTypeRegistry::Z_ERROR_NOT_IMPLEMENTED_YET, [ 'data' => $target ]
842                ),
843                HttpStatus::BAD_REQUEST,
844                [
845                    'target' => $target,
846                    'mode' => 'output'
847                ]
848            );
849        }
850
851        // At this point, we know that we must render, so wrap the function call in that
852        return new ZFunctionCall( new ZReference( $rendererFunction ), [
853            $rendererFunction . "K1" => $callObject,
854            $rendererFunction . "K2" => new ZReference( $renderLanguageZid )
855        ] );
856    }
857
858    /**
859     * A convenience function for making a ZFunctionCall and returning its result to embed within a page.
860     * Throws different ZErrorExceptions wrapping the ZErrors:
861     * * Z_ERROR_API_FAILURE: for any exceptions thrown by the Api before completing the Orchestrator request
862     * * Z_ERROR_EVALUATION: for any errors in the Orchestrator or its response
863     * * Z_ERROR_INVALID_EVALUATION_RESULT: for successful Orchestrator response but unexpected response
864     *
865     * @param string $call The ZFunctionCall to make, as a JSON object turned into a string
866     * @param string $renderLanguageCode The code of the language in which to render errors, e.g. fr
867     * @param SpanInterface $span Trace instance for adding spans to a trace
868     * @return stdClass Currently the only permissable response objects are strings
869     * @throws ZErrorException
870     */
871    private function makeRequest( $call, $renderLanguageCode, $span ): stdClass {
872        // TODO (T407490): Can we do an Orchestrator call directly here using WikiLambdaApiBase::executeFunctionCall()
873        // (But note that we're not an ActionAPI class, so can't extend that… Meh.)
874        // Also, consider if we can do an Orchestrator call simply calling
875        // OrchestratorRequest instead of layering APIs.
876        $api = new ApiMain( new FauxRequest() );
877        $request = new FauxRequest(
878            [
879                'format' => 'json',
880                'action' => 'wikilambda_function_call',
881                'wikilambda_function_call_zobject' => $call,
882                'uselang' => $renderLanguageCode
883            ],
884            /* wasPosted */
885            true
886        );
887
888        $context = new DerivativeContext( RequestContext::getMain() );
889        $context->setRequest( $request );
890        $api->setContext( $context );
891
892        // 1. Handle Exceptions thrown by ApiFunctionCall and throw Z_ERROR_API_FAILURE
893
894        // Using FauxRequest means ApiMain is internal and hence $api->execute()
895        // doesn't do any error handling. We need to catch any Exceptions:
896        // * dieWithError: throws ApiUsageException with key="apierror-*"
897        // * dieWithZError: throws ApiUsageException with key="wikilambda-zerror"
898        // * other exceptions: throws MWException
899        try {
900            $api->execute();
901        } catch ( ApiUsageException $e ) {
902            $apiMessage = $e->getStatusValue()->getErrors()[0]['message'];
903            '@phan-var \MediaWiki\Api\ApiMessage $apiMessage';
904
905            // Log error message thrown by ApiFunctionCall; this is almost certainly a 429 /
906            // "You have too many function calls executing right now." error.
907            $errorMessage = __METHOD__ . ' executed ApiFunctionCall which threw an ApiUsageException: {error}';
908            $this->logger->info(
909                $errorMessage,
910                [ 'error' => $e->getMessage() ]
911            );
912            $span->setSpanStatus( SpanInterface::SPAN_STATUS_ERROR )
913                ->setAttributes( [
914                    'error.message' => $errorMessage
915                ] );
916
917            if ( $apiMessage->getApiCode() === 'wikilambda-zerror' ) {
918                // Throw ZErrorException with Z_ERROR_API_FAILURE with propagated error:
919                $zerror = ZObjectFactory::create( $apiMessage->getApiData()[ 'zerror' ] );
920                '@phan-var \MediaWiki\Extension\WikiLambda\ZObjects\ZError $zerror';
921                $span->setSpanStatus( SpanInterface::SPAN_STATUS_ERROR )
922                    ->setAttributes( [
923                        'error.message' => $zerror
924                    ] );
925                throw new ZErrorException( ZErrorFactory::createApiFailureError( $zerror, $call ) );
926            }
927
928            $errorMessage = $e->getMessage();
929            $span->setSpanStatus( SpanInterface::SPAN_STATUS_ERROR )
930                ->setAttributes( [
931                    'error.message' => $errorMessage
932                ] );
933            // Throw ZErrorException with Z_ERROR_API_FAILURE error:
934            throw new ZErrorException( ZErrorFactory::createApiFailureError( $errorMessage, $call ) );
935        } catch ( Exception $e ) {
936            // Log unhandled exception thrown by ApiFunctionCall
937            $errorMessage = __METHOD__ . ' executed ApiFunctionCall which threw an unhandled exception: {error}';
938            $this->logger->warning(
939                $errorMessage,
940                [ 'error' => $e->getMessage() ]
941            );
942            $span->setSpanStatus( SpanInterface::SPAN_STATUS_ERROR )
943                ->setAttributes( [
944                    'error.message' => $errorMessage
945                ] );
946            // Throw ZErrorException with Z_ERROR_API_FAILURE error:
947            throw new ZErrorException( ZErrorFactory::createApiFailureError( $e->getMessage(), $call ) );
948        } finally {
949            $span->end();
950        }
951
952        $outerResponse = $api->getResult()->getResultData( [], [ 'Strip' => 'all' ] );
953
954        // 2. Handle non valid Orchestrator responses and throw Z_ERROR_EVALUATION
955
956        // Now we know that the request has not failed before it even got to the orchestrator, get the response
957        // JSON string as a ZResponseEnvelope (falling back to an empty string in case it's unset).
958        $response = ZObjectFactory::create(
959            json_decode( $outerResponse['wikilambda_function_call']['data'] ?? '' )
960        );
961
962        if ( !( $response instanceof ZResponseEnvelope ) ) {
963            // The server's not given us a result! This is an unexpected system error
964            $responseType = $response->getZType();
965
966            // Log non-valid Orchestrator response
967            $errorMessage =
968                __METHOD__ . ' got a non-valid response from the server of type {responseType} with call: {call}';
969            $this->logger->error(
970                $errorMessage,
971                [
972                    'responseType' => $responseType,
973                    'call' => $call
974                ]
975            );
976
977            $span->setSpanStatus( SpanInterface::SPAN_STATUS_ERROR )
978                ->setAttributes( [
979                    'error.message' => $errorMessage
980                ] );
981            $span->end();
982            // Throw ZErrorException for evaluation error:
983            throw new ZErrorException( ZErrorFactory::createEvaluationError(
984                "Server returned a non-result of type '$responseType'!",
985                $call
986            ) );
987        }
988
989        // 3. Handle valid Orchestrator responses that might have errors: throw Z_ERROR_EVALUATION
990
991        if ( $response->hasErrors() ) {
992            // If the server has responded with a Z22 with errors, throw evaluation error
993            $zerror = $response->getErrors();
994
995            // Log a debug message, Orchestrator returned a valid response but function call failed
996            $errorMessage = __METHOD__ . ' got an error-ful Z22 back from the server: {error}';
997            $this->logger->debug(
998                $errorMessage,
999                [ 'error' => $zerror->getSerialized() ]
1000            );
1001            $span->setSpanStatus( SpanInterface::SPAN_STATUS_ERROR )
1002                ->setAttributes( [
1003                    'error.message' => $errorMessage
1004                ] );
1005            $span->end();
1006            if ( !( $zerror instanceof ZError ) ) {
1007                // Throw ZErrorException for evaluation error, wrap the non error in a Z500:
1008                throw new ZErrorException( ZErrorFactory::createEvaluationError( new ZQuote( $zerror ), $call ) );
1009            }
1010
1011            // Throw ZErrorException for evaluation error, propagate existing zerror:
1012            throw new ZErrorException( ZErrorFactory::createZErrorInstance(
1013                ZErrorTypeRegistry::Z_ERROR_EVALUATION,
1014                [
1015                    'functionCall' => $call,
1016                    'error' => $zerror
1017                ]
1018            ) );
1019        }
1020
1021        // Response envelope value (Z22K1)
1022        $responseValue = $response->getZValue();
1023        // If the response value is not a ZString or ZHtmlFragment, we can't handle it
1024        if (
1025            $responseValue->getZType() !== ZTypeRegistry::Z_STRING &&
1026            $responseValue->getZType() !== ZTypeRegistry::Z_HTML_FRAGMENT
1027        ) {
1028            // Log a debug message, Orchestrator returned a valid response but not a string
1029            $errorMessage =
1030                __METHOD__ . ' got a non-string output from the server of type {responseType} with call: {call}';
1031            $this->logger->debug(
1032                $errorMessage,
1033                [
1034                    'responseType' => $responseValue->getZType(),
1035                    'call' => $call
1036                ]
1037            );
1038            $span->setSpanStatus( SpanInterface::SPAN_STATUS_ERROR )
1039                ->setAttributes( [
1040                    'error.message' => $errorMessage
1041                ] );
1042            $span->end();
1043            // Throw ZErrorException for invalid evaluator result
1044            throw new ZErrorException( ZErrorFactory::createZErrorInstance(
1045                ZErrorTypeRegistry::Z_ERROR_INVALID_EVALUATION_RESULT, [ 'result' => $responseValue ]
1046            ) );
1047        }
1048
1049        $span->setSpanStatus( SpanInterface::SPAN_STATUS_OK );
1050        $span->end();
1051
1052        // SUCCESS! if type is Z6 (string) or Z89 (HTML fragment), return the value
1053        return (object)[
1054            'value' => trim( $responseValue->getZValue() ),
1055            'type' => $responseValue->getZType()
1056        ];
1057    }
1058
1059    /** @inheritDoc */
1060    public function applyCacheControl( ResponseInterface $response ) {
1061        if ( $response->getStatusCode() >= 200 && $response->getStatusCode() < 300 ) {
1062            $response->setHeader( 'Cache-Control', 'public,must-revalidate,s-maxage=' . 60 * 60 * 24 );
1063        }
1064    }
1065
1066    /** @inheritDoc */
1067    public function needsWriteAccess(): bool {
1068        // This is a rough approximation, as we have no access to the User object in the REST API, but
1069        // we want the system to scale back access to this endpoint.
1070        return true;
1071    }
1072
1073    /** @inheritDoc */
1074    public function getParamSettings() {
1075        return [
1076            'zid' => [
1077                Handler::PARAM_SOURCE => 'path',
1078                ParamValidator::PARAM_TYPE => 'string',
1079                ParamValidator::PARAM_ISMULTI => false,
1080                ParamValidator::PARAM_REQUIRED => true,
1081            ],
1082            'arguments' => [
1083                Handler::PARAM_SOURCE => 'path',
1084                ParamValidator::PARAM_TYPE => 'string',
1085                ParamValidator::PARAM_ISMULTI => false,
1086                ParamValidator::PARAM_REQUIRED => true,
1087            ],
1088            'parselang' => [
1089                Handler::PARAM_SOURCE => 'path',
1090                ParamValidator::PARAM_TYPE => 'string',
1091                ParamValidator::PARAM_DEFAULT => 'en',
1092                ParamValidator::PARAM_REQUIRED => false,
1093            ],
1094            'renderlang' => [
1095                Handler::PARAM_SOURCE => 'path',
1096                ParamValidator::PARAM_TYPE => 'string',
1097                ParamValidator::PARAM_DEFAULT => 'en',
1098                ParamValidator::PARAM_REQUIRED => false,
1099            ],
1100        ];
1101    }
1102}