Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.87% covered (warning)
89.87%
213 / 237
42.86% covered (danger)
42.86%
3 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiPerformTest
89.87% covered (warning)
89.87%
213 / 237
42.86% covered (danger)
42.86%
3 / 7
58.14
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 run
91.55% covered (success)
91.55%
65 / 71
0.00% covered (danger)
0.00%
0 / 1
14.12
 getTestResultForImplementation
98.82% covered (success)
98.82%
84 / 85
0.00% covered (danger)
0.00%
0 / 1
9
 validateRequestedObject
76.47% covered (warning)
76.47%
52 / 68
0.00% covered (danger)
0.00%
0 / 1
28.30
 setMemoizedObjectData
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getMemoizedObjectData
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 getAllowedParams
n/a
0 / 0
n/a
0 / 0
1
 getExamplesMessages
n/a
0 / 0
n/a
0 / 0
2
 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 MediaWiki\Api\ApiMain;
14use MediaWiki\Extension\WikiLambda\HttpStatus;
15use MediaWiki\Extension\WikiLambda\Jobs\CacheTesterResultsJob;
16use MediaWiki\Extension\WikiLambda\Jobs\ExecuteTestAndCacheJob;
17use MediaWiki\Extension\WikiLambda\OrchestratorRequest;
18use MediaWiki\Extension\WikiLambda\Registry\ZErrorTypeRegistry;
19use MediaWiki\Extension\WikiLambda\Registry\ZTypeRegistry;
20use MediaWiki\Extension\WikiLambda\WikiLambdaServices;
21use MediaWiki\Extension\WikiLambda\ZErrorException;
22use MediaWiki\Extension\WikiLambda\ZErrorFactory;
23use MediaWiki\Extension\WikiLambda\ZObjectFactory;
24use MediaWiki\Extension\WikiLambda\ZObjects\ZBoolean;
25use MediaWiki\Extension\WikiLambda\ZObjects\ZFunction;
26use MediaWiki\Extension\WikiLambda\ZObjects\ZObject;
27use MediaWiki\Extension\WikiLambda\ZObjects\ZPersistentObject;
28use MediaWiki\Extension\WikiLambda\ZObjects\ZReference;
29use MediaWiki\Extension\WikiLambda\ZObjects\ZResponseEnvelope;
30use MediaWiki\Extension\WikiLambda\ZObjects\ZTypedList;
31use MediaWiki\Extension\WikiLambda\ZObjectStore;
32use MediaWiki\Extension\WikiLambda\ZObjectUtils;
33use MediaWiki\JobQueue\JobQueueGroup;
34use MediaWiki\Json\FormatJson;
35use MediaWiki\Title\Title;
36use Wikimedia\ParamValidator\ParamValidator;
37use Wikimedia\Stats\StatsFactory;
38
39class ApiPerformTest extends WikiLambdaApiBase {
40
41    // Function variables
42    private ZFunction $function;
43    private string $functionZid;
44    private int $functionRevision;
45
46    private array $connectedImplementations = [];
47    private array $connectedTests = [];
48    private array $memoizedObjectData = [];
49
50    public function __construct(
51        ApiMain $mainModule,
52        string $moduleName,
53        OrchestratorRequest $orchestrator,
54        private readonly ZObjectStore $zObjectStore,
55        private readonly JobQueueGroup $jobQueueGroup,
56        StatsFactory $statsFactory,
57    ) {
58        parent::__construct(
59            $mainModule,
60            $moduleName,
61            $statsFactory,
62            'wikilambda_perform_test_'
63        );
64
65        $this->setUp( $orchestrator );
66    }
67
68    /**
69     * Runs a matrix of Testers against Implementations for a given Function and returns the results
70     * of each test/implementation pair.
71     *
72     * Request parameters:
73     * ===================
74     * * zfunction (required): Zid of the Z8/Function to test. When called alone (no values for the
75     *   implementations or the tests array), it will test the matrix of all the connected tests and
76     *   implementations.
77     * * zimplementations (optional): List of implementations to test. Every item can be a Zid or a
78     *   literal implementation. However, when passed a literal, that is normally considered the only
79     *   implementation to test.
80     * * ztesters (optional): List of tests to perform. Every item can be a zid or a literal test object.
81     *   When passed as a literal, it is normally the only item.
82     *
83     * Response:
84     * =========
85     * For each implementation/tester pair, returns an item with the following info for each test:
86     * * zFunctionId
87     * * zImplementationId
88     * * zTesterId
89     * * validateStatus: Z40/Boolean indicating whether the test passed
90     * * testMetadata: Z22K2 metadata map from the orchestrator response. Contains the response of
91     *   the initial test call, enriched with new keys containing different circumstances encountered
92     *   by the test execution (e.g. actual result vs. expected result, cache-related keys, etc.)
93     *
94     * This API has no synchronous execution, so it should return the matrix of values
95     * immediately. The matrix of values contains all the items requested, it will not
96     * remove expected results from the response.
97     *
98     * Internal logic:
99     * ===============
100     * For each implementation-test pair to run:
101     * 1. We try to getch the test result from the test result cache (DB table wikilambda_ztester_results)
102     * 2. If not stored, we orchestrate the test execution:
103     *    2.a. We resolve the test call/Z20K2
104     *    2.b. We use the response from the test call to build the validation call.
105     *    2.c. We resolve the validation call.
106     *    These two calls are fetched from the function call cache. At any point, if any call
107     *    is missing from the cache, then the test cannot be resolved immediately, so a "pending"
108     *    response is added to the response matrix.
109     * 3. For every test response in a pending state and needs to be fully executed, queue an
110     *    executeTestAndCache job
111     * 4. For every test response that is ready but not stored in the test results table: queue
112     *    a cacheTesterResults job.
113     *
114     * @inheritDoc
115     */
116    protected function run() {
117        $params = $this->extractRequestParams();
118        $pageResult = $this->getResult();
119
120        $this->functionZid = $params[ 'zfunction' ];
121
122        $requestedImps = $params[ 'zimplementations' ] ?: [];
123        $requestedTesters = $params[ 'ztesters' ] ?: [];
124
125        // 1. Work out the matrix of implementations/testers that we want to run
126
127        // 1.a. Check that Function exists
128        $targetTitle = Title::newFromText( $this->functionZid, NS_MAIN );
129        if ( !$targetTitle || !( $targetTitle->exists() ) ) {
130            $this->dieWithError(
131                [ "wikilambda-performtest-error-unknown-zid", $this->functionZid ],
132                null,
133                null,
134                HttpStatus::NOT_FOUND
135            );
136        }
137
138        // 1.b. Check that Function Zid belongs to an object of the right type (Z8/Function)
139        // (T430601) Read at the requester's audience, so a RevisionDelete'd or suppressed
140        // current revision is not disclosed to those who may not see it; it then reads as
141        // the same unknown-Zid error as a missing object.
142        $targetObject = $this->zObjectStore->fetchZObjectByTitle(
143            $targetTitle, null, $this->getAuthority()
144        );
145        if ( !$targetObject ) {
146            $this->dieWithError(
147                [ "wikilambda-performtest-error-unknown-zid", $this->functionZid ],
148                null,
149                null,
150                HttpStatus::NOT_FOUND
151            );
152        }
153        if ( $targetObject->getZType() !== ZTypeRegistry::Z_FUNCTION ) {
154            $this->dieWithError(
155                [ "wikilambda-performtest-error-nonfunction", $this->functionZid ],
156                null,
157                null,
158                HttpStatus::BAD_REQUEST
159            );
160        }
161
162        // Initialize the function globals
163        $this->functionRevision = $targetTitle->getLatestRevID();
164        $functionObject = $targetObject->getInnerZObject();
165        '@phan-var \MediaWiki\Extension\WikiLambda\ZObjects\ZFunction $functionObject';
166
167        // Set globally function object, connected implementations and connected tests
168        $this->function = $functionObject;
169        $this->connectedImplementations = $functionObject->getImplementationZids();
170        $this->connectedTests = $functionObject->getTesterZids();
171
172        // 1.c. If no specific implementation Zids are passed in the request, test all of them (connected ones)
173        if ( !count( $requestedImps ) ) {
174            $implementationsTemp = $functionObject->getValueByKey( ZTypeRegistry::Z_FUNCTION_IMPLEMENTATIONS );
175            '@phan-var \MediaWiki\Extension\WikiLambda\ZObjects\ZTypedList $implementationsTemp';
176            $requestedImps = $implementationsTemp->getAsArray();
177        }
178
179        // 1.d. If no specific tester Zids are passed in the request, run all of them (connected ones)
180        if ( !count( $requestedTesters ) ) {
181            $testsTemp = $functionObject->getValueByKey( ZTypeRegistry::Z_FUNCTION_TESTERS );
182            '@phan-var \MediaWiki\Extension\WikiLambda\ZObjects\ZTypedList $testsTemp';
183            $requestedTesters = $testsTemp->getAsArray();
184        }
185
186        // 2. For each selected implementation, run each selected tester
187
188        // Array of test results for each implementation:tester combination
189        $responseArray = [];
190
191        foreach ( $requestedImps as $requestedImp ) {
192            // 2.a. Validate the implementation passed in the request, and if all goes well,
193            // get all the details needed (zid, revision, inner object and whether its passed inline)
194            $implementation = $this->validateRequestedObject( $requestedImp, ZTypeRegistry::Z_IMPLEMENTATION );
195
196            // 2.b. Re-use our copy of the target function, setting the implementations
197            // to a list with just the one we're testing now
198            $functionObject->setValueByKey(
199                ZTypeRegistry::Z_FUNCTION_IMPLEMENTATIONS,
200                new ZTypedList(
201                    ZTypedList::buildType( new ZReference( ZTypeRegistry::Z_IMPLEMENTATION ) ),
202                    $implementation[ 'object' ]
203                )
204            );
205
206            // 3. For each tester passed in the request, perform function call
207            // against the current implementation.
208            foreach ( $requestedTesters as $requestedTester ) {
209                // 3.a. Validate the tester passed in the request, and if all goes well,
210                // get all the details needed (zid, revision, inner object and whether its passed inline)
211                $test = $this->validateRequestedObject( $requestedTester, ZTypeRegistry::Z_TESTER );
212
213                // 3.b. Initialize test result item
214                $testResult = [
215                    'zFunctionId' => $this->functionZid,
216                    'zImplementationId' => $implementation['zid'],
217                    'zTesterId' => $test['zid'],
218                ];
219
220                // 3.c. If there was any validation error, set test result as false and create error metadata
221                if ( $implementation['zError'] || $test['zError'] ) {
222                    $testResult['validateStatus'] = new ZBoolean( false );
223                    $testResult['testMetadata'] = ZResponseEnvelope::wrapInResponseMap(
224                        'validateErrors', $implementation['zError'] ?: $test['zError']
225                    );
226
227                    // Continue to next implementation:test iteration
228                    $responseArray[] = $testResult;
229                    continue;
230                }
231
232                // Initial validation of implementation and tester went well!
233                $testResult = array_merge( $testResult,
234                    $this->getTestResultForImplementation( $implementation, $test ) );
235
236                // Serialize and encode the responses; validateStatus and testMetadata are ZObjects
237                $testResult[ 'validateStatus' ] = json_encode( $testResult[ 'validateStatus' ]->getSerialized() );
238                $testResult[ 'testMetadata' ] = json_encode( $testResult[ 'testMetadata' ]->getSerialized() );
239
240                // Stash the response
241                $responseArray[] = $testResult;
242            }
243        }
244
245        // 5. Return the response.
246        $pageResult->addValue( [ 'query' ], $this->getModuleName(), $responseArray );
247    }
248
249    /**
250     * Returns the result of running a test against an implementation:
251     * * See if the execution response is stored in the test result cache
252     * * Run orchestrateTestExecution with evaluateOnMiss=false
253     * * If both calls are available, return the response and persist it in the test results cache.
254     * * If one call is not available, return pending and execute the test via an async job.
255     *
256     * @param array $implementation
257     * @param array $test
258     * @return array
259     */
260    private function getTestResultForImplementation( array $implementation, array $test ): array {
261        $passed = true;
262        $testResult = [];
263        $logContext = [
264            'functionZid' => $this->functionZid,
265            'functionRevision' => $this->functionRevision,
266            'implementationZid' => $implementation['zid'],
267            'implementationRevision' => $implementation['revision'],
268            'testZid' => $test['zid'],
269            'testRevision' => $test['revision']
270        ];
271
272        // 1. If test and implementation have revision Ids (aren't inline literals), check test result table
273        $inlineTest = $test['revision'] === null;
274        $inlineImplementation = $implementation['revision'] === null;
275
276        if ( !$inlineTest && !$inlineImplementation ) {
277            $cachedResult = $this->zObjectStore->findZTesterResult(
278                $this->functionZid,
279                $this->functionRevision,
280                $implementation['zid'],
281                $implementation['revision'],
282                $test['zid'],
283                $test['revision'],
284            );
285
286            // 1.a. Cache hit on the first level! We return the stored value,
287            // which should already have info about where and when it was cached
288            if ( $cachedResult !== null ) {
289                $this->getLogger()->debug( __CLASS__ . ' Test results are cached', $logContext + [
290                    'cached' => $cachedResult->getZValue()->getSerialized()
291                ] );
292
293                $testResult['validateStatus'] = $cachedResult->getZValue();
294                $testResult['testMetadata'] = $cachedResult->getZMetadata();
295
296                return $testResult;
297            }
298        }
299
300        // 2. Orchestrate the test execution
301        // =================================
302
303        // 2.a. Use tester to create a function call of the test case inputs
304        $testObject = $test['object'];
305        '@phan-var \MediaWiki\Extension\WikiLambda\ZObjects\ZObject $testObject';
306
307        $testFunctionCall = $testObject->getValueByKey( ZTypeRegistry::Z_TESTER_CALL );
308        '@phan-var \MediaWiki\Extension\WikiLambda\ZObjects\ZFunctionCall $testFunctionCall';
309
310        $validationCall = $testObject->getValueByKey( ZTypeRegistry::Z_TESTER_VALIDATION );
311        '@phan-var \MediaWiki\Extension\WikiLambda\ZObjects\ZFunctionCall $validationCall';
312
313        // 2.b. Set the target function of the call to our modified copy of the
314        // target function with only the current implementation.
315        // It might not be the top level Z7K1, so descend down the nested function
316        // call to find the position of the target before setting the new value.
317        $testDereferencedCall = ZObjectUtils::dereferenceZFunction(
318            $testFunctionCall,
319            $this->functionZid,
320            $this->function
321        );
322
323        // 2.c. execute without evaluation on miss
324        // * can return false,
325        // * won't ever throw OrchestratorException or TimeoutException
326        $result = $this->orchestrator->orchestrateTestExecution(
327            $testDereferencedCall,
328            $validationCall->getSerialized(),
329            /* evaluateOnMiss= */ false
330        );
331
332        // 2.d. at least one of the necessary function calls is not cached :(
333        if ( $result === false ) {
334            $this->getLogger()->debug(
335                __CLASS__ . ' Test calls are not cached: creating new ExecuteTestAndCacheJob',
336                $logContext
337            );
338
339            // Before queuing an execution job, make sure the user has the right permission
340            if ( !$this->getContext()->getAuthority()->isAllowed( 'wikilambda-execute' ) ) {
341                $this->failWithPermissionDenied( 'wikilambda-execute', $testDereferencedCall );
342            }
343
344            // This job should be able to execute the whole test
345            // no matter which call miss has caused the re-execution.
346            $executeTestJob = new ExecuteTestAndCacheJob( [
347                // Initial test call already dereferenced with the literal function
348                'testCall' => ZObjectUtils::arrayify( $testDereferencedCall ),
349                // Validation call without K1 (which will be replaced with test call result)
350                'validationCall' => ZObjectUtils::arrayify( $validationCall->getSerialized() ),
351                // Function, implementation and test data
352                'functionZid' => $this->functionZid,
353                'functionRevision' => $this->functionRevision,
354                'firstImplementation' => $this->connectedImplementations[0] ?? null,
355                'implementationZid' => $implementation['zid'],
356                'implementationRevision' => $implementation['revision'],
357                'testZid' => $test['zid'],
358                'testRevision' => $test['revision'],
359            ] );
360            $this->jobQueueGroup->push( $executeTestJob );
361
362            // Return a pending response
363            $failedResponse = ZResponseEnvelope::wrapInResponseMap( 'pending', new ZBoolean( true ) );
364            $testResult['validateStatus'] = new ZBoolean( false );
365            $testResult['testMetadata'] = $failedResponse;
366
367            return $testResult;
368        }
369
370        // 3. Cache through an asynchronous job
371        // ====================================
372        $testMetadata = $result['metadata'];
373        '@phan-var \MediaWiki\Extension\WikiLambda\ZObjects\ZTypedMap $testMetadata';
374
375        // We can only do this for persisted revisions, not inline items, as we can't
376        // version them otherwise, so use truthiness (neither null nor 0, non-extant).
377        // We also only do this if the validation step didn't have an error itself.
378        if ( !$inlineImplementation && !$inlineTest && !$result['hasErrors'] ) {
379            // Store a fake ZResponseEnvelope of the validation result and the real metadata
380            // via an asynchronous job so that we don't trigger a "DB write on API GET" error
381            $this->getLogger()->debug(
382                __CLASS__ . ' Test calls are cached: creating new CacheTesterResultsJob',
383                $logContext
384            );
385
386            $stashedResult = new ZResponseEnvelope( $result['value'], $testMetadata );
387
388            // Create and push job to update the stored result
389            $cacheTesterResultsJob = new CacheTesterResultsJob( [
390                'functionZid' => $this->functionZid,
391                'functionRevision' => $this->functionRevision,
392                'implementationZid' => $implementation['zid'],
393                'implementationRevision' => $implementation['revision'],
394                'testZid' => $test['zid'],
395                'testRevision' => $test['revision'],
396                'passed' => $result['passed'],
397                'stashedResult' => ZObjectUtils::arrayify( $stashedResult->getSerialized() )
398            ] );
399            $this->jobQueueGroup->push( $cacheTesterResultsJob );
400        }
401
402        $testResult['validateStatus'] = $result['value'];
403        $testResult['testMetadata'] = $testMetadata;
404        return $testResult;
405    }
406
407    /**
408     * Validates an implementation or test object before adding it to the
409     * test result matrix and proceeding to its test evaluation.
410     *
411     * The input object could be:
412     * * If received through the API parameters, it will be a string,
413     *   which can contain a zid, or a literal test or implementation.
414     * * Else, it will be a ZObject (most likely a ZReference), stored
415     *   in the ZFunction object.
416     *
417     * Permission errors will be thrown immediately and will cause the API to die.
418     * Validation errors will be added to the result matrix.
419     *
420     * The data returned is an array containing:
421     * * zid: the zid of the implementation or test
422     * * object: the value of the object, which can be the zid or literal Z14
423     *   in the case of implementations, and will be the literal Z20 in case of testers.
424     * * revision: the latest revision ID, which will be used for caching
425     * * inline: boolean flag that indicates whether the object was passed
426     *   as an inline literal object rather than a reference
427     *   to the function or disconnected
428     * * zError: error to attach to the result matrix
429     *
430     * @param ZObject|string $object - object to validate for the given type
431     * @param string $type - either 'Z14' or 'Z20'
432     * @return array
433     */
434    protected function validateRequestedObject( $object, $type ): array {
435        // If this was validated in a previous iteration, don't do it again
436        $memoized = $this->getMemoizedObjectData( $object );
437        if ( is_array( $memoized ) ) {
438            return $memoized;
439        }
440
441        // Prepare response array:
442        $response = [
443            'zid' => null,
444            'object' => null,
445            'revision' => null,
446            'zError' => null
447        ];
448
449        $isImplementation = $type === ZTypeRegistry::Z_IMPLEMENTATION;
450        $createPermission = $isImplementation ?
451            'wikilambda-create-implementation' :
452            'wikilambda-create-tester';
453        $nonObjectMessge = $isImplementation ?
454            'wikilambda-performtest-error-nonimplementation' :
455            'wikilambda-performtest-error-nontester';
456        $invalidObjectMessge = $isImplementation ?
457            'wikilambda-performtest-error-invalidimplementation' :
458            'wikilambda-performtest-error-invalidtester';
459
460        // If object is a string, it was passed as a parameter in the request;
461        // decode it to see if it's a zid (persisted) or an inline object (not persisted)
462        if ( is_string( $object ) ) {
463
464            // (T358089) Decode any '|' characters of ZObjects that were escaped for the API transit
465            $object = str_replace( '🪈', '|', $object );
466            $decodedJson = FormatJson::decode( $object );
467
468            // Validate literal input
469            // ======================
470            if ( $decodedJson ) {
471                // If the input is a JSON, we have received an inline implementation.
472                $userAuthority = $this->getContext()->getAuthority();
473                // Non-recoverable Failure: if creation is not authorized, we end the request.
474                if ( !$userAuthority->isAllowed( $createPermission ) ) {
475                    $this->failWithPermissionDenied( $createPermission, $decodedJson );
476                }
477                // Non-recoverable Failure: user is not authorized to run inline implementation
478                if ( $isImplementation && !$userAuthority->isAllowed( 'wikilambda-execute-unsaved-code' ) ) {
479                    $this->failWithPermissionDenied( 'wikilambda-execute-unsaved-code', $decodedJson );
480                }
481
482                try {
483                    // For an inline object, create the ZObject instance with the factory
484                    $object = ZObjectFactory::create( $decodedJson );
485                } catch ( ZErrorException ) {
486                    // ZObjectFactory::create threw a ZErrorException because the object
487                    // wasn't valid: set response zError and return immediately
488                    $response['zError'] = ZErrorFactory::createZErrorInstance( ZErrorTypeRegistry::Z_ERROR_UNKNOWN, [
489                        'message' => wfMessage( $invalidObjectMessge )->text()
490                    ] );
491                    return $response;
492                }
493
494                // The inline object is a valid object: check that it contains the right type
495                // (it can be wrapped in a persistent object or be directly a literal implementation or test)
496                $isPersistentObj = $object instanceof ZPersistentObject;
497                $objectType = $isPersistentObj ? $object->getInternalZType() : $object->getZType();
498
499                if ( $objectType !== $type ) {
500                    // The type is something else: set response zError and return immediately
501                    $response['zError'] = ZErrorFactory::createZErrorInstance( ZErrorTypeRegistry::Z_ERROR_UNKNOWN, [
502                        'message' => wfMessage( $nonObjectMessge, (string)$object )->text()
503                    ] );
504                    return $response;
505                }
506
507                // object must have a ZObject; eiter a ZTest or ZImplementation or a ZReference
508                $response['object'] = $isPersistentObj ? $object->getInnerZObject() : $object;
509                return $response;
510            }
511
512            // object was a Zid, convert into a ZReference and continue
513            $object = new ZReference( $object );
514        }
515
516        // Validate reference
517        // ==================
518
519        if ( !( $object instanceof ZReference ) ) {
520            // At this point this should not happen, as no literal tests or implementations
521            // should be stored inside Z8K3 or Z8K4, but let's make sure nonetheless:
522            $response['zError'] = ZErrorFactory::createZErrorInstance( ZErrorTypeRegistry::Z_ERROR_UNKNOWN, [
523                'message' => wfMessage( $nonObjectMessge, (string)$object )->text()
524            ] );
525        }
526
527        // At this point, $object is a ZReference for sure:
528        $response['zid'] = $object->getZValue();
529
530        // Check the zid is a valid title
531        $title = Title::newFromText( $response['zid'], NS_MAIN );
532        if ( !$title || !( $title instanceof Title ) || !$title->exists() ) {
533            $response['zError'] = ZErrorFactory::createZErrorInstance( ZErrorTypeRegistry::Z_ERROR_UNKNOWN, [
534                'message' => wfMessage( $nonObjectMessge, $response['zid'] )->text()
535            ] );
536            return $response;
537        }
538
539        // Check the zid belongs to an object of the right type
540        // (T430601) Read at the requester's audience; a hidden revision reads as the same
541        // "not an object of this type" error as a missing one, rather than a fatal.
542        $fetchedContent = $this->zObjectStore->fetchZObjectByTitle(
543            $title, null, $this->getAuthority()
544        );
545        $fetchedObject = $fetchedContent ? $fetchedContent->getInnerZObject() : null;
546        if ( !$fetchedObject || $fetchedObject->getZType() !== $type ) {
547            $response['zError'] = ZErrorFactory::createZErrorInstance( ZErrorTypeRegistry::Z_ERROR_UNKNOWN, [
548                'message' => wfMessage( $nonObjectMessge, $response['zid'] )->text()
549            ] );
550            return $response;
551        }
552
553        // IMPORTANT!
554        // If the type is test, we need to resolve it and get the literal object
555        // as well, because the caller is ALWAYS gonna need the test object in order
556        // to run separately the call and validation call.
557        $response['object'] = $isImplementation ? $object : $fetchedObject;
558        $response['revision'] = $title->getLatestRevID();
559
560        $this->setMemoizedObjectData( $response );
561
562        return $response;
563    }
564
565    /**
566     * Keeps the already validated object data for future iterations
567     * to retrieve. When requesting a matrix of implementations and
568     * testers (e.g. for a function page), the inner loop (tests)
569     * walks the list of tests for each of the outer implementation.
570     * We memoize these validated objects to avoid running the same
571     * validation operation N*M times, as it contains some expensive
572     * logic (deserialization using ZObjectFactory and DB fetches)
573     *
574     * Only stores when the validated object was given as a zid.
575     *
576     * @param array $response
577     */
578    private function setMemoizedObjectData( array $response ): void {
579        if ( is_string( $response[ 'zid' ] ) ) {
580            $this->memoizedObjectData[ $response[ 'zid' ] ] = $response;
581        }
582    }
583
584    /**
585     * Returns the memoized resulg of validating an input object and
586     * creating its array of object data. Returns false if nothing
587     * was stored yet.
588     *
589     * Only retrieves when the object to validate is given as a zid.
590     *
591     * @param ZObject|string $object
592     * @return array|false
593     */
594    private function getMemoizedObjectData( ZObject|string $object ): array|false {
595        if ( is_string( $object ) && array_key_exists( $object, $this->memoizedObjectData ) ) {
596            return $this->memoizedObjectData[ $object ];
597        }
598        return false;
599    }
600
601    /**
602     * @inheritDoc
603     * @codeCoverageIgnore
604     */
605    protected function getAllowedParams(): array {
606        return [
607            'zfunction' => [
608                ParamValidator::PARAM_TYPE => 'text',
609                ParamValidator::PARAM_REQUIRED => true,
610            ],
611            'zimplementations' => [
612                ParamValidator::PARAM_ISMULTI => true,
613                ParamValidator::PARAM_REQUIRED => false,
614            ],
615            'ztesters' => [
616                ParamValidator::PARAM_ISMULTI => true,
617                ParamValidator::PARAM_REQUIRED => false,
618            ],
619        ];
620    }
621
622    /**
623     * @inheritDoc
624     * @codeCoverageIgnore
625     */
626    protected function getExamplesMessages() {
627        // Don't try to read the latest ZID from the DB on client wikis, we can't.
628        $exampleZid =
629            ( WikiLambdaServices::getMode()->isRepo() ) ?
630            $this->zObjectStore->findFirstZImplementationFunction() :
631            'Z10000';
632
633        $queryPrefix = 'action=wikilambda_perform_test&format=json&wikilambda_perform_test_zfunction=';
634
635        return [
636            $queryPrefix . $exampleZid
637                => 'apihelp-wikilambda_perform_test-example',
638            $queryPrefix . 'Z801'
639                => 'apihelp-wikilambda_perform_test-z801',
640            $queryPrefix . 'Z801&wikilambda_perform_test_zimplementations=Z901'
641                => 'apihelp-wikilambda_perform_test-z801-implementation',
642            $queryPrefix . 'Z801&wikilambda_perform_test_ztesters=Z8010|Z8011'
643                => 'apihelp-wikilambda_perform_test-z801-tester',
644            $queryPrefix . 'Z801&wikilambda_perform_test_zimplementations=Z901&wikilambda_perform_test_ztesters=Z8010'
645                => 'apihelp-wikilambda_perform_test-z801-implementation-and-testers',
646        ];
647    }
648
649    /**
650     * Mark as internal. This isn't meant to be user-facing, and can change at any time.
651     * @return bool
652     */
653    public function isInternal() {
654        return true;
655    }
656}