Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.50% covered (success)
92.50%
111 / 120
66.67% covered (warning)
66.67%
4 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiAbstractWikiRunFragment
92.50% covered (success)
92.50%
111 / 120
66.67% covered (warning)
66.67%
4 / 6
21.19
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 execute
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
5
 getLatestFragmentAndRevalidate
100.00% covered (success)
100.00%
40 / 40
100.00% covered (success)
100.00%
1 / 1
7
 buildFragmentFunctionCall
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
1
 getFunctionDefinition
46.67% covered (danger)
46.67%
7 / 15
0.00% covered (danger)
0.00%
0 / 1
4.37
 isInternal
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getAllowedParams
n/a
0 / 0
n/a
0 / 0
1
 buildExampleCallFor
n/a
0 / 0
n/a
0 / 0
1
 getExamplesMessages
n/a
0 / 0
n/a
0 / 0
1
1<?php
2/**
3 * WikiLambda Abstract Wiki run fragment 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 MediaWiki\Api\ApiBase;
14use MediaWiki\Api\ApiMain;
15use MediaWiki\Extension\WikiLambda\AbstractContent\AbstractContentUtils;
16use MediaWiki\Extension\WikiLambda\AbstractContent\AbstractWikiRequest;
17use MediaWiki\Extension\WikiLambda\Cache\MemcachedWrapper;
18use MediaWiki\Extension\WikiLambda\HttpStatus;
19use MediaWiki\Extension\WikiLambda\Jobs\CacheAbstractContentFragmentJob;
20use MediaWiki\Extension\WikiLambda\Registry\ZTypeRegistry;
21use MediaWiki\Extension\WikiLambda\WikifunctionCallException;
22use MediaWiki\Extension\WikiLambda\WikiLambdaServices;
23use MediaWiki\Extension\WikiLambda\ZObjectUtils;
24use MediaWiki\JobQueue\JobQueueGroup;
25use MediaWiki\Logger\LoggerFactory;
26use Psr\Log\LoggerInterface;
27use Wikimedia\ParamValidator\ParamValidator;
28
29class ApiAbstractWikiRunFragment extends ApiBase {
30
31    public const ABSTRACT_FRAGMENT_CACHE_KEY_PREFIX = 'WikiLambdaAbstractFragment';
32
33    private AbstractWikiRequest $abstractWikiRequest;
34    private MemcachedWrapper $objectCache;
35    private LoggerInterface $logger;
36
37    public function __construct(
38        ApiMain $mainModule,
39        string $moduleName,
40        private readonly JobQueueGroup $jobQueueGroup,
41        AbstractWikiRequest $abstractWikiRequest
42    ) {
43        parent::__construct( $mainModule, $moduleName, 'abstractwiki_run_fragment_' );
44
45        $this->abstractWikiRequest = $abstractWikiRequest;
46
47        $this->objectCache = WikiLambdaServices::getMemcachedWrapper();
48        $this->logger = LoggerFactory::getInstance( 'WikiLambdaAbstract' );
49    }
50
51    /**
52     * @see ApiBase::execute()
53     * @inheritDoc
54     */
55    public function execute() {
56        // Abstract Wiki not enabled: exit with HTTP 501
57        if ( !$this->getConfig()->get( 'WikiLambdaEnableAbstractMode' ) ) {
58            $this->dieWithError(
59                [ 'apierror-abstractwiki_run_fragment-not-enabled' ],
60                null, null, HttpStatus::NOT_IMPLEMENTED
61            );
62        }
63
64        $params = $this->extractRequestParams();
65
66        $qid = $params[ 'qid' ];
67        $date = $params[ 'date' ];
68        $language = $params[ 'language' ];
69        $fragmentStr = $params[ 'fragment' ];
70        $async = filter_var( $params[ 'async' ], FILTER_VALIDATE_BOOLEAN );
71
72        // Check fragment validity
73        $fragment = json_decode( $fragmentStr, true );
74        if ( $fragment === null || !is_array( $fragment ) ) {
75            $this->dieWithError(
76                [ 'apierror-abstractwiki_run_fragment-bad-fragment' ],
77                null, null, HttpStatus::BAD_REQUEST
78            );
79        }
80
81        $result = $this->getLatestFragmentAndRevalidate( $fragment, $qid, $language, $date, $async );
82
83        // Build WikifunctionCallException from the serialized cached error
84        // for convenience when building and throwing ApiUsageException:
85        if ( $result[ 'success' ] === false ) {
86            $e = WikifunctionCallException::fromArray( $result[ 'value' ] );
87            $errorData = [
88                'msg' => $e->getMessageKey(),
89                'zerror' => $e->getZError(),
90                'zerrorType' => $e->getZErrorType()
91            ];
92
93            $this->dieWithError(
94                /* message */ $e->getMessageObject(),
95                /* code */ $e->getErrorCode(),
96                /* data */ $errorData,
97                /* status */ $e->getHttpStatusCode()
98            );
99        }
100
101        // Set successful responses (pending or finalized):
102        $pageResult = $this->getResult();
103        $pageResult->addValue( [], $this->getModuleName(), $result );
104    }
105
106    /**
107     * Gets the latest available HTML fragment rendered from the given
108     * Abstract Content fragment and its arguments.
109     *
110     * Implements Stale-While-Revalidate caching strategy:
111     * * Returns cached HTML fragment for today,
112     * * If fresh fragment is not available in the cache, it queues
113     *   a job to regenerate the fragment with today's date and returns
114     *   whatever is available.
115     *
116     * Implements synchronous or asynchronous behavior depending on the
117     * async flag:
118     * * If called with async=true, the request is expected to respond
119     *   immediately with whatever it has available in the cache. In the
120     *   case that there is no stale content, it returns a pending response
121     *   while the job to regenerate the value is queued.
122     * * If called with async=false, the request is expected to respond
123     *   with the rendered value. In the case in which there is no fresh
124     *   nor stale values cached, it will perform a synchronous call to
125     *   wikifunctions_function_call and wait for its response.
126     *
127     * A successful response will contain a 'success' flag set to true,
128     * and the sanitized rendered fragment as 'value':
129     * [
130     *   'success' => true,
131     *   'value' => '<em>sanitized fragment</em>'
132     * ]
133     *
134     * A pending response will contain a 'success' flag set to true,
135     * and a 'pending' flag, also set to true:
136     * [
137     *   'success' => true,
138     *   'pending' => true
139     * ]
140     *
141     * A failed response will contain a 'success' flag set to false,
142     * the error information under the 'value' key:
143     * [
144     *   'success' => false,
145     *   'value' => [
146     *     'httpStatus' => 400,
147     *     'code' => 'wikilambda-zerror',
148     *     'data' => [
149     *        'msg' => 'some-error-message',
150     *        'params' => [],
151     *        'zerror' => [ ... ],
152     *        'zerrorType' => 'Z500'
153     *     ]
154     *   ]
155     * ]
156     *
157     * @param array $fragment
158     * @param string $qid
159     * @param string $language
160     * @param string $date
161     * @param bool $async
162     * @return array
163     */
164    private function getLatestFragmentAndRevalidate(
165        array $fragment,
166        string $qid,
167        string $language,
168        string $date,
169        bool $async
170    ): array {
171        // 1. Check in the cache for a fresh fragment
172        $cacheKeyFresh = $this->objectCache->makeKey(
173            self::ABSTRACT_FRAGMENT_CACHE_KEY_PREFIX,
174            $qid,
175            $language,
176            $date,
177            AbstractContentUtils::makeCacheKeyForAbstractFragment( $fragment )
178        );
179
180        $freshValue = json_decode( $this->objectCache->get( $cacheKeyFresh ) ?: '', true );
181
182        // Fresh fragment is cached: return value and do nothing more
183        if ( is_array( $freshValue ) ) {
184            return $freshValue;
185        }
186
187        // Build function call for the input fragment and arguments.
188        // At this point we know we are running the call for today's value.
189        $functionCall = $this->buildFragmentFunctionCall( $fragment, $qid, $language, $date );
190
191        // 2. Check in the cache for a stale fragment (cache key without date)
192        $cacheKeyStale = $this->objectCache->makeKey(
193            self::ABSTRACT_FRAGMENT_CACHE_KEY_PREFIX,
194            $qid,
195            $language,
196            AbstractContentUtils::makeCacheKeyForAbstractFragment( $fragment )
197        );
198
199        $staleValue = json_decode( $this->objectCache->get( $cacheKeyStale ) ?: '', true );
200
201        // 3.a. If we are running a sync call and there's no cached value (fresh or stale),
202        // regenerate the value synchronously and return it.
203        if ( !( $async ) && !is_array( $staleValue ) ) {
204            $cachedValue = $this->abstractWikiRequest->generateSafeFragment(
205                $functionCall,
206                $cacheKeyFresh,
207                $cacheKeyStale
208            );
209
210            return $cachedValue;
211        }
212
213        // 3.b. If we are running an async call:
214        // * push a job for the fragment to be refreshed and recashed
215        // * if there is cached stale value, we return it
216        // * if there is no cached value, we return a pending state
217        $revalidateFragmentJob = new CacheAbstractContentFragmentJob( [
218            'qid' => $qid,
219            'language' => $language,
220            'date' => $date,
221            'functionCall' => $functionCall,
222            'cacheKeyFresh' => $cacheKeyFresh,
223            'cacheKeyStale' => $cacheKeyStale
224        ] );
225        $this->jobQueueGroup->lazyPush( $revalidateFragmentJob );
226
227        if ( !is_array( $staleValue ) ) {
228            return [
229                'success' => true,
230                'pending' => true
231            ];
232        }
233
234        return $staleValue;
235    }
236
237    /**
238     * Builds function call to virtual function Z825/Run Abstract Fragment
239     *
240     * @param array $fragment
241     * @param string $qid
242     * @param string $language
243     * @param string $date
244     * @return array
245     */
246    private function buildFragmentFunctionCall( array $fragment, string $qid, string $language, string $date ): array {
247        // We get the function definition from schemata because:
248        // * it will be available in the Abstract repo
249        // * we don't need the user-contributed labels
250        // * we can avoid making a remote fetch from wikifunctions
251        $function = $this->getFunctionDefinition();
252
253        // Set function's only implementation as fragment to execute:
254        $function[ ZTypeRegistry::Z_FUNCTION_IMPLEMENTATIONS ] = [
255            ZTypeRegistry::Z_IMPLEMENTATION,
256            [
257                ZTypeRegistry::Z_OBJECT_TYPE => ZTypeRegistry::Z_IMPLEMENTATION,
258                ZTypeRegistry::Z_IMPLEMENTATION_FUNCTION => ZTypeRegistry::Z_RUN_ABSTRACT_FRAGMENT,
259                ZTypeRegistry::Z_IMPLEMENTATION_COMPOSITION => $fragment
260            ]
261        ];
262
263        // Build argument: Wikidata reference object from qid
264        $wikidataReference = [
265            ZTypeRegistry::Z_OBJECT_TYPE => ZTypeRegistry::Z_WIKIDATA_REFERENCE_ITEM,
266            ZTypeRegistry::Z_WIKIDATA_REFERENCE_ITEM_ID => $qid
267        ];
268
269        // Build argument: Date parser function call from date string and language
270        $dateParser = [
271            ZTypeRegistry::Z_OBJECT_TYPE => ZTypeRegistry::Z_FUNCTIONCALL,
272            ZTypeRegistry::Z_FUNCTIONCALL_FUNCTION => ZTypeRegistry::Z_DATE_PARSER,
273            ZTypeRegistry::Z_DATE_PARSER_STRING => $date,
274            ZTypeRegistry::Z_DATE_PARSER_LANGUAGE => $language
275        ];
276
277        // Build function call to literal function:
278        $functionCall = [
279            ZTypeRegistry::Z_OBJECT_TYPE => ZTypeRegistry::Z_FUNCTIONCALL,
280            ZTypeRegistry::Z_FUNCTIONCALL_FUNCTION => $function,
281            ZTypeRegistry::Z_RUN_ABSTRACT_FRAGMENT_QID => $wikidataReference,
282            ZTypeRegistry::Z_RUN_ABSTRACT_FRAGMENT_LANGUAGE => $language,
283            ZTypeRegistry::Z_RUN_ABSTRACT_FRAGMENT_DATE => $dateParser
284        ];
285
286        return $functionCall;
287    }
288
289    /**
290     * Gets the function definition for Z825/Run Abstract Fragment from
291     * the function schemata data definitions directory.
292     *
293     * @return array
294     */
295    private function getFunctionDefinition(): array {
296        $functionPath = dirname( __DIR__ ) . '/../function-schemata/data/definitions/';
297        $functionFile = ZTypeRegistry::Z_RUN_ABSTRACT_FRAGMENT . '.json';
298        $functionDefinitionStr = file_get_contents( $functionPath . $functionFile );
299
300        if ( !$functionDefinitionStr ) {
301            $this->dieWithError(
302                [ 'apierror-abstractwiki_run_fragment-not-enabled' ],
303                null, null, HttpStatus::NOT_IMPLEMENTED
304            );
305        }
306
307        $functionDefinition = json_decode( $functionDefinitionStr, true );
308        if ( !is_array( $functionDefinition ) ) {
309            $this->dieWithError(
310                [ 'apierror-abstractwiki_run_fragment-not-enabled' ],
311                null, null, HttpStatus::NOT_IMPLEMENTED
312            );
313        }
314
315        return $functionDefinition[ ZTypeRegistry::Z_PERSISTENTOBJECT_VALUE ];
316    }
317
318    /**
319     * @see ApiBase::isInternal()
320     * @inheritDoc
321     */
322    public function isInternal() {
323        return true;
324    }
325
326    /**
327     * @see ApiBase::getAllowedParams()
328     * @inheritDoc
329     * @codeCoverageIgnore
330     */
331    protected function getAllowedParams(): array {
332        return [
333            'qid' => [
334                ParamValidator::PARAM_TYPE => 'string',
335                ParamValidator::PARAM_REQUIRED => true,
336            ],
337            'language' => [
338                ParamValidator::PARAM_TYPE => 'string',
339                ParamValidator::PARAM_REQUIRED => true,
340            ],
341            'date' => [
342                ParamValidator::PARAM_TYPE => 'string',
343                ParamValidator::PARAM_REQUIRED => true,
344            ],
345            'fragment' => [
346                ParamValidator::PARAM_TYPE => 'text',
347                ParamValidator::PARAM_REQUIRED => true,
348            ],
349            'async' => [
350                ParamValidator::PARAM_TYPE => 'boolean',
351                ParamValidator::PARAM_REQUIRED => false,
352                ParamValidator::PARAM_DEFAULT => false
353            ]
354        ];
355    }
356
357    /**
358     * Generates URL-encoded example call to run an abstract fragment
359     *
360     * @param string $qid
361     * @param string $language
362     * @param string $date
363     * @param string $fragmentFile
364     * @return string URL-encoded contents
365     * @codeCoverageIgnore
366     */
367    private function buildExampleCallFor( $qid, $language, $date, $fragmentFile ): string {
368        $fragment = ZObjectUtils::readTestFile( 'abstract/' . $fragmentFile );
369        return 'action=abstractwiki_run_fragment&'
370            . 'abstractwiki_run_fragment_qid=' . $qid . '&'
371            . 'abstractwiki_run_fragment_language=' . $language . '&'
372            . 'abstractwiki_run_fragment_date=' . $date . '&'
373            . 'abstractwiki_run_fragment_fragment=' . urlencode( $fragment );
374    }
375
376    /**
377     * @see ApiBase::getExamplesMessages()
378     * @return array
379     * @codeCoverageIgnore
380     */
381    protected function getExamplesMessages(): array {
382        return [
383            // Run an Abstract Wiki content fragment that contains a simple literal HTML
384            $this->buildExampleCallFor( 'Q319', 'Z1002', '26-7-2023', 'fragment-literal-html.json' )
385                => 'apihelp-abstractwiki_run_fragment-example-literal-html',
386
387            // Run an Abstract Wiki content fragment that contains a composition with arguments
388            $this->buildExampleCallFor( 'Q319', 'Z1002', '26-7-2023', 'fragment-with-args.json' )
389                => 'apihelp-abstractwiki_run_fragment-example-composition'
390        ];
391    }
392}