Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 70
0.00% covered (danger)
0.00%
0 / 2
CRAP
0.00% covered (danger)
0.00%
0 / 1
ServiceLinkRecommendationProvider
0.00% covered (danger)
0.00%
0 / 70
0.00% covered (danger)
0.00%
0 / 2
110
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 get
0.00% covered (danger)
0.00%
0 / 62
0.00% covered (danger)
0.00%
0 / 1
90
1<?php
2
3namespace GrowthExperiments\NewcomerTasks\AddLink;
4
5use GrowthExperiments\NewcomerTasks\TaskType\LinkRecommendationTaskType;
6use GrowthExperiments\NewcomerTasks\TaskType\TaskType;
7use MediaWiki\Http\HttpRequestFactory;
8use MediaWiki\Linker\LinkTarget;
9use MediaWiki\Revision\RevisionLookup;
10use MediaWiki\Revision\SlotRecord;
11use MediaWiki\Title\TitleFactory;
12use MediaWiki\Utils\MWTimestamp;
13use RequestContext;
14use StatusValue;
15use Wikimedia\Assert\Assert;
16use WikitextContent;
17
18/**
19 * A link recommendation provider that uses the link recommendation service.
20 * @see https://wikitech.wikimedia.org/wiki/Add_Link
21 */
22class ServiceLinkRecommendationProvider implements LinkRecommendationProvider {
23
24    /** @var TitleFactory */
25    private $titleFactory;
26
27    /** @var RevisionLookup */
28    private $revisionLookup;
29
30    /** @var HttpRequestFactory */
31    private $httpRequestFactory;
32
33    /** @var string */
34    private $url;
35
36    /** @var string */
37    private $wikiId;
38
39    /** @var string */
40    private $languageCode;
41
42    /** @var string|null */
43    private $accessToken;
44
45    /** @var int|null Service request timeout in seconds. */
46    private $requestTimeout;
47
48    /**
49     * @param TitleFactory $titleFactory
50     * @param RevisionLookup $revisionLookup
51     * @param HttpRequestFactory $httpRequestFactory
52     * @param string $url Link recommendation service root URL
53     * @param string $wikiId Wiki ID (e.g. "simple", "en")
54     * @param string $languageCode the ISO-639 language code (e.g. "az" for Azeri, "en" for English) to use in
55     *   processing the wikitext
56     * @param string|null $accessToken Jwt for authorization with external traffic release of link
57     *   recommendation service
58     * @param int|null $requestTimeout Service request timeout in seconds.
59     */
60    public function __construct(
61        TitleFactory $titleFactory,
62        RevisionLookup $revisionLookup,
63        HttpRequestFactory $httpRequestFactory,
64        string $url,
65        string $wikiId,
66        string $languageCode,
67        ?string $accessToken,
68        ?int $requestTimeout
69    ) {
70        $this->titleFactory = $titleFactory;
71        $this->revisionLookup = $revisionLookup;
72        $this->httpRequestFactory = $httpRequestFactory;
73        $this->url = $url;
74        $this->wikiId = $wikiId;
75        $this->languageCode = $languageCode;
76        $this->accessToken = $accessToken;
77        $this->requestTimeout = $requestTimeout;
78    }
79
80    /** @inheritDoc */
81    public function get( LinkTarget $title, TaskType $taskType ) {
82        Assert::parameterType( LinkRecommendationTaskType::class, $taskType, '$taskType' );
83        /** @var LinkRecommendationTaskType $taskType */'@phan-var LinkRecommendationTaskType $taskType';
84        $title = $this->titleFactory->newFromLinkTarget( $title );
85        $pageId = $title->getArticleID();
86        $titleText = $title->getPrefixedDBkey();
87        $revId = $title->getLatestRevID();
88
89        if ( !$revId ) {
90            return StatusValue::newFatal( 'growthexperiments-addlink-pagenotfound', $titleText );
91        }
92        $content = $this->revisionLookup->getRevisionById( $revId )->getContent( SlotRecord::MAIN );
93        if ( !$content ) {
94            return StatusValue::newFatal( 'growthexperiments-addlink-revdeleted', $revId, $titleText );
95        } elseif ( !( $content instanceof WikitextContent ) ) {
96            return StatusValue::newFatal( 'growthexperiments-addlink-wrongmodel', $revId, $titleText );
97        }
98        $wikitext = $content->getText();
99
100        // FIXME: Don't hardcode 'wikipedia' project
101        // FIXME: Use a less hacky way to get the project/subdomain pair that the API gateway wants.
102        $pathArgs = [ 'wikipedia', str_replace( 'wiki', '', $this->wikiId ), $titleText ];
103        $queryArgs = [
104            'threshold' => $taskType->getMinimumLinkScore(),
105            'max_recommendations' => $taskType->getMaximumLinksPerTask(),
106            'language_code' => $this->languageCode,
107        ];
108        $postBodyArgs = [
109            'pageid' => $pageId,
110            'revid' => $revId,
111            'wikitext' => $wikitext,
112        ];
113        if ( $taskType->getExcludedSections() ) {
114            $postBodyArgs['sections_to_exclude'] = $taskType->getExcludedSections();
115        }
116        $request = $this->httpRequestFactory->create(
117            wfAppendQuery(
118                $this->url . '/v1/linkrecommendations/' . implode( '/', array_map( 'rawurlencode', $pathArgs ) ),
119                $queryArgs
120            ),
121            [
122                'method' => 'POST',
123                'postData' => json_encode( $postBodyArgs ),
124                'originalRequest' => RequestContext::getMain()->getRequest(),
125                'timeout' => $this->requestTimeout,
126            ],
127            __METHOD__
128        );
129        if ( $this->accessToken ) {
130            // TODO: Support app authentication with client ID / secret
131            // https://api.wikimedia.org/wiki/Documentation/Getting_started/Authentication#App_authentication
132            $request->setHeader( 'Authorization', "Bearer $this->accessToken" );
133        }
134        $request->setHeader( 'Content-Type', 'application/json' );
135
136        $status = $request->execute();
137        if ( !$status->isOK() ) {
138            return $status;
139        }
140        $response = $request->getContent();
141
142        $data = json_decode( $response, true );
143        if ( $data === null ) {
144            return StatusValue::newFatal( 'growthexperiments-addlink-invalidjson', $titleText );
145        }
146        if ( array_key_exists( 'error', $data ) ) {
147            return StatusValue::newFatal( 'growthexperiments-addlink-serviceerror',
148                $titleText, $data['error'] );
149        }
150        // TODO validate/process data; compare $data['page_id'] and $data['revid']
151        return new LinkRecommendation(
152            $title,
153            $pageId,
154            $revId,
155            LinkRecommendation::getLinksFromArray( $data['links'] ),
156            LinkRecommendation::getMetadataFromArray( $data['meta'] + [
157                'task_timestamp' => MWTimestamp::time(),
158            ] )
159        );
160    }
161
162}