Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
35.29% covered (danger)
35.29%
36 / 102
33.33% covered (danger)
33.33%
2 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiFunctionCall
35.29% covered (danger)
35.29%
36 / 102
33.33% covered (danger)
33.33%
2 / 6
256.84
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
 execute
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 executeGenerator
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 run
63.27% covered (warning)
63.27%
31 / 49
0.00% covered (danger)
0.00%
0 / 1
19.14
 getAllowedParams
n/a
0 / 0
n/a
0 / 0
1
 readTestFileAsArray
n/a
0 / 0
n/a
0 / 0
1
 createUserDefinedValidationExample
n/a
0 / 0
n/a
0 / 0
1
 createCurryExample
n/a
0 / 0
n/a
0 / 0
1
 createExample
n/a
0 / 0
n/a
0 / 0
1
 getExamplesMessages
n/a
0 / 0
n/a
0 / 0
1
 makeRequest
0.00% covered (danger)
0.00%
0 / 46
0.00% covered (danger)
0.00%
0 / 1
56
 isInternal
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * WikiLambda function call 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\ActionAPI;
12
13use ApiMain;
14use ApiPageSet;
15use ApiUsageException;
16use GuzzleHttp\Exception\ClientException;
17use GuzzleHttp\Exception\ConnectException;
18use GuzzleHttp\Exception\ServerException;
19use MediaWiki\Context\DerivativeContext;
20use MediaWiki\Context\RequestContext;
21use MediaWiki\Extension\WikiLambda\Registry\ZErrorTypeRegistry;
22use MediaWiki\Extension\WikiLambda\ZErrorException;
23use MediaWiki\Extension\WikiLambda\ZErrorFactory;
24use MediaWiki\Extension\WikiLambda\ZObjectFactory;
25use MediaWiki\Extension\WikiLambda\ZObjects\ZError;
26use MediaWiki\Extension\WikiLambda\ZObjects\ZQuote;
27use MediaWiki\Extension\WikiLambda\ZObjects\ZResponseEnvelope;
28use MediaWiki\Extension\WikiLambda\ZObjectUtils;
29use MediaWiki\PoolCounter\PoolCounterWorkViaCallback;
30use MediaWiki\Request\FauxRequest;
31use MediaWiki\Status\Status;
32use Wikimedia\ParamValidator\ParamValidator;
33
34class ApiFunctionCall extends WikiLambdaApiBase {
35
36    /**
37     * @inheritDoc
38     */
39    public function __construct( $query, $moduleName ) {
40        parent::__construct( $query, $moduleName, 'wikilambda_function_call_' );
41
42        $this->setUp();
43    }
44
45    /**
46     * @inheritDoc
47     */
48    public function execute() {
49        // (T362271) Emit appropriate cache headers for a 24 hour TTL
50        // NOTE (T362273): MediaWiki out-guesses us and assumes we don't know what we're doing; to fix so it works
51        $this->getMain()->setCacheMode( 'public' );
52        $this->getMain()->setCacheMaxAge( 60 * 60 * 24 );
53
54        $this->run();
55    }
56
57    /**
58     * @inheritDoc
59     */
60    public function executeGenerator( $resultPageSet ) {
61        $this->run( $resultPageSet );
62    }
63
64    /**
65     * TODO (T338251): Use WikiLambdaApiBase::executeFunctionCall() rather than rolling our own.
66     *
67     * @param ApiPageSet|null $resultPageSet
68     */
69    private function run( $resultPageSet = null ) {
70        // Unlike the Special pages, we don't have a helpful userCanExecute() method
71        $userAuthority = $this->getContext()->getAuthority();
72        if ( !$userAuthority->isAllowed( 'wikilambda-execute' ) ) {
73            $zError = ZErrorFactory::createZErrorInstance( ZErrorTypeRegistry::Z_ERROR_USER_CANNOT_RUN, [] );
74            $this->dieWithZError( $zError, 403 );
75        }
76
77        $params = $this->extractRequestParams();
78        $pageResult = $this->getResult();
79        $stringOfAZ = $params[ 'zobject' ];
80        $zObjectAsStdClass = json_decode( $stringOfAZ );
81        $jsonQuery = [
82            'zobject' => $zObjectAsStdClass,
83            'doValidate' => true
84        ];
85
86        // Arbitrary implementation calls need more than wikilambda-execute;
87        // require wikilambda-execute-unsaved-code, so that it can be independently
88        // activated/deactivated (to run an arbitrary implementation, you have to
89        // pass a custom function with the raw implementation rather than a ZID string.)
90        $isUnsavedCode = false;
91        if (
92            property_exists( $zObjectAsStdClass, 'Z7K1' ) &&
93            is_object( $zObjectAsStdClass->Z7K1 ) &&
94            property_exists( $zObjectAsStdClass->Z7K1, 'Z8K4' ) &&
95            count( $zObjectAsStdClass->Z7K1->Z8K4 ) > 1
96        ) {
97            $implementation = $zObjectAsStdClass->Z7K1->Z8K4[ 1 ];
98            if ( is_object( $implementation ) && property_exists( $implementation, 'Z14K1' ) ) {
99                $isUnsavedCode = true;
100            }
101        }
102        if ( $isUnsavedCode && !$userAuthority->isAllowed( 'wikilambda-execute-unsaved-code' ) ) {
103            $zError = ZErrorFactory::createZErrorInstance( ZErrorTypeRegistry::Z_ERROR_USER_CANNOT_RUN, [] );
104            $this->dieWithZError( $zError, 403 );
105        }
106
107        $work = new PoolCounterWorkViaCallback( 'WikiLambdaFunctionCall', $this->getUser()->getName(), [
108            'doWork' => function () use ( $jsonQuery ) {
109                return $this->orchestrator->orchestrate( $jsonQuery );
110            },
111            'error' => function ( Status $status ) {
112                $this->dieWithError( [ "apierror-wikilambda_function_call-concurrency-limit" ], null, null, 429 );
113            }
114        ] );
115
116        $result = [ 'success' => false ];
117        try {
118            $response = $work->execute();
119            $result['data'] = $response;
120            $result['success'] = true;
121        } catch ( ConnectException $exception ) {
122            $this->dieWithError(
123                [ "apierror-wikilambda_function_call-not-connected", $this->orchestratorHost ],
124                null, null, 503
125            );
126        } catch ( ClientException | ServerException $exception ) {
127            $zError = ZErrorFactory::wrapMessageInZError(
128                $exception->getResponse()->getReasonPhrase(),
129                $zObjectAsStdClass
130            );
131            $zResponseMap = ZResponseEnvelope::wrapErrorInResponseMap( $zError );
132            $zResponseObject = new ZResponseEnvelope( null, $zResponseMap );
133            $result['data'] = $zResponseObject->getSerialized();
134        }
135        $pageResult->addValue( [ 'query' ], $this->getModuleName(), $result );
136    }
137
138    /**
139     * @inheritDoc
140     * @codeCoverageIgnore
141     */
142    protected function getAllowedParams(): array {
143        return [
144            'zobject' => [
145                ParamValidator::PARAM_TYPE => 'text',
146                ParamValidator::PARAM_REQUIRED => true,
147            ]
148        ];
149    }
150
151    /**
152     * Reads file contents from test data directory as JSON array.
153     * @param string $fileName
154     * @return array file contents (JSON-decoded)
155     * @codeCoverageIgnore
156     */
157    private function readTestFileAsArray( $fileName ): array {
158        return json_decode( ZObjectUtils::readTestFile( $fileName ), true );
159    }
160
161    /**
162     * Generates URL-encoded example function call exercising user-defined validation function.
163     * This function call produces a validation error. Replace
164     * Z1000000K1: 'a' with Z1000000K1: 'A' in order to see successful validation.
165     * @return string URL-encoded Function Call
166     * @codeCoverageIgnore
167     */
168    private function createUserDefinedValidationExample(): string {
169        $ZMillionOuter = $this->readTestFileAsArray( 'user-defined-validation-type.json' );
170        $ZMillionInner = $this->readTestFileAsArray( 'user-defined-validation-type.json' );
171        $validationZ7 = $this->readTestFileAsArray( 'example-user-defined-validation.json' );
172        $ZMillionOuter["Z4K3"]["Z8K1"][1]["Z17K1"] = $ZMillionInner;
173        $validationZ7["Z801K1"]["Z1K1"] = $ZMillionOuter;
174        return urlencode( json_encode( $validationZ7 ) );
175    }
176
177    /**
178     * Generates URL-encoded example function call exercising curry function.
179     * @return string URL-encoded Function Call
180     * @codeCoverageIgnore
181     */
182    private function createCurryExample(): string {
183        $curryImplementation = $this->readTestFileAsArray( 'curry-implementation-Z409.json' );
184        $curryFunction = $this->readTestFileAsArray( 'curry-Z408.json' );
185        $curryFunction["Z8K4"][1] = $curryImplementation;
186        $curryFunctionCall = $this->readTestFileAsArray( 'curry-call-Z410.json' );
187        $curryFunctionCall["Z8K4"][1]["Z14K2"]["Z7K1"]["Z7K1"] = $curryFunction;
188        $andFunction = $this->readTestFileAsArray( 'and-Z407.json' );
189        $curry = [
190            "Z1K1" => "Z7",
191            "Z7K1" => $curryFunctionCall,
192            "Z410K1" => $andFunction,
193            "Z410K2" => [
194                "Z1K1" => "Z40",
195                "Z40K1" => "Z41"
196            ],
197            "Z410K3" => [
198                "Z1K1" => "Z40",
199                "Z40K1" => "Z41"
200            ]
201        ];
202        return urlencode( json_encode( $curry ) );
203    }
204
205    /**
206     * Generates URL-encoded example function call from JSON file contents.
207     * @param string $fileName
208     * @return string URL-encoded contents
209     * @codeCoverageIgnore
210     */
211    private function createExample( $fileName ): string {
212        return urlencode( ZObjectUtils::readTestFile( $fileName ) );
213    }
214
215    /**
216     * @see ApiBase::getExamplesMessages()
217     * @return array
218     * @codeCoverageIgnore
219     */
220    protected function getExamplesMessages() {
221        return [
222            'action=wikilambda_function_call&wikilambda_function_call_zobject='
223                . $this->createExample( 'Z902_false.json' )
224                => 'apihelp-wikilambda_function_call-example-if',
225            'action=wikilambda_function_call&wikilambda_function_call_zobject='
226                . $this->createExample( 'evaluated-js.json' )
227                => 'apihelp-wikilambda_function_call-example-native-js-code',
228            'action=wikilambda_function_call&wikilambda_function_call_zobject='
229                . $this->createExample( 'evaluated-python.json' )
230                => 'apihelp-wikilambda_function_call-example-native-python-code',
231            'action=wikilambda_function_call&wikilambda_function_call_zobject='
232                . $this->createExample( 'example-composition.json' )
233                => 'apihelp-wikilambda_function_call-example-composition',
234            'action=wikilambda_function_call&wikilambda_function_call_zobject='
235                . $this->createExample( 'example-notempty.json' )
236                => 'apihelp-wikilambda_function_call-example-notempty',
237            'action=wikilambda_function_call&wikilambda_function_call_zobject='
238                . $this->createExample( 'example-map.json' )
239                => 'apihelp-wikilambda_function_call-example-map',
240            'action=wikilambda_function_call&wikilambda_function_call_zobject='
241                . $this->createExample( 'example-apply.json' )
242                => 'apihelp-wikilambda_function_call-example-apply',
243            'action=wikilambda_function_call&wikilambda_function_call_zobject='
244                . $this->createExample( 'example-generic-list.json' )
245                => 'apihelp-wikilambda_function_call-example-generic-list',
246            'action=wikilambda_function_call&wikilambda_function_call_zobject='
247                . $this->createExample( 'example-generic-pair.json' )
248                => 'apihelp-wikilambda_function_call-example-generic-pair',
249            'action=wikilambda_function_call&wikilambda_function_call_zobject='
250                . $this->createExample( 'example-generic-map.json' )
251                => 'apihelp-wikilambda_function_call-example-generic-map',
252            'action=wikilambda_function_call&wikilambda_function_call_zobject='
253                . $this->createExample( 'example-user-defined-python.json' )
254                => 'apihelp-wikilambda_function_call-example-user-defined-python',
255            'action=wikilambda_function_call&wikilambda_function_call_zobject='
256                . $this->createExample( 'example-user-defined-javascript.json' )
257                => 'apihelp-wikilambda_function_call-example-user-defined-javascript',
258            'action=wikilambda_function_call&wikilambda_function_call_zobject='
259                . $this->createUserDefinedValidationExample()
260                => 'apihelp-wikilambda_function_call-example-user-defined-validation',
261            'action=wikilambda_function_call&wikilambda_function_call_zobject='
262                . $this->createExample( 'example-user-defined-generic-type.json' )
263                => 'apihelp-wikilambda_function_call-example-user-defined-generic-type',
264            'action=wikilambda_function_call&wikilambda_function_call_zobject='
265                . $this->createCurryExample()
266                => 'apihelp-wikilambda_function_call-example-curry',
267            'action=wikilambda_function_call&wikilambda_function_call_zobject='
268                . $this->createExample( 'example-socket.json' )
269                => 'apihelp-wikilambda_function_call-example-socket',
270            'action=wikilambda_function_call&wikilambda_function_call_zobject='
271                . $this->createExample( 'example-timeout.json' )
272                => 'apihelp-wikilambda_function_call-example-timeout',
273            'action=wikilambda_function_call&wikilambda_function_call_zobject='
274                . $this->createExample( 'example-orchestrator-timeout.json' )
275                => 'apihelp-wikilambda_function_call-example-orchestrator-timeout',
276        ];
277    }
278
279    /**
280     * A convenience function for making a ZFunctionCall and returning its result to embed within a page.
281     *
282     * @param string $call The ZFunctionCall to make, as a JSON object turned into a string
283     * @return string Currently the only permissable response objects are strings
284     * @throws ZErrorException When the request is responded to oddly by the orchestrator
285     */
286    public static function makeRequest( $call ): string {
287        $api = new ApiMain( new FauxRequest() );
288        $request = new FauxRequest(
289            [
290                'format' => 'json',
291                'action' => 'wikilambda_function_call',
292                'wikilambda_function_call_zobject' => $call,
293            ],
294            /* wasPosted */ true
295        );
296
297        $context = new DerivativeContext( RequestContext::getMain() );
298        $context->setRequest( $request );
299        $api->setContext( $context );
300        $api->execute();
301        $outerResponse = $api->getResult()->getResultData( [], [ 'Strip' => 'all' ] );
302
303        if ( isset( $outerResponse[ 'error' ] ) ) {
304            try {
305                $zerror = ZObjectFactory::create( $outerResponse['error'] );
306            } catch ( ZErrorException $e ) {
307                // Can't use $this->dieWithError() as we're static, so use the call indirectly
308                throw ApiUsageException::newWithMessage(
309                    null,
310                    [
311                        'apierror-wikilambda_function_call-response-malformed',
312                        // TODO (T362236): Pass the rendering language in, don't default to English
313                        $e->getZError()->getMessage()
314                    ],
315                    null,
316                    null,
317                    400
318                );
319            }
320            if ( !( $zerror instanceof ZError ) ) {
321                $zerror = ZErrorFactory::wrapMessageInZError( new ZQuote( $zerror ), $call );
322            }
323            throw new ZErrorException( $zerror );
324        }
325
326        // Now we know that the request has not failed before it even got to the orchestrator, get the response
327        // JSON string as a ZResponseEnvelope (falling back to an empty string in case it's unset).
328        $response = ZObjectFactory::create(
329            $outerResponse['query']['wikilambda_function_call']['data'] ?? ''
330        );
331
332        if ( !( $response instanceof ZResponseEnvelope ) ) {
333            // The server's not given us a result!
334            $responseType = $response->getZType();
335            $zerror = ZErrorFactory::wrapMessageInZError(
336                "Server returned a non-result of type '$responseType'!",
337                $call
338            );
339            throw new ZErrorException( $zerror );
340        }
341
342        if ( $response->hasErrors() ) {
343            // If the server has responsed with a Z5/Error, show that properly.
344            $zerror = $response->getErrors();
345            if ( !( $zerror instanceof ZError ) ) {
346                $zerror = ZErrorFactory::wrapMessageInZError( new ZQuote( $zerror ), $call );
347            }
348            throw new ZErrorException( $zerror );
349        }
350
351        return trim( $response->getZValue() );
352    }
353
354    /**
355     * Mark as internal. This isn't meant to be user-facing, and can change at any time.
356     * @return bool
357     */
358    public function isInternal() {
359        return true;
360    }
361}