Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
66.67% covered (warning)
66.67%
56 / 84
33.33% covered (danger)
33.33%
1 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiFunctionCall
66.67% covered (warning)
66.67%
56 / 84
33.33% covered (danger)
33.33%
1 / 3
32.37
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 run
63.51% covered (warning)
63.51%
47 / 74
0.00% covered (danger)
0.00%
0 / 1
16.88
 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
 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 JsonException;
14use MediaWiki\Api\ApiMain;
15use MediaWiki\Api\ApiUsageException;
16use MediaWiki\Extension\TestKitchen\Sdk\InstrumentManagerInterface;
17use MediaWiki\Extension\WikiLambda\HttpStatus;
18use MediaWiki\Extension\WikiLambda\OrchestratorRequest;
19use MediaWiki\Extension\WikiLambda\Registry\ZErrorTypeRegistry;
20use MediaWiki\Extension\WikiLambda\ZErrorFactory;
21use MediaWiki\Extension\WikiLambda\ZObjectUtils;
22use MediaWiki\Permissions\RateLimiter;
23use Wikimedia\ParamValidator\ParamValidator;
24use Wikimedia\Stats\StatsFactory;
25use Wikimedia\Telemetry\SpanInterface;
26use Wikimedia\Telemetry\TracerInterface;
27
28class ApiFunctionCall extends WikiLambdaApiBase {
29
30    public function __construct(
31        ApiMain $mainModule,
32        string $moduleName,
33        StatsFactory $statsFactory,
34        private readonly TracerInterface $tracer,
35        OrchestratorRequest $orchestrator,
36        RateLimiter $rateLimiter,
37        ?InstrumentManagerInterface $instrumentManager = null,
38    ) {
39        parent::__construct(
40            $mainModule,
41            $moduleName,
42            $statsFactory,
43            'wikilambda_function_call_',
44            $rateLimiter,
45            $instrumentManager
46        );
47
48        $this->setUp( $orchestrator );
49    }
50
51    /**
52     * @inheritDoc
53     */
54    protected function run() {
55        $start = microtime( true );
56
57        // Get input parameters
58        $params = $this->extractRequestParams();
59        $zObjectString = $params[ 'zobject' ];
60
61        // (T432869) Get origin from header, which can be
62        // * aw-fragment: set by AbstractWikiRequest before requesting an AW fragment render
63        // * wp-fragment: set by WikifunctionsClientRequestJob, and propagated through the
64        //   RESTAPI FunctionCallHandler, before requesting the execution of an embedded
65        //   function call with the parser function {{#function:Z*}}
66        $origin = $this->getRequest()->getHeader( 'X-WikiLambda-Request-Origin' );
67        $origin = is_string( $origin ) ? $origin : '';
68
69        // Initialize output
70        $pageResult = $this->getResult();
71
72        // Initialize span
73        $span = $this->tracer->createSpan( 'WikiLambda ApiFunctionCall' )
74            ->setSpanKind( SpanInterface::SPAN_KIND_CLIENT )
75            ->start();
76        $span->activate();
77
78        // 1. JSON decode input zobject and die with ZError if invalid syntax
79        try {
80            $zObjectAsStdClass = json_decode( $zObjectString, false, 512, JSON_THROW_ON_ERROR );
81        } catch ( JsonException $e ) {
82            // (T389702) If the JSON is invalid, we return a 400 error rather than have PHP die
83            $this->submitFunctionCallEvent( HttpStatus::BAD_REQUEST, null, $start );
84            $zError = ZErrorFactory::createZErrorInstance( ZErrorTypeRegistry::Z_ERROR_INVALID_SYNTAX, [
85                'message' => $e->getMessage(),
86                'input' => $zObjectString
87            ] );
88            $span
89                ->setSpanStatus( SpanInterface::SPAN_STATUS_ERROR )
90                ->setAttributes( [ 'error.message' => $e->getMessage() ] )
91                ->end();
92            WikiLambdaApiBase::dieWithZError( $zError, HttpStatus::BAD_REQUEST );
93        }
94
95        // Cautionary canonicalization
96        $zObjectAsStdClass = ZObjectUtils::canonicalize( $zObjectAsStdClass );
97
98        // Initialize flags:
99        $flags = [
100            'validate' => true,
101            'isUnsavedCode' => false,
102            // Get bypassCache boolean flag if set to any truthy value, false otherwise
103            'bypassCache' => filter_var( $params[ 'bypass-cache' ], FILTER_VALIDATE_BOOLEAN ),
104            // Get getFreshResult boolean flag if set to any truthy value, false otherwise
105            'getFreshResult' => filter_var( $params[ 'fresh-result' ], FILTER_VALIDATE_BOOLEAN )
106        ];
107
108        // Get function zid for logging
109        $function = ZObjectUtils::getFunctionZidOrNull( $zObjectAsStdClass );
110        if ( !ZObjectUtils::isValidZObjectReference( (string)$function ) ) {
111            $function = 'No valid ZID';
112            $this->getLogger()->info(
113                __METHOD__ . ' unable to find a ZID for the function called',
114                [
115                    'zobject' => $zObjectString
116                ]
117            );
118        }
119
120        // Arbitrary implementation calls need more than wikilambda-execute;
121        // require wikilambda-execute-unsaved-code, so that it can be independently
122        // activated/deactivated (to run an arbitrary implementation, you have to
123        // pass a custom function with the raw implementation rather than a ZID string.)
124        if ( $this->hasUnsavedCode( $zObjectAsStdClass ) ) {
125            $flags[ 'isUnsavedCode' ] = true;
126        }
127
128        try {
129            $response = $this->executeFunctionCall( $zObjectAsStdClass, $flags, $origin );
130
131            $result = [
132                'success' => true,
133                'data' => $response['result'],
134                'orchestratorHttpStatusCode' => $response['httpStatusCode']
135            ];
136
137            // Get the Http status code returned by the orchestrator, and:
138            // * For 2xx or 4xx, log it as a successful request
139            // * For 5xx (server errors), log it as a failed request
140            $httpStatusCode = $response['httpStatusCode'];
141            if ( $httpStatusCode < HttpStatus::INTERNAL_SERVER_ERROR ) {
142                $span->setSpanStatus( SpanInterface::SPAN_STATUS_OK );
143            } else {
144                $result['success'] = false;
145                $span
146                    ->setSpanStatus( SpanInterface::SPAN_STATUS_ERROR )
147                    ->setAttributes( [
148                        'response.status_code' => $httpStatusCode,
149                        'exception.message' => $response['result']
150                    ] );
151            }
152
153            $pageResult->addValue( [], $this->getModuleName(), $result );
154            $this->submitFunctionCallEvent( $httpStatusCode, $function, $start );
155
156        } catch ( ApiUsageException $e ) {
157            // Whenever executeFunctionCall dies with error, we intercept it so that:
158            // * we submit a function call metrics event,
159            // * we rethrow the error
160            // * finally we end the span
161            $errorCode = $e->getCode();
162            $httpStatusCode = ( is_int( $errorCode ) && $errorCode >= 100 && $errorCode < 600 ) ?
163                $errorCode : HttpStatus::BAD_REQUEST;
164            $this->submitFunctionCallEvent( $httpStatusCode, $function, $start );
165            throw $e;
166
167        } catch ( \Throwable $e ) {
168            // Whatever we catch here is an unexpected system error:
169            // * we log accordingly as error
170            // * we rethrow the error
171            // * finally we end the span
172            $this->getLogger()->error(
173                __METHOD__ . ' caused an unexpected system failure',
174                [
175                    'zobject' => $zObjectString,
176                    'exception' => $e
177                ]
178            );
179            throw $e;
180
181        } finally {
182            // End the span
183            $span->end();
184        }
185    }
186
187    /**
188     * @inheritDoc
189     * @codeCoverageIgnore
190     */
191    protected function getAllowedParams(): array {
192        return [
193            'zobject' => [
194                ParamValidator::PARAM_TYPE => 'text',
195                ParamValidator::PARAM_REQUIRED => true,
196            ],
197            'bypass-cache' => [
198                ParamValidator::PARAM_TYPE => 'boolean',
199                ParamValidator::PARAM_REQUIRED => false,
200                ParamValidator::PARAM_DEFAULT => false
201            ],
202            'fresh-result' => [
203                ParamValidator::PARAM_TYPE => 'boolean',
204                ParamValidator::PARAM_REQUIRED => false,
205                ParamValidator::PARAM_DEFAULT => false
206            ]
207        ];
208    }
209
210    /**
211     * Reads file contents from test data directory as JSON array.
212     * @param string $fileName
213     * @return array file contents (JSON-decoded)
214     * @codeCoverageIgnore
215     */
216    private function readTestFileAsArray( $fileName ): array {
217        return json_decode( ZObjectUtils::readTestFile( $fileName ), true );
218    }
219
220    /**
221     * Generates URL-encoded example function call exercising user-defined validation function.
222     * This function call produces a validation error. Replace
223     * Z1000000K1: 'a' with Z1000000K1: 'A' in order to see successful validation.
224     * @return string URL-encoded Function Call
225     * @codeCoverageIgnore
226     */
227    private function createUserDefinedValidationExample(): string {
228        $ZMillionOuter = $this->readTestFileAsArray( 'user-defined-validation-type.json' );
229        $ZMillionInner = $this->readTestFileAsArray( 'user-defined-validation-type.json' );
230        $validationZ7 = $this->readTestFileAsArray( 'example-user-defined-validation.json' );
231        $ZMillionOuter["Z4K3"]["Z8K1"][1]["Z17K1"] = $ZMillionInner;
232        $validationZ7["Z801K1"]["Z1K1"] = $ZMillionOuter;
233        return urlencode( json_encode( $validationZ7 ) );
234    }
235
236    /**
237     * Generates URL-encoded example function call exercising curry function.
238     * @return string URL-encoded Function Call
239     * @codeCoverageIgnore
240     */
241    private function createCurryExample(): string {
242        $curryImplementation = $this->readTestFileAsArray( 'curry-implementation-Z409.json' );
243        $curryFunction = $this->readTestFileAsArray( 'curry-Z408.json' );
244        $curryFunction["Z8K4"][1] = $curryImplementation;
245        $curryFunctionCall = $this->readTestFileAsArray( 'curry-call-Z410.json' );
246        $curryFunctionCall["Z8K4"][1]["Z14K2"]["Z7K1"]["Z7K1"] = $curryFunction;
247        $andFunction = $this->readTestFileAsArray( 'and-Z407.json' );
248        $curry = [
249            "Z1K1" => "Z7",
250            "Z7K1" => $curryFunctionCall,
251            "Z410K1" => $andFunction,
252            "Z410K2" => [
253                "Z1K1" => "Z40",
254                "Z40K1" => "Z41"
255            ],
256            "Z410K3" => [
257                "Z1K1" => "Z40",
258                "Z40K1" => "Z41"
259            ]
260        ];
261        return urlencode( json_encode( $curry ) );
262    }
263
264    /**
265     * Generates URL-encoded example function call from JSON file contents.
266     * @param string $fileName
267     * @return string URL-encoded contents
268     * @codeCoverageIgnore
269     */
270    private function createExample( $fileName ): string {
271        return urlencode( ZObjectUtils::readTestFile( $fileName ) );
272    }
273
274    /**
275     * @inheritDoc
276     * @codeCoverageIgnore
277     */
278    protected function getExamplesMessages() {
279        return [
280            'action=wikilambda_function_call&wikilambda_function_call_zobject='
281                . $this->createExample( 'Z902_false.json' )
282                => 'apihelp-wikilambda_function_call-example-if',
283            'action=wikilambda_function_call&wikilambda_function_call_zobject='
284                . $this->createExample( 'evaluated-js.json' )
285                => 'apihelp-wikilambda_function_call-example-native-js-code',
286            'action=wikilambda_function_call&wikilambda_function_call_zobject='
287                . $this->createExample( 'evaluated-python.json' )
288                => 'apihelp-wikilambda_function_call-example-native-python-code',
289            'action=wikilambda_function_call&wikilambda_function_call_zobject='
290                . $this->createExample( 'example-composition.json' )
291                => 'apihelp-wikilambda_function_call-example-composition',
292            'action=wikilambda_function_call&wikilambda_function_call_zobject='
293                . $this->createExample( 'example-notempty.json' )
294                => 'apihelp-wikilambda_function_call-example-notempty',
295            'action=wikilambda_function_call&wikilambda_function_call_zobject='
296                . $this->createExample( 'example-map.json' )
297                => 'apihelp-wikilambda_function_call-example-map',
298            'action=wikilambda_function_call&wikilambda_function_call_zobject='
299                . $this->createExample( 'example-apply.json' )
300                => 'apihelp-wikilambda_function_call-example-apply',
301            'action=wikilambda_function_call&wikilambda_function_call_zobject='
302                . $this->createExample( 'example-generic-list.json' )
303                => 'apihelp-wikilambda_function_call-example-generic-list',
304            'action=wikilambda_function_call&wikilambda_function_call_zobject='
305                . $this->createExample( 'example-generic-pair.json' )
306                => 'apihelp-wikilambda_function_call-example-generic-pair',
307            'action=wikilambda_function_call&wikilambda_function_call_zobject='
308                . $this->createExample( 'example-generic-map.json' )
309                => 'apihelp-wikilambda_function_call-example-generic-map',
310            'action=wikilambda_function_call&wikilambda_function_call_zobject='
311                . $this->createExample( 'example-user-defined-python.json' )
312                => 'apihelp-wikilambda_function_call-example-user-defined-python',
313            'action=wikilambda_function_call&wikilambda_function_call_zobject='
314                . $this->createExample( 'example-user-defined-javascript.json' )
315                => 'apihelp-wikilambda_function_call-example-user-defined-javascript',
316            'action=wikilambda_function_call&wikilambda_function_call_zobject='
317                . $this->createUserDefinedValidationExample()
318                => 'apihelp-wikilambda_function_call-example-user-defined-validation',
319            'action=wikilambda_function_call&wikilambda_function_call_zobject='
320                . $this->createExample( 'example-user-defined-generic-type.json' )
321                => 'apihelp-wikilambda_function_call-example-user-defined-generic-type',
322            'action=wikilambda_function_call&wikilambda_function_call_zobject='
323                . $this->createCurryExample()
324                => 'apihelp-wikilambda_function_call-example-curry',
325            'action=wikilambda_function_call&wikilambda_function_call_zobject='
326                . $this->createExample( 'example-socket.json' )
327                => 'apihelp-wikilambda_function_call-example-socket',
328            'action=wikilambda_function_call&wikilambda_function_call_zobject='
329                . $this->createExample( 'example-timeout.json' )
330                => 'apihelp-wikilambda_function_call-example-timeout',
331            'action=wikilambda_function_call&wikilambda_function_call_zobject='
332                . $this->createExample( 'example-orchestrator-timeout.json' )
333                => 'apihelp-wikilambda_function_call-example-orchestrator-timeout',
334        ];
335    }
336
337    /**
338     * Mark as internal. This isn't meant to be user-facing, and can change at any time.
339     * @return bool
340     */
341    public function isInternal() {
342        return true;
343    }
344}