Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
37.50% covered (danger)
37.50%
60 / 160
50.00% covered (danger)
50.00%
3 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
WikiLambdaApiBase
37.50% covered (danger)
37.50%
60 / 160
50.00% covered (danger)
50.00%
3 / 6
107.13
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
 dieWithZError
38.89% covered (danger)
38.89%
7 / 18
0.00% covered (danger)
0.00%
0 / 1
2.91
 returnWithZError
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 executeFunctionCall
35.16% covered (danger)
35.16%
45 / 128
0.00% covered (danger)
0.00%
0 / 1
59.08
 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
1<?php
2
3namespace MediaWiki\Extension\WikiLambda\ActionAPI;
4
5use ApiBase;
6use ApiUsageException;
7use FormatJson;
8use GuzzleHttp\Client;
9use GuzzleHttp\Exception\ClientException;
10use GuzzleHttp\Exception\ConnectException;
11use GuzzleHttp\Exception\ServerException;
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\Logger\LoggerFactory;
21use MediaWiki\MediaWikiServices;
22use MediaWiki\PoolCounter\PoolCounterWorkViaCallback;
23use MediaWiki\Status\Status;
24use Psr\Log\LoggerAwareInterface;
25use Psr\Log\LoggerInterface;
26use Wikimedia\RequestTimeout\RequestTimeoutException;
27
28/**
29 * WikiLambda Base API util
30 *
31 * This abstract class extends the Wikimedia's ApiBase class
32 * and provides specific additional methods.
33 *
34 * @stable to extend
35 *
36 * @ingroup API
37 * @copyright 2020– Abstract Wikipedia team; see AUTHORS.txt
38 * @license MIT
39 */
40abstract class WikiLambdaApiBase extends ApiBase implements LoggerAwareInterface {
41
42    protected OrchestratorRequest $orchestrator;
43    protected string $orchestratorHost;
44    protected LoggerInterface $logger;
45
46    protected function setUp() {
47        $this->setLogger( LoggerFactory::getInstance( 'WikiLambda' ) );
48
49        // TODO (T330033): Consider injecting this service rather than just fetching from main
50        $services = MediaWikiServices::getInstance();
51
52        $config = $services->getConfigFactory()->makeConfig( 'WikiLambda' );
53        $this->orchestratorHost = $config->get( 'WikiLambdaOrchestratorLocation' );
54        $client = new Client( [ "base_uri" => $this->orchestratorHost ] );
55        $this->orchestrator = new OrchestratorRequest( $client );
56    }
57
58    /**
59     * @param ZError $zerror The ZError object to return to the user
60     * @param int $code HTTP error code, defaulting to 400/Bad Request
61     */
62    public function dieWithZError( $zerror, $code = 400 ) {
63        try {
64            $errorData = $zerror->getErrorData();
65        } catch ( ZErrorException $e ) {
66            // Generating the human-readable error data itself threw. Oh dear.
67            $this->getLogger()->warning(
68                __METHOD__ . ' called but an error was thrown when trying to report an error',
69                [
70                    'zerror' => $zerror->getSerialized(),
71                    'error' => $e,
72                ]
73            );
74
75            $errorData = [
76                'zerror' => $zerror->getSerialized()
77            ];
78        }
79
80        parent::dieWithError(
81            [ 'wikilambda-zerror', $zerror->getZErrorType() ],
82            null,
83            $errorData,
84            $code
85        );
86    }
87
88    /**
89     * @param string $errorMessage
90     * @param string $zFunctionCallString
91     * @return ZResponseEnvelope
92     */
93    private function returnWithZError( $errorMessage, $zFunctionCallString ): ZResponseEnvelope {
94        $zErrorObject = ZErrorFactory::wrapMessageInZError(
95            $errorMessage,
96            $zFunctionCallString
97        );
98        $zResponseMap = ZResponseEnvelope::wrapErrorInResponseMap( $zErrorObject );
99        return new ZResponseEnvelope( null, $zResponseMap );
100    }
101
102    /**
103     * @param ZFunctionCall|\stdClass $zObject
104     * @param bool $validate
105     * @return ZResponseEnvelope
106     */
107    protected function executeFunctionCall( $zObject, $validate ) {
108        $zObjectAsStdClass = ( $zObject instanceof ZFunctionCall ) ? $zObject->getSerialized() : $zObject;
109        $zObjectAsString = json_encode( $zObjectAsStdClass );
110
111        if ( $zObjectAsStdClass->Z1K1 !== 'Z7' && $zObjectAsStdClass->Z1K1->Z9K1 !== 'Z7' ) {
112            $this->dieWithError( [ "apierror-wikilambda_function_call-not-a-function" ], null, null, 400 );
113        }
114
115        $this->getLogger()->debug(
116            __METHOD__ . ' called',
117            [
118                'zObject' => $zObjectAsString,
119                'validate' => $validate,
120            ]
121        );
122
123        // Unlike the Special pages, we don't have a helpful userCanExecute() method
124        if ( !$this->getContext()->getAuthority()->isAllowed( 'wikilambda-execute' ) ) {
125            $zError = ZErrorFactory::createZErrorInstance( ZErrorTypeRegistry::Z_ERROR_USER_CANNOT_RUN, [] );
126            $this->dieWithZError( $zError, 403 );
127        }
128
129        $queryArguments = [
130            'zobject' => $zObjectAsStdClass,
131            'doValidate' => $validate
132        ];
133        try {
134            $work = new PoolCounterWorkViaCallback(
135                'WikiLambdaFunctionCall',
136                $this->getUser()->getName(),
137                [
138                    'doWork' => function () use ( $queryArguments ) {
139                        return $this->orchestrator->orchestrate( $queryArguments );
140                    },
141                    'error' => function ( Status $status ) {
142                        $this->dieWithError(
143                            [ "apierror-wikilambda_function_call-concurrency-limit" ],
144                            null, null, 429
145                        );
146                    }
147                ]
148            );
149            $response = $work->execute();
150
151            $this->getLogger()->debug(
152                __METHOD__ . ' executed successfully',
153                [
154                    'zObject' => $zObjectAsString,
155                    'validate' => $validate,
156                    'response' => $response,
157                ]
158            );
159
160            $responseContents = FormatJson::decode( $response );
161
162            try {
163                $responseObject = ZObjectFactory::create( $responseContents );
164            } catch ( ZErrorException $e ) {
165                $this->dieWithError(
166                    [
167                        'apierror-wikilambda_function_call-response-malformed',
168                        $e->getZErrorMessage()
169                    ],
170                    null, null, 500
171                );
172            }
173            '@phan-var \MediaWiki\Extension\WikiLambda\ZObjects\ZResponseEnvelope $responseObject';
174            return $responseObject;
175        } catch ( ConnectException $exception ) {
176            $this->dieWithError(
177                [ "apierror-wikilambda_function_call-not-connected", $this->orchestratorHost ],
178                null, null, 503
179            );
180        } catch ( ClientException | ServerException $exception ) {
181            if ( $exception->getResponse()->getStatusCode() === 404 ) {
182                $this->dieWithError(
183                    [ "apierror-wikilambda_function_call-not-connected", $this->orchestratorHost ],
184                    null, null, 503
185                );
186            }
187
188            $this->getLogger()->warning(
189                __METHOD__ . ' failed to execute with a ClientException/ServerException: {exception}',
190                [
191                    'zObject' => $zObjectAsString,
192                    'validate' => $validate,
193                    'exception' => $exception,
194                ]
195            );
196
197            return $this->returnWithZError(
198                $exception->getResponse()->getReasonPhrase(),
199                $zObjectAsString
200            );
201        } catch ( RequestTimeoutException $exception ) {
202            $this->getLogger()->warning(
203                __METHOD__ . ' failed to execute with a RequestTimeoutException: {exception}',
204                [
205                    'zObject' => $zObjectAsString,
206                    'validate' => $validate,
207                    'exception' => $exception,
208                ]
209            );
210
211            return $this->returnWithZError(
212                $exception->getMessage(),
213                $zObjectAsString
214            );
215        } catch ( ApiUsageException $exception ) {
216            // This is almost certainly a user-limit-error, and not worth worrying in the middleware
217            // about, so only log as debug() not warning()
218            $this->getLogger()->debug(
219                __METHOD__ . ' failed to execute with a ApiUsageException: {exception}',
220                [
221                    'zObject' => $zObjectAsString,
222                    'validate' => $validate,
223                    'exception' => $exception,
224                ]
225            );
226
227            return $this->returnWithZError(
228                $exception->getMessage(),
229                $zObjectAsString
230            );
231        } catch ( ZErrorException $exception ) {
232            // This is almost certainly a user-error, and not worth worrying in the middleware
233            // about, so only log as debug() not warning()
234            $this->getLogger()->debug(
235                __METHOD__ . ' failed to execute with a ZErrorException: {exception}',
236                [
237                    'zObject' => $zObjectAsString,
238                    'validate' => $validate,
239                    'exception' => $exception,
240                ]
241            );
242
243            return $this->returnWithZError(
244                $exception->getZErrorMessage(),
245                $zObjectAsString
246            );
247        } catch ( \Exception $exception ) {
248
249            $this->getLogger()->warning(
250                __METHOD__ . ' failed to execute with a general Exception: {exception}',
251                [
252                    'zObject' => $zObjectAsString,
253                    'validate' => $validate,
254                    'exception' => $exception,
255                ]
256            );
257
258            return $this->returnWithZError(
259                $exception->getMessage(),
260                $zObjectAsString
261            );
262        }
263    }
264
265    /** @inheritDoc */
266    public function setLogger( LoggerInterface $logger ) {
267        $this->logger = $logger;
268    }
269
270    /** @inheritDoc */
271    public function getLogger(): LoggerInterface {
272        return $this->logger;
273    }
274}