Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
28.42% |
52 / 183 |
|
33.33% |
3 / 9 |
CRAP | |
0.00% |
0 / 1 |
WikiLambdaApiBase | |
28.42% |
52 / 183 |
|
33.33% |
3 / 9 |
235.29 | |
0.00% |
0 / 1 |
setUp | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
execute | |
22.22% |
2 / 9 |
|
0.00% |
0 / 1 |
3.88 | |||
run | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
2 | |||
dieWithZError | |
66.67% |
8 / 12 |
|
0.00% |
0 / 1 |
2.15 | |||
returnWithZError | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
executeFunctionCall | |
26.56% |
34 / 128 |
|
0.00% |
0 / 1 |
79.93 | |||
setLogger | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getLogger | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
submitMetricsEvent | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
6 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\WikiLambda\ActionAPI; |
4 | |
5 | use GuzzleHttp\Client; |
6 | use GuzzleHttp\Exception\ClientException; |
7 | use GuzzleHttp\Exception\ConnectException; |
8 | use GuzzleHttp\Exception\ServerException; |
9 | use MediaWiki\Api\ApiBase; |
10 | use MediaWiki\Api\ApiUsageException; |
11 | use MediaWiki\Extension\EventLogging\EventLogging; |
12 | use MediaWiki\Extension\WikiLambda\OrchestratorRequest; |
13 | use MediaWiki\Extension\WikiLambda\Registry\ZErrorTypeRegistry; |
14 | use MediaWiki\Extension\WikiLambda\ZErrorException; |
15 | use MediaWiki\Extension\WikiLambda\ZErrorFactory; |
16 | use MediaWiki\Extension\WikiLambda\ZObjectFactory; |
17 | use MediaWiki\Extension\WikiLambda\ZObjects\ZError; |
18 | use MediaWiki\Extension\WikiLambda\ZObjects\ZFunctionCall; |
19 | use MediaWiki\Extension\WikiLambda\ZObjects\ZResponseEnvelope; |
20 | use MediaWiki\Json\FormatJson; |
21 | use MediaWiki\Logger\LoggerFactory; |
22 | use MediaWiki\MediaWikiServices; |
23 | use MediaWiki\PoolCounter\PoolCounterWorkViaCallback; |
24 | use MediaWiki\Registration\ExtensionRegistry; |
25 | use MediaWiki\Status\Status; |
26 | use Psr\Log\LoggerAwareInterface; |
27 | use Psr\Log\LoggerInterface; |
28 | use 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 | */ |
42 | abstract 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 | } |