Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
28.42% covered (danger)
28.42%
52 / 183
33.33% covered (danger)
33.33%
3 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
WikiLambdaApiBase
28.42% covered (danger)
28.42%
52 / 183
33.33% covered (danger)
33.33%
3 / 9
235.29
0.00% covered (danger)
0.00%
0 / 1
 setUp
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 execute
22.22% covered (danger)
22.22%
2 / 9
0.00% covered (danger)
0.00%
0 / 1
3.88
 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
 returnWithZError
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 executeFunctionCall
26.56% covered (danger)
26.56%
34 / 128
0.00% covered (danger)
0.00%
0 / 1
79.93
 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
 submitMetricsEvent
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3namespace MediaWiki\Extension\WikiLambda\ActionAPI;
4
5use GuzzleHttp\Client;
6use GuzzleHttp\Exception\ClientException;
7use GuzzleHttp\Exception\ConnectException;
8use GuzzleHttp\Exception\ServerException;
9use MediaWiki\Api\ApiBase;
10use MediaWiki\Api\ApiUsageException;
11use MediaWiki\Extension\EventLogging\EventLogging;
12use MediaWiki\Extension\WikiLambda\OrchestratorRequest;
13use MediaWiki\Extension\WikiLambda\Registry\ZErrorTypeRegistry;
14use MediaWiki\Extension\WikiLambda\ZErrorException;
15use MediaWiki\Extension\WikiLambda\ZErrorFactory;
16use MediaWiki\Extension\WikiLambda\ZObjectFactory;
17use MediaWiki\Extension\WikiLambda\ZObjects\ZError;
18use MediaWiki\Extension\WikiLambda\ZObjects\ZFunctionCall;
19use MediaWiki\Extension\WikiLambda\ZObjects\ZResponseEnvelope;
20use MediaWiki\Json\FormatJson;
21use MediaWiki\Logger\LoggerFactory;
22use MediaWiki\MediaWikiServices;
23use MediaWiki\PoolCounter\PoolCounterWorkViaCallback;
24use MediaWiki\Registration\ExtensionRegistry;
25use MediaWiki\Status\Status;
26use Psr\Log\LoggerAwareInterface;
27use Psr\Log\LoggerInterface;
28use Wikimedia\RequestTimeout\RequestTimeoutException;
29
30/**
31 * WikiLambda Base API util
32 *
33 * This abstract class extends the Wikimedia's ApiBase class
34 * and provides specific additional methods.
35 *
36 * @stable to extend
37 *
38 * @ingroup API
39 * @copyright 2020– Abstract Wikipedia team; see AUTHORS.txt
40 * @license MIT
41 */
42abstract class WikiLambdaApiBase extends ApiBase implements LoggerAwareInterface {
43
44    protected OrchestratorRequest $orchestrator;
45    protected string $orchestratorHost;
46    protected LoggerInterface $logger;
47
48    protected function setUp() {
49        $this->setLogger( LoggerFactory::getInstance( 'WikiLambda' ) );
50
51        // TODO (T330033): Consider injecting this service rather than just fetching from main
52        $services = MediaWikiServices::getInstance();
53
54        $config = $services->getConfigFactory()->makeConfig( 'WikiLambda' );
55        $this->orchestratorHost = $config->get( 'WikiLambdaOrchestratorLocation' );
56        $client = new Client( [ "base_uri" => $this->orchestratorHost ] );
57        $this->orchestrator = new OrchestratorRequest( $client );
58    }
59
60    /**
61     * @inheritDoc
62     */
63    public function execute() {
64        // Exit if we're running in non-repo mode (e.g. on a client wiki)
65        if ( !$this->getConfig()->get( 'WikiLambdaEnableRepoMode' ) ) {
66            self::dieWithZError(
67                ZErrorFactory::createZErrorInstance(
68                    ZErrorTypeRegistry::Z_ERROR_USER_CANNOT_RUN,
69                    []
70                ),
71                501
72            );
73        }
74
75        $this->run();
76    }
77
78    /**
79     * @inheritDoc
80     */
81    protected function run() {
82        // Throw, not implemented
83        self::dieWithZError(
84            ZErrorFactory::createZErrorInstance(
85                ZErrorTypeRegistry::Z_ERROR_UNKNOWN,
86                [ 'You must implement your run() method when using WikiLambdaApiBase' ]
87            ),
88            501
89        );
90    }
91
92    /**
93     * @param ZError $zerror The ZError object to return to the user
94     * @param int $code HTTP error code, defaulting to 400/Bad Request
95     * @return never
96     */
97    public static function dieWithZError( $zerror, $code = 400 ) {
98        try {
99            $errorData = $zerror->getErrorData();
100        } catch ( ZErrorException $e ) {
101            $errorData = [
102                'zerror' => $zerror->getSerialized()
103            ];
104        }
105
106        // Copied from ApiBase in an ugly way, so that we can be static.
107        throw ApiUsageException::newWithMessage(
108            null,
109            [ 'wikilambda-zerror', $zerror->getZErrorType() ],
110            null,
111            $errorData,
112            $code
113        );
114    }
115
116    /**
117     * @param string $errorMessage
118     * @param string $zFunctionCallString
119     * @return ZResponseEnvelope
120     */
121    private function returnWithZError( $errorMessage, $zFunctionCallString ): ZResponseEnvelope {
122        $zErrorObject = ZErrorFactory::wrapMessageInZError(
123            $errorMessage,
124            $zFunctionCallString
125        );
126        $zResponseMap = ZResponseEnvelope::wrapErrorInResponseMap( $zErrorObject );
127        return new ZResponseEnvelope( null, $zResponseMap );
128    }
129
130    /**
131     * @param ZFunctionCall|\stdClass $zObject
132     * @param bool $validate
133     * @return ZResponseEnvelope
134     */
135    protected function executeFunctionCall( $zObject, $validate ) {
136        $zObjectAsStdClass = ( $zObject instanceof ZFunctionCall ) ? $zObject->getSerialized() : $zObject;
137        $zObjectAsString = json_encode( $zObjectAsStdClass );
138
139        if ( $zObjectAsStdClass->Z1K1 !== 'Z7' && $zObjectAsStdClass->Z1K1->Z9K1 !== 'Z7' ) {
140            $this->dieWithError( [ "apierror-wikilambda_function_call-not-a-function" ], null, null, 400 );
141        }
142
143        $this->getLogger()->debug(
144            __METHOD__ . ' called',
145            [
146                'zObject' => $zObjectAsString,
147                'validate' => $validate,
148            ]
149        );
150
151        // Unlike the Special pages, we don't have a helpful userCanExecute() method
152        if ( !$this->getContext()->getAuthority()->isAllowed( 'wikilambda-execute' ) ) {
153            $zError = ZErrorFactory::createZErrorInstance( ZErrorTypeRegistry::Z_ERROR_USER_CANNOT_RUN, [] );
154            self::dieWithZError( $zError, 403 );
155        }
156
157        $queryArguments = [
158            'zobject' => $zObjectAsStdClass,
159            'doValidate' => $validate
160        ];
161        try {
162            $work = new PoolCounterWorkViaCallback(
163                'WikiLambdaFunctionCall',
164                $this->getUser()->getName(),
165                [
166                    'doWork' => function () use ( $queryArguments ) {
167                        return $this->orchestrator->orchestrate( $queryArguments );
168                    },
169                    'error' => function ( Status $status ) {
170                        $this->dieWithError(
171                            [ "apierror-wikilambda_function_call-concurrency-limit" ],
172                            null, null, 429
173                        );
174                    }
175                ]
176            );
177            $response = $work->execute();
178
179            $this->getLogger()->debug(
180                __METHOD__ . ' executed successfully',
181                [
182                    'zObject' => $zObjectAsString,
183                    'validate' => $validate,
184                    'response' => $response,
185                ]
186            );
187
188            $responseContents = FormatJson::decode( $response );
189
190            try {
191                $responseObject = ZObjectFactory::create( $responseContents );
192            } catch ( ZErrorException $e ) {
193                $this->dieWithError(
194                    [
195                        'apierror-wikilambda_function_call-response-malformed',
196                        $e->getZErrorMessage()
197                    ],
198                    null, null, 500
199                );
200            }
201            '@phan-var \MediaWiki\Extension\WikiLambda\ZObjects\ZResponseEnvelope $responseObject';
202            return $responseObject;
203        } catch ( ConnectException $exception ) {
204            $this->dieWithError(
205                [ "apierror-wikilambda_function_call-not-connected", $this->orchestratorHost ],
206                null, null, 503
207            );
208        } catch ( ClientException | ServerException $exception ) {
209            if ( $exception->getResponse()->getStatusCode() === 404 ) {
210                $this->dieWithError(
211                    [ "apierror-wikilambda_function_call-not-connected", $this->orchestratorHost ],
212                    null, null, 503
213                );
214            }
215
216            $this->getLogger()->warning(
217                __METHOD__ . ' failed to execute with a ClientException/ServerException: {exception}',
218                [
219                    'zObject' => $zObjectAsString,
220                    'validate' => $validate,
221                    'exception' => $exception,
222                ]
223            );
224
225            return $this->returnWithZError(
226                $exception->getResponse()->getReasonPhrase(),
227                $zObjectAsString
228            );
229        } catch ( RequestTimeoutException $exception ) {
230            $this->getLogger()->warning(
231                __METHOD__ . ' failed to execute with a RequestTimeoutException: {exception}',
232                [
233                    'zObject' => $zObjectAsString,
234                    'validate' => $validate,
235                    'exception' => $exception,
236                ]
237            );
238
239            return $this->returnWithZError(
240                $exception->getMessage(),
241                $zObjectAsString
242            );
243        } catch ( ApiUsageException $exception ) {
244            // This is almost certainly a user-limit-error, and not worth worrying in the middleware
245            // about, so only log as debug() not warning()
246            $this->getLogger()->debug(
247                __METHOD__ . ' failed to execute with a ApiUsageException: {exception}',
248                [
249                    'zObject' => $zObjectAsString,
250                    'validate' => $validate,
251                    'exception' => $exception,
252                ]
253            );
254
255            return $this->returnWithZError(
256                $exception->getMessage(),
257                $zObjectAsString
258            );
259        } catch ( ZErrorException $exception ) {
260            // This is almost certainly a user-error, and not worth worrying in the middleware
261            // about, so only log as debug() not warning()
262            $this->getLogger()->debug(
263                __METHOD__ . ' failed to execute with a ZErrorException: {exception}',
264                [
265                    'zObject' => $zObjectAsString,
266                    'validate' => $validate,
267                    'exception' => $exception,
268                ]
269            );
270
271            return $this->returnWithZError(
272                $exception->getZErrorMessage(),
273                $zObjectAsString
274            );
275        } catch ( \Exception $exception ) {
276
277            $this->getLogger()->warning(
278                __METHOD__ . ' failed to execute with a general Exception: {exception}',
279                [
280                    'zObject' => $zObjectAsString,
281                    'validate' => $validate,
282                    'exception' => $exception,
283                ]
284            );
285
286            return $this->returnWithZError(
287                $exception->getMessage(),
288                $zObjectAsString
289            );
290        }
291    }
292
293    /** @inheritDoc */
294    public function setLogger( LoggerInterface $logger ): void {
295        $this->logger = $logger;
296    }
297
298    /** @inheritDoc */
299    public function getLogger(): LoggerInterface {
300        return $this->logger;
301    }
302
303    /**
304     * @param string $action An arbitrary string describing what's being recorded
305     * @param array $eventData Key-value pairs stating various characteristics of the action;
306     *  these must conform to the specified schema.
307     */
308    public function submitMetricsEvent( $action, $eventData ) {
309        if ( ExtensionRegistry::getInstance()->isLoaded( 'EventLogging' ) ) {
310            EventLogging::getMetricsPlatformClient()->submitInteraction(
311                'mediawiki.product_metrics.wikilambda_api',
312                '/analytics/mediawiki/product_metrics/wikilambda/api/1.0.0',
313                $action,
314                $eventData );
315        } else {
316            $this->getLogger()->debug(
317                __METHOD__ . ' unable to submit event; EventLogging not loaded',
318                [
319                    'action' => $action,
320                    'eventData' => $eventData
321                ]
322            );
323        }
324    }
325}