Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
74.39% covered (warning)
74.39%
61 / 82
50.00% covered (danger)
50.00%
2 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiQueryImageSuggestionData
74.39% covered (warning)
74.39%
61 / 82
50.00% covered (danger)
50.00%
2 / 4
25.06
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 execute
72.41% covered (warning)
72.41%
42 / 58
0.00% covered (danger)
0.00%
0 / 1
16.55
 getAllowedParams
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 hasErrorCode
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2
3namespace GrowthExperiments\Api;
4
5use GrowthExperiments\NewcomerTasks\AddImage\ImageRecommendation;
6use GrowthExperiments\NewcomerTasks\AddImage\ImageRecommendationProvider;
7use GrowthExperiments\NewcomerTasks\ConfigurationLoader\ConfigurationLoader;
8use GrowthExperiments\NewcomerTasks\TaskType\ImageRecommendationBaseTaskType;
9use GrowthExperiments\NewcomerTasks\TaskType\ImageRecommendationTaskTypeHandler;
10use GrowthExperiments\NewcomerTasks\TaskType\SectionImageRecommendationTaskTypeHandler;
11use MediaWiki\Api\ApiBase;
12use MediaWiki\Api\ApiQuery;
13use MediaWiki\Api\ApiQueryBase;
14use MediaWiki\Api\ApiResult;
15use MediaWiki\Api\IApiMessage;
16use MediaWiki\Config\Config;
17use MediaWiki\Logger\LoggerFactory;
18use MediaWiki\Title\Title;
19use MediaWiki\Title\TitleFactory;
20use Psr\Log\LoggerAwareTrait;
21use StatusValue;
22use Wikimedia\Assert\Assert;
23use Wikimedia\ParamValidator\ParamValidator;
24
25/**
26 * Query module to support fetching image metadata from the Image Suggestion API.
27 *
28 * - Users must be logged-in
29 * - Rate limits apply
30 * - Image suggestion metadata is cached
31 */
32class ApiQueryImageSuggestionData extends ApiQueryBase {
33    use LoggerAwareTrait;
34
35    private ImageRecommendationProvider $imageRecommendationProvider;
36    private ConfigurationLoader $configurationLoader;
37    private Config $config;
38    private TitleFactory $titleFactory;
39
40    public function __construct(
41        ApiQuery $mainModule,
42        string $moduleName,
43        ImageRecommendationProvider $imageRecommendationProvider,
44        ConfigurationLoader $configurationLoader,
45        Config $config,
46        TitleFactory $titleFactory
47    ) {
48        parent::__construct( $mainModule, $moduleName, 'gisd' );
49        $this->imageRecommendationProvider = $imageRecommendationProvider;
50        $this->configurationLoader = $configurationLoader;
51        $this->config = $config;
52        $this->titleFactory = $titleFactory;
53
54        $this->setLogger( LoggerFactory::getInstance( 'GrowthExperiments' ) );
55    }
56
57    /** @inheritDoc */
58    public function execute() {
59        $user = $this->getUser();
60        if ( !$user->isNamed() ) {
61            $this->dieWithError( [ 'apierror-mustbeloggedin-generic' ] );
62        }
63
64        if ( $user->pingLimiter( 'growthexperiments-apiqueryimagesuggestiondata' ) ) {
65            $this->dieWithError( 'apierror-ratelimited' );
66        }
67        $params = $this->extractRequestParams();
68        // This API is used by external clients for their own structured task workflows so
69        // include disabled task types.
70        $allTaskTypes = $this->configurationLoader->getTaskTypes()
71            + $this->configurationLoader->getDisabledTaskTypes();
72        $taskType = $allTaskTypes[$params['tasktype']] ?? null;
73
74        if ( $taskType === null ) {
75            $this->logger->warning(
76                'Task type {tasktype} was not found in {configpage}',
77                [
78                    'tasktype' => $params['tasktype'],
79                    'configpage' => $this->getConfig()->get( 'GENewcomerTasksConfigTitle' ),
80                ]
81            );
82            $this->dieWithError(
83                [ 'growthexperiments-homepage-imagesuggestiondata-not-in-config', $params['tasktype'] ],
84                'not-in-config'
85            );
86        }
87
88        Assert::parameterType( ImageRecommendationBaseTaskType::class, $taskType, '$taskType' );
89        '@phan-var ImageRecommendationBaseTaskType $taskType';
90
91        $continueTitle = null;
92        if ( $params['continue'] !== null ) {
93            $continue = $this->parseContinueParamOrDie( $params['continue'], [ 'int', 'string' ] );
94            $continueTitle = $this->titleFactory->makeTitleSafe( $continue[0], $continue[1] );
95            $this->dieContinueUsageIf( !$continueTitle );
96        }
97        $pageSet = $this->getPageSet();
98        // Allow non-existing pages in developer setup, to facilitate local development/testing.
99        $pages = $this->config->get( 'GEDeveloperSetup' )
100            ? $pageSet->getGoodAndMissingPages()
101            : $pageSet->getGoodPages();
102        foreach ( $pages as $pageApiId => $pageIdentity ) {
103            if ( $continueTitle && !$continueTitle->equals( $pageIdentity ) ) {
104                continue;
105            }
106            $title = Title::castFromPageIdentity( $pageIdentity );
107            if ( !$title ) {
108                continue;
109            }
110            $metadata = $this->imageRecommendationProvider->get(
111                $title,
112                $taskType
113            );
114            $fit = null;
115            if ( $metadata instanceof ImageRecommendation ) {
116                $fit = $this->addPageSubItem(
117                    $pageApiId,
118                    $metadata->toArray()
119                );
120            } elseif ( !$this->hasErrorCode( $metadata, 'growthexperiments-no-recommendation-found' ) ) {
121                // like ApiQueryBase::addPageSubItems but we want to use a different path
122                $errorArray = $this->getErrorFormatter()->arrayFromStatus( $metadata );
123                $path = [ 'query', 'pages', $pageApiId ];
124                ApiResult::setIndexedTagName( $errorArray, 'growthimagesuggestiondataerrors' );
125                $fit = $this->getResult()->addValue( $path, 'growthimagesuggestiondataerrors', $errorArray );
126            }
127            if ( $fit === false ) {
128                $this->setContinueEnumParameter(
129                    'continue',
130                    $title->getNamespace() . '|' . $title->getText()
131                );
132                break;
133            }
134        }
135    }
136
137    /** @inheritDoc */
138    public function getAllowedParams() {
139        return [
140            'tasktype' => [
141                ParamValidator::PARAM_TYPE => [
142                    // Do not filter out non-existing task-types: during API structure tests
143                    // none of the task types exist and an empty list would cause test failures.
144                    ImageRecommendationTaskTypeHandler::TASK_TYPE_ID,
145                    SectionImageRecommendationTaskTypeHandler::TASK_TYPE_ID,
146                ],
147                ParamValidator::PARAM_REQUIRED => false,
148                ParamValidator::PARAM_DEFAULT => ImageRecommendationTaskTypeHandler::TASK_TYPE_ID,
149            ],
150            'continue' => [
151                ApiBase::PARAM_HELP_MSG => 'api-help-param-continue',
152            ],
153        ];
154    }
155
156    private function hasErrorCode( StatusValue $status, string $errorCode ): bool {
157        foreach ( $status->getErrors() as $error ) {
158            $message = $error['message'];
159            if ( $message instanceof IApiMessage && $message->getApiCode() === $errorCode ) {
160                return true;
161            }
162        }
163        return false;
164    }
165
166}