Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
71.75% covered (warning)
71.75%
221 / 308
61.54% covered (warning)
61.54%
8 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
WikiLambdaApiBase
71.75% covered (warning)
71.75%
221 / 308
61.54% covered (warning)
61.54%
8 / 13
96.79
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
 setUp
50.00% covered (danger)
50.00%
4 / 8
0.00% covered (danger)
0.00%
0 / 1
2.50
 execute
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 run
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 dieWithZError
66.67% covered (warning)
66.67%
8 / 12
0.00% covered (danger)
0.00%
0 / 1
2.15
 executeFunctionCall
57.05% covered (warning)
57.05%
89 / 156
0.00% covered (danger)
0.00%
0 / 1
43.67
 getResponseEnvelope
100.00% covered (success)
100.00%
69 / 69
100.00% covered (success)
100.00%
1 / 1
5
 getFailedResponseEnvelope
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setLogger
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLogger
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 submitFunctionCallEvent
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
2
 submitMetricsEvent
61.54% covered (warning)
61.54%
8 / 13
0.00% covered (danger)
0.00%
0 / 1
2.23
 hasUnsavedCode
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
9
1<?php
2
3namespace MediaWiki\Extension\WikiLambda\ActionAPI;
4
5use GuzzleHttp\Client;
6use GuzzleHttp\Exception\ConnectException;
7use GuzzleHttp\Exception\GuzzleException;
8use GuzzleHttp\Exception\TooManyRedirectsException;
9use JsonException;
10use MediaWiki\Api\ApiBase;
11use MediaWiki\Api\ApiMain;
12use MediaWiki\Api\ApiUsageException;
13use MediaWiki\Extension\EventLogging\EventLogging;
14use MediaWiki\Extension\WikiLambda\HttpStatus;
15use MediaWiki\Extension\WikiLambda\OrchestratorRequest;
16use MediaWiki\Extension\WikiLambda\Registry\ZErrorTypeRegistry;
17use MediaWiki\Extension\WikiLambda\Registry\ZTypeRegistry;
18use MediaWiki\Extension\WikiLambda\Tests\Integration\MockOrchestratorRequest;
19use MediaWiki\Extension\WikiLambda\ZErrorException;
20use MediaWiki\Extension\WikiLambda\ZErrorFactory;
21use MediaWiki\Extension\WikiLambda\ZObjectFactory;
22use MediaWiki\Extension\WikiLambda\ZObjects\ZError;
23use MediaWiki\Extension\WikiLambda\ZObjects\ZFunctionCall;
24use MediaWiki\Extension\WikiLambda\ZObjects\ZResponseEnvelope;
25use MediaWiki\Logger\LoggerFactory;
26use MediaWiki\MediaWikiServices;
27use MediaWiki\PoolCounter\PoolCounterWorkViaCallback;
28use MediaWiki\Registration\ExtensionRegistry;
29use MediaWiki\Status\Status;
30use Psr\Log\LoggerAwareInterface;
31use Psr\Log\LoggerInterface;
32use Wikimedia\RequestTimeout\TimeoutException;
33
34/**
35 * WikiLambda Base API util
36 *
37 * This abstract class extends the Wikimedia's ApiBase class
38 * and provides specific additional methods.
39 *
40 * @stable to extend
41 *
42 * @ingroup API
43 * @copyright 2020– Abstract Wikipedia team; see AUTHORS.txt
44 * @license MIT
45 */
46abstract class WikiLambdaApiBase extends ApiBase implements LoggerAwareInterface {
47
48    protected OrchestratorRequest $orchestrator;
49    protected string $orchestratorHost;
50    protected LoggerInterface $logger;
51    protected bool $isPublicApi;
52
53    public const FUNCTIONCALL_POOL_COUNTER_TYPE = 'WikiLambdaFunctionCall';
54
55    public function __construct(
56        ApiMain $mainModule,
57        string $moduleName,
58        string $modulePrefix = '',
59        bool $isPublicApi = false
60    ) {
61        parent::__construct( $mainModule, $moduleName, $modulePrefix );
62
63        $this->isPublicApi = $isPublicApi;
64    }
65
66    protected function setUp() {
67        $this->setLogger( LoggerFactory::getInstance( 'WikiLambda' ) );
68
69        // TODO (T330033): Consider injecting this service rather than just fetching from main
70        $services = MediaWikiServices::getInstance();
71
72        if ( defined( 'MW_PHPUNIT_TEST' ) ) {
73            // Phan is unhappy because, altough it's a sub-class, this is not loaded in prod code.
74            // @phan-suppress-next-line PhanTypeMismatchPropertyReal, PhanUndeclaredClassMethod
75            $this->orchestrator = new MockOrchestratorRequest();
76        } else {
77            $config = $services->getConfigFactory()->makeConfig( 'WikiLambda' );
78            $this->orchestratorHost = $config->get( 'WikiLambdaOrchestratorLocation' );
79            $client = new Client( [ "base_uri" => $this->orchestratorHost ] );
80            $this->orchestrator = new OrchestratorRequest( $client );
81        }
82    }
83
84    /**
85     * @inheritDoc
86     */
87    public function execute() {
88        if ( !$this->getConfig()->get( 'WikiLambdaEnableRepoMode' ) ) {
89            // Failure Level #3: Service is not available in a non-repo mode (e.g. client wiki),
90            // die with error (throws ApiUsageException)
91
92            // FIXME: shouldn't we return FORBIDDEN or even NOT_IMPLEMENTED instead of BAD_REQUEST?
93            // Train of thought, on multiple-request APIs like perform test, a BAD_REQUEST response
94            // means we'll abandon that one and go for the next. but WikiLambdaEnableRepoMode means
95            // that's not only a problem of the request, but that this particular service (client api)
96            // doesn't allow that kind of request. This feels more like a 403 than a 400
97            self::dieWithZError(
98                ZErrorFactory::createZErrorInstance( ZErrorTypeRegistry::Z_ERROR_USER_CANNOT_RUN, [] ),
99                HttpStatus::BAD_REQUEST
100            );
101        }
102
103        $this->run();
104    }
105
106    /**
107     * @inheritDoc
108     */
109    protected function run() {
110        // Failure Level #3: Service is not implemented, die with error
111        // (throws ApiUsageException)
112        self::dieWithZError(
113            ZErrorFactory::createZErrorInstance(
114                ZErrorTypeRegistry::Z_ERROR_UNKNOWN,
115                [ 'You must implement your run() method when using WikiLambdaApiBase' ]
116            ),
117            HttpStatus::NOT_IMPLEMENTED
118        );
119    }
120
121    /**
122     * @param ZError $zerror The ZError object to return to the user
123     * @param int $code HTTP error code, defaulting to 400/Bad Request
124     * @throws ApiUsageException
125     */
126    public static function dieWithZError( $zerror, $code = HttpStatus::BAD_REQUEST ): never {
127        try {
128            $errorData = $zerror->getErrorData();
129        } catch ( ZErrorException ) {
130            $errorData = [
131                'zerror' => $zerror->getSerialized()
132            ];
133        }
134
135        // Copied from ApiBase in an ugly way, so that we can be static.
136        throw ApiUsageException::newWithMessage(
137            null,
138            [ 'wikilambda-zerror', $zerror->getZErrorType() ],
139            null,
140            $errorData,
141            $code
142        );
143    }
144
145    /**
146     * Execute the Function Call passed as parameter.
147     *
148     * The following error handling contract enables the caller APIs to
149     * continue with more calls or exit early as needed:
150     *
151     * * If the orchestrator executes the function call successfully,
152     *   (even if the response contains an error)
153     *   it will return the resulting Response Envelope/Z22 object.
154     *
155     * * Failure Level #1: Request Failure.
156     *   If the orchestrator fails to execute this particular function
157     *   call, it will build and return a failure Response Envelope/Z22 object.
158     *
159     * * Failure Level #2: Temporary Service Failure.
160     *   If the orchestrator fails to execute the function call due to
161     *   service issues that might be temporary, such as:
162     *   * the concurrency limit was reached,
163     *   it will throw an exception so that the caller can decide how to handle it.
164     *
165     * * Failure Level #3: Execution Prohibited.
166     *   If the orchestrator fails to execute the function call due to
167     *   service issues that will make any call impossible, such as:
168     *   * the user doesn't have execute permission,
169     *   * the backend server is not reachable,
170     *   * the service is not implemented or disabled (e.g. client mode),
171     *   it will directly die with error.
172     *
173     * @param ZFunctionCall|\stdClass $zObject - Function call, either canonical or normal form.
174     * @param array $flags - Array with the boolean flags 'validate', 'bypassCache' and 'isUnsavedCode'
175     * @return array - Response from the orchestrator, with the keys 'result' and 'httpStatusCode'
176     * @throws ApiUsageException
177     */
178    protected function executeFunctionCall( $zObject, $flags ) {
179        // Extract flags and set their default values
180        $validate = (bool)( $flags[ 'validate' ] ?? true );
181        $bypassCache = (bool)( $flags[ 'bypassCache' ] ?? false );
182        $isUnsavedCode = (bool)( $flags[ 'isUnsavedCode' ] ?? false );
183
184        $zObjectAsStdClass = ( $zObject instanceof ZFunctionCall ) ? $zObject->getSerialized() : $zObject;
185        $zObjectAsString = json_encode( $zObjectAsStdClass );
186
187        // Get user authority and user name for rights, pool counter and logging
188        $userAuthority = $this->getContext()->getAuthority();
189        $userName = $userAuthority->getUser()->getName();
190
191        // 1. Check that input ZObject is a (normal or canonical) Z7/Function Call
192        // Exit with failed response if zObject is:
193        // * null, a string, not an object, doesn't have Z1K1 or Z1K1 is not Z7
194        if (
195            !is_object( $zObjectAsStdClass ) ||
196            !isset( $zObjectAsStdClass->Z1K1 ) ||
197            (
198                $zObjectAsStdClass->Z1K1 !== 'Z7' &&
199                (
200                    !is_object( $zObjectAsStdClass->Z1K1 ) ||
201                    $zObjectAsStdClass->Z1K1->Z9K1 !== 'Z7'
202                )
203            )
204        ) {
205            // Failure Level #1: Bad Request, return Z22/Response Envalope with error
206            $this->getLogger()->info(
207                __METHOD__ . ' prevented from executing request "{request}" for user "{user}", not a valid Z7',
208                [
209                    'request' => $zObjectAsString,
210                    'user' => $userName,
211                ]
212            );
213            $zError = ZErrorFactory::createZErrorInstance( ZErrorTypeRegistry::Z_ERROR_OBJECT_TYPE_MISMATCH, [
214                'expected' => ZTypeRegistry::Z_FUNCTION,
215                'actual' => $zObjectAsStdClass
216            ] );
217            self::dieWithZError( $zError, HttpStatus::BAD_REQUEST );
218        }
219
220        // LOG: request and flags
221        $this->getLogger()->debug(
222            __METHOD__ . ' called',
223            [
224                'request' => $zObjectAsString,
225                'validate' => $validate,
226                'bypassCache' => $bypassCache
227            ]
228        );
229
230        // 2. Check that the user has the appropriate permissions to run the function call
231        // 2.a. User can execute functions from public or internal API
232        $executionRight = $this->isPublicApi ? 'wikifunctions-run' : 'wikilambda-execute';
233        if ( !$userAuthority->isAllowed( $executionRight ) ) {
234            $this->getLogger()->info(
235                __METHOD__ . ' prevented from executing for user "{user}", user not allowed',
236                [
237                    'request' => $zObjectAsString,
238                    'user' => $userName,
239                ]
240            );
241
242            // Failure Level #3: Execution is forbidden, die with error
243            // (throws ApiUsageException)
244            $zError = ZErrorFactory::createZErrorInstance( ZErrorTypeRegistry::Z_ERROR_USER_CANNOT_RUN, [] );
245            self::dieWithZError( $zError, HttpStatus::FORBIDDEN );
246        }
247
248        // 2.b. If user is trying to run unsaved code (a literal function with a literal implementation)
249        // from the internal API, check for special right. From public API, always deny.
250        if (
251            $isUnsavedCode &&
252            ( $this->isPublicApi || !$userAuthority->isAllowed( 'wikilambda-execute-unsaved-code' ) )
253        ) {
254            $this->getLogger()->info(
255                __METHOD__ . ' prevented from executing unsaved code for user "{user}", user not allowed',
256                [
257                    'request' => $zObjectAsString,
258                    'user' => $userName,
259                ]
260            );
261
262            // Failure Level #3: Execution is forbidden, die with error
263            // (throws ApiUsageException)
264            $zError = ZErrorFactory::createZErrorInstance( ZErrorTypeRegistry::Z_ERROR_USER_CANNOT_RUN, [] );
265            self::dieWithZError( $zError, HttpStatus::FORBIDDEN );
266        }
267
268        // 2.b. User can bypass the cache if flag bypassCache is true
269        if ( $bypassCache && !$userAuthority->isAllowed( 'wikilambda-bypass-cache' ) ) {
270            $this->getLogger()->info(
271                __METHOD__ . ' prevented from executing with cache bypass for user "{user}", user not allowed',
272                [
273                    'request' => $zObjectAsString,
274                    'user' => $userName,
275                ]
276            );
277
278            // Failure Level #3: Execution is forbidden, die with error
279            // (throws ApiUsageException)
280            $zError = ZErrorFactory::createZErrorInstance( ZErrorTypeRegistry::Z_ERROR_USER_CANNOT_RUN, [] );
281            self::dieWithZError( $zError, HttpStatus::FORBIDDEN );
282        }
283
284        // 3. Call OrchestratorRequest::orchestrate if there are not too many requests
285        // being run at the same time by the same user.
286        $queryArguments = [
287            'zobject' => $zObjectAsStdClass,
288            'doValidate' => $validate
289        ];
290        try {
291            $method = __METHOD__;
292            $work = new PoolCounterWorkViaCallback( self::FUNCTIONCALL_POOL_COUNTER_TYPE, $userName, [
293                'doWork' => function () use ( $queryArguments, $bypassCache, $method ) {
294                    $this->getLogger()->debug(
295                        '{method} running {caller} request',
296                        [
297                            'method' => $method,
298                            'caller' => static::class,
299                            'query' => $queryArguments
300                        ]
301                    );
302                    return $this->orchestrator->orchestrate( $queryArguments, $bypassCache );
303                },
304                // Failure Level #2: Execution is temporarily forbidden due to too many requests
305                // at once. Throw an exception so that the caller API can handle it as needed.
306                // E.g. FunctionCall might want to die with error, while PerformTest will
307                // return normally with all those tests that could be run before.
308                'error' => function ( Status $status ) use ( $queryArguments, $userName, $method ): never {
309                    $this->getLogger()->info(
310                        '{method} rejected {caller} request due to too many requests from source "{user}"',
311                        [
312                            'method' => $method,
313                            'caller' => static::class,
314                            'user' => $userName,
315                            'query' => $queryArguments
316                        ]
317                    );
318                    $this->dieWithError(
319                        [ "apierror-wikilambda_function_call-concurrency-limit" ],
320                        null, null, HttpStatus::TOO_MANY_REQUESTS
321                    );
322                }
323            ] );
324
325            $response = $work->execute();
326
327            $this->getLogger()->debug(
328                __METHOD__ . ' executed successfully',
329                [
330                    'request' => $zObjectAsString,
331                    'response' => $response[ 'result' ],
332                ]
333            );
334
335            // 4. Everything went well: return the raw orchestrator response
336            // (an array with 'result' and 'httpStatus' keys) so that the caller
337            // does whatever they need (e.g. ApiPerformTest will convert it to a
338            // response envelope object, while ApiFunctionCall will just track the
339            // http status and return the result string without validating it)
340            return $response;
341
342        } catch ( ConnectException $exception ) {
343            // ConnectException exception is thrown in the event of a networking error.
344            // See: https://docs.guzzlephp.org/en/stable/quickstart.html#exceptions
345
346            // Failure Level #3: Service is not available, die with error
347            // (throws ApiUsageException)
348            $this->getLogger()->error(
349                __METHOD__ . ' failed to execute, server connection error: {exception}',
350                [
351                    'request' => $zObjectAsString,
352                    'exception' => $exception,
353                ]
354            );
355
356            $zError = ZErrorFactory::createZErrorInstance( ZErrorTypeRegistry::Z_ERROR_CONNECTION_FAILURE,
357                [ 'host' => $this->orchestratorHost ]
358            );
359            self::dieWithZError( $zError, HttpStatus::SERVICE_UNAVAILABLE );
360
361        } catch ( TooManyRedirectsException $exception ) {
362            // TooManyRedirectsException is thrown when too many redirects are followed.
363            // See: https://docs.guzzlephp.org/en/stable/quickstart.html#exceptions
364
365            // Failure Level #3: Service is not available, die with error
366            // (throws ApiUsageException)
367            $this->getLogger()->error(
368                __METHOD__ . ' failed to execute, too many redirects error: {exception}',
369                [
370                    'request' => $zObjectAsString,
371                    'exception' => $exception,
372                ]
373            );
374
375            $zError = ZErrorFactory::createZErrorInstance( ZErrorTypeRegistry::Z_ERROR_CONNECTION_FAILURE,
376                [ 'host' => $this->orchestratorHost ]
377            );
378            self::dieWithZError( $zError, HttpStatus::SERVICE_UNAVAILABLE );
379
380        } catch ( GuzzleException $exception ) {
381            // This should not happen, but just in case we ever change http_errors => false to something else
382            // See: https://docs.guzzlephp.org/en/stable/quickstart.html#exceptions
383
384            // Failure Level #3: Service is not available as it returned Not Found, die with error
385            // (throws ApiUsageException)
386            $this->getLogger()->error(
387                __METHOD__ . ' failed to execute with an uncaught GuzzleException: {exception}',
388                [
389                    'request' => $zObjectAsString,
390                    'exception' => $exception,
391                ]
392            );
393
394            $zError = ZErrorFactory::createZErrorInstance( ZErrorTypeRegistry::Z_ERROR_CONNECTION_FAILURE,
395                [ 'host' => $this->orchestratorHost ]
396            );
397            self::dieWithZError( $zError, HttpStatus::SERVICE_UNAVAILABLE );
398
399        } catch ( TimeoutException $exception ) {
400            // An exception generated by the RequestTimeout library.
401            // See: https://doc.wikimedia.org/mediawiki-libs-RequestTimeout/master/
402
403            // Failure Level #3: This request timed out, fully end the request
404            // (throws ApiUsageException)
405            $this->getLogger()->warning(
406                __METHOD__ . ' failed to execute with a TimeoutException: {exception}',
407                [
408                    'request' => $zObjectAsString,
409                    'exception' => $exception,
410                ]
411            );
412
413            $this->dieWithError(
414                [ "timeouterror-text", $exception->getLimit() ],
415                null, null, HttpStatus::SERVICE_UNAVAILABLE
416            );
417        }
418    }
419
420    /**
421     * Converts a response string into a valid ZResponseEnvelope and
422     * handles all possible errors from the conversion.
423     *
424     * If the response cannot be validated or the response type is
425     * not correct, it builds a new failure Response Envelope/Z22
426     * with the error details in its "errors" key.
427     *
428     * All errors captured and returned in this method will be wrapped
429     * in a Z507/Evaluator error, as they are errors carried by the
430     * response from the orchestrator service.
431     *
432     * This method will NOT throw any exception nor cause the API
433     * to die with error.
434     *
435     * @param string $response The JSON string being decoded
436     * @param string $call The JSON string of the function call
437     * @return ZResponseEnvelope
438     */
439    protected function getResponseEnvelope( string $response, string $call ): ZResponseEnvelope {
440        // Decode string JSON into stdClass
441        try {
442            $responseContents = json_decode( $response, false, 512, JSON_THROW_ON_ERROR );
443        } catch ( JsonException $e ) {
444            $this->getLogger()->error(
445                __METHOD__ . ' failed to execute, server response not valid Json: {exception}',
446                [
447                    'request' => $call,
448                    'response' => $response,
449                    'exception' => $e,
450                ]
451            );
452            // ZError: Invalid JSON returned by the evaluator
453            $zErrorJson = ZErrorFactory::createZErrorInstance( ZErrorTypeRegistry::Z_ERROR_INVALID_JSON, [
454                'message' => $e->getMessage(),
455                'data' => $response
456            ] );
457            $zError = ZErrorFactory::createZErrorInstance( ZErrorTypeRegistry::Z_ERROR_EVALUATION, [
458                'functionCall' => $call,
459                'error' => $zErrorJson
460            ] );
461            return $this->getFailedResponseEnvelope( $zError );
462        }
463
464        // (T414752) Trivial check if server hasn't responded with a ZObject, rather than loading all of ZObjectFactory
465        // e.g. "{"error":"Payload too large"}"
466        if ( !property_exists( $responseContents, ZTypeRegistry::Z_OBJECT_TYPE ) ) {
467            $this->getLogger()->warning(
468                __METHOD__ . ' failed to execute, server response was not a ZObject: {response}',
469                [
470                    'request' => $call,
471                    'response' => $response
472                ]
473            );
474            // ZError: Invalid ZObject returned by the evaluator
475            $zErrorType = ZErrorFactory::createZErrorInstance( ZErrorTypeRegistry::Z_ERROR_OBJECT_TYPE_MISMATCH, [
476                'expected' => ZTypeRegistry::Z_RESPONSEENVELOPE,
477                // If it's not a ZObject at all, just say it's a string
478                'actual' => ZTypeRegistry::Z_STRING
479            ] );
480            $zError = ZErrorFactory::createZErrorInstance( ZErrorTypeRegistry::Z_ERROR_EVALUATION, [
481                'functionCall' => $call,
482                'error' => $zErrorType
483            ] );
484            return $this->getFailedResponseEnvelope( $zError );
485        }
486
487        // Convert stdClass into ZObject by passing it through ZObjectFactory::create
488        try {
489            $responseObject = ZObjectFactory::create( $responseContents );
490        } catch ( ZErrorException $e ) {
491            $this->getLogger()->error(
492                __METHOD__ . ' failed to execute, server response not wellformed: {exception}',
493                [
494                    'request' => $call,
495                    'response' => $response,
496                    'exception' => $e,
497                ]
498            );
499            // ZError: Invalid ZObject returned by the evaluator
500            $zError = ZErrorFactory::createZErrorInstance( ZErrorTypeRegistry::Z_ERROR_EVALUATION, [
501                'functionCall' => $call,
502                'error' => $e->getZError()
503            ] );
504            return $this->getFailedResponseEnvelope( $zError );
505        }
506
507        // Check that returned object is of the right type
508        if ( !( $responseObject instanceof ZResponseEnvelope ) ) {
509            $this->getLogger()->error(
510                __METHOD__ . ' failed to execute, server response was not a Response Envelope',
511                [
512                    'request' => $call,
513                    'response' => $response
514                ]
515            );
516            // ZError: Invalid ZObject returned by the evaluator
517            $zErrorType = ZErrorFactory::createZErrorInstance( ZErrorTypeRegistry::Z_ERROR_OBJECT_TYPE_MISMATCH, [
518                'expected' => ZTypeRegistry::Z_RESPONSEENVELOPE,
519                'actual' => $responseObject->getZType()
520            ] );
521            $zError = ZErrorFactory::createZErrorInstance( ZErrorTypeRegistry::Z_ERROR_EVALUATION, [
522                'functionCall' => $call,
523                'error' => $zErrorType
524            ] );
525            return $this->getFailedResponseEnvelope( $zError );
526        }
527
528        // Response Envelope returned by the orchestrator is valid!
529        return $responseObject;
530    }
531
532    /**
533     * Builds and returns a Z22/ResponseEnvelope object wrapping a failure, so:
534     * * Z22K1 is Void/Z24, and
535     * * Z22K2 contains a Map with an "error" key with the given zError object
536     *
537     * @param ZError $zErrorObject
538     * @return ZResponseEnvelope
539     */
540    private function getFailedResponseEnvelope( $zErrorObject ): ZResponseEnvelope {
541        $zResponseMap = ZResponseEnvelope::wrapErrorInResponseMap( $zErrorObject );
542        return new ZResponseEnvelope( null, $zResponseMap );
543    }
544
545    /** @inheritDoc */
546    public function setLogger( LoggerInterface $logger ): void {
547        $this->logger = $logger;
548    }
549
550    /** @inheritDoc */
551    public function getLogger(): LoggerInterface {
552        return $this->logger;
553    }
554
555    /**
556     * Constructs and submits a metrics event representing this call.
557     *
558     * @param int $httpStatus
559     * @param string|null $function
560     * @param float $start
561     * @return void
562     */
563    protected function submitFunctionCallEvent( $httpStatus, $function, $start ): void {
564        $duration = 1000 * ( microtime( true ) - $start );
565
566        // Module name: wikilambda_function_call | wikilambda_run | wikilambda_perform_test
567        $action = $this->getModuleName();
568
569        $eventData = [
570            'http' => [ 'status_code' => $httpStatus ],
571            'total_time_ms' => $duration,
572        ];
573        if ( $function !== null ) {
574            $eventData['function'] = $function;
575        }
576
577        // This is our submission to the Analytics / metrics system (private data stream);
578        // if EventLogging isn't enabled, this will be a no-op.
579        $this->submitMetricsEvent( $action, $eventData );
580
581        // (T390548) This is our submission to the Prometheus / SLO system (public data stream).
582        // Note: There is already a metric stream provided out-of-the-box from us being part of the Action API,
583        // mediawiki_api_executeTiming_seconds{module="wikilambda_function_call",…}, but that does not include
584        // the HTTP status code, so we have to track our own.
585        MediaWikiServices::getInstance()->getStatsFactory()->withComponent( 'WikiLambda' )
586            // Will end up as 'mediawiki.WikiLambda.mw_to_orchestrator_api_call_seconds{status=…}'
587            ->getTiming( 'mw_to_orchestrator_api_call_seconds' )
588            // Note: We intentionally don't log the function here, for cardinality reasons
589            ->setLabel( 'status', (string)$httpStatus )
590            // The "observe" method takes milliseconds.
591            ->observe( $duration );
592    }
593
594    /**
595     * @param string $action An arbitrary string describing what's being recorded
596     * @param array $eventData Key-value pairs stating various characteristics of the action;
597     *  these must conform to the specified schema.
598     * @return void
599     */
600    protected function submitMetricsEvent( $action, $eventData ) {
601        if ( ExtensionRegistry::getInstance()->isLoaded( 'EventLogging' ) ) {
602            EventLogging::getMetricsPlatformClient()->submitInteraction(
603                'mediawiki.product_metrics.wikilambda_api',
604                '/analytics/mediawiki/product_metrics/wikilambda/api/1.0.0',
605                $action,
606                $eventData );
607        } else {
608            $this->getLogger()->debug(
609                __METHOD__ . ' unable to submit event; EventLogging not loaded',
610                [
611                    'action' => $action,
612                    'eventData' => $eventData
613                ]
614            );
615        }
616    }
617
618    /**
619     * Determines whether the input function call might execute unsaved code.
620     *
621     * To do this, we look at the function called by the function call (Z7K1)
622     * and, if the function is a literal, we explore its implementations.
623     * If any of the implementations is a literal, we consider it unsaved
624     * code, unless it's implementing Run Abstract Fragment/Z825 function.
625     *
626     * TODO figure out a better way to allow execution of fragments, which
627     * are unsaved code in the strict sense, but should be allowed to run:
628     * * E.g. if the implementation is literal, but is a composition, we can
629     *   allow it, but if any nested implementation has code, we should stop it.
630     * * E.g. if the literal implementation implements Z825, but does it by
631     *   a code implementation, we should mark it as unsaved code.
632     *
633     * @param \stdClass $functionCall
634     * @return bool
635     */
636    protected function hasUnsavedCode( $functionCall ): bool {
637        // If function is not an object, no danger; exit early
638        if (
639            !property_exists( $functionCall, ZTypeRegistry::Z_FUNCTIONCALL_FUNCTION ) ||
640            !is_object( $functionCall->{ ZTypeRegistry::Z_FUNCTIONCALL_FUNCTION } )
641        ) {
642            return false;
643        }
644
645        $function = $functionCall->{ ZTypeRegistry::Z_FUNCTIONCALL_FUNCTION };
646
647        // If function has no implementations, no danger; exit early
648        if (
649            !property_exists( $function, ZTypeRegistry::Z_FUNCTION_IMPLEMENTATIONS ) ||
650            count( $function->{ ZTypeRegistry::Z_FUNCTION_IMPLEMENTATIONS } ) <= 1
651        ) {
652            return false;
653        }
654
655        $implementations = $function->{ ZTypeRegistry::Z_FUNCTION_IMPLEMENTATIONS };
656
657        // We loop through all implementations for a chance of unsaved code;
658        // the orchestrator will try them in order, and if it finds a non valid
659        // implementation, it will continue with the next, making it possible to
660        // bypass this security check if we only checked for the first.
661        foreach ( array_slice( $implementations, 1 ) as $implementation ) {
662            // If implementation is a zid, no danger; continue
663            if ( !is_object( $implementation ) ) {
664                continue;
665            }
666
667            // If implementation is a literal, danger! mark as unsaved code,
668            // except when implementing Run Abstract Fragment/Z825 function.
669            if (
670                property_exists( $implementation, ZTypeRegistry::Z_IMPLEMENTATION_FUNCTION ) &&
671                $implementation->{ ZTypeRegistry::Z_IMPLEMENTATION_FUNCTION } !== ZTypeRegistry::Z_RUN_ABSTRACT_FRAGMENT
672            ) {
673                return true;
674            }
675        }
676
677        // All checks passed, no danger
678        return false;
679    }
680}