Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 72
0.00% covered (danger)
0.00%
0 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
UserImpactHandler
0.00% covered (danger)
0.00%
0 / 72
0.00% covered (danger)
0.00%
0 / 5
240
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 run
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 getUserImpact
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
110
 getParamSettings
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
2
 needsWriteAccess
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace GrowthExperiments\Rest\Handler;
4
5use Exception;
6use GrowthExperiments\UserImpact\ExpensiveUserImpact;
7use GrowthExperiments\UserImpact\RefreshUserImpactJob;
8use GrowthExperiments\UserImpact\UserImpactFormatter;
9use GrowthExperiments\UserImpact\UserImpactLookup;
10use GrowthExperiments\UserImpact\UserImpactStore;
11use JobQueueGroup;
12use MediaWiki\Deferred\DeferredUpdates;
13use MediaWiki\ParamValidator\TypeDef\UserDef;
14use MediaWiki\Rest\HttpException;
15use MediaWiki\Rest\SimpleHandler;
16use MediaWiki\User\UserFactory;
17use MediaWiki\User\UserIdentity;
18use Wikimedia\ParamValidator\ParamValidator;
19use Wikimedia\Stats\IBufferingStatsdDataFactory;
20
21/**
22 * Handler for POST and GET requests /growthexperiments/v0/user-impact/{user} endpoint.
23 * Returns data about the user's impact on the wiki. POST is preferred for facilitating
24 * writes to the database cache. Requests made with GET will use the job queue for
25 * persisting impact data to the cache, which will take longer.
26 */
27class UserImpactHandler extends SimpleHandler {
28
29    private UserImpactStore $userImpactStore;
30    private UserImpactLookup $userImpactLookup;
31    private UserImpactFormatter $userImpactFormatter;
32    private IBufferingStatsdDataFactory $statsdDataFactory;
33    private JobQueueGroup $jobQueueGroup;
34    private UserFactory $userFactory;
35
36    /**
37     * @param UserImpactStore $userImpactStore
38     * @param UserImpactLookup $userImpactLookup
39     * @param UserImpactFormatter $userImpactFormatter
40     * @param IBufferingStatsdDataFactory $statsdDataFactory
41     * @param JobQueueGroup $jobQueueGroup
42     * @param UserFactory $userFactory
43     */
44    public function __construct(
45        UserImpactStore $userImpactStore,
46        UserImpactLookup $userImpactLookup,
47        UserImpactFormatter $userImpactFormatter,
48        IBufferingStatsdDataFactory $statsdDataFactory,
49        JobQueueGroup $jobQueueGroup,
50        UserFactory $userFactory
51    ) {
52        $this->userImpactStore = $userImpactStore;
53        $this->userImpactLookup = $userImpactLookup;
54        $this->userImpactFormatter = $userImpactFormatter;
55        $this->statsdDataFactory = $statsdDataFactory;
56        $this->jobQueueGroup = $jobQueueGroup;
57        $this->userFactory = $userFactory;
58    }
59
60    /**
61     * @param UserIdentity $user
62     * @return array
63     * @throws HttpException
64     * @throws Exception
65     */
66    public function run( UserIdentity $user ) {
67        $start = microtime( true );
68        $userImpact = $this->getUserImpact( $user );
69        $validParams = $this->getValidatedParams();
70        $pageviewsUrlDisplayLanguageCode = 'en';
71        if ( $validParams[ 'lang' ] ) {
72            $pageviewsUrlDisplayLanguageCode = $validParams[ 'lang' ];
73        }
74        $formattedJsonData = $this->userImpactFormatter->format( $userImpact, $pageviewsUrlDisplayLanguageCode );
75        $this->statsdDataFactory->timing(
76            'timing.growthExperiments.UserImpactHandler.run', microtime( true ) - $start
77        );
78        return $formattedJsonData;
79    }
80
81    /**
82     * @param UserIdentity $user
83     * @return ExpensiveUserImpact
84     * @throws HttpException
85     */
86    private function getUserImpact( UserIdentity $user ): ExpensiveUserImpact {
87        if ( $this->getRequest()->getQueryParams()['regenerate'] ?? false ) {
88            $cachedUserImpact = null;
89        } else {
90            $cachedUserImpact = $this->userImpactStore->getExpensiveUserImpact( $user );
91        }
92        if ( $cachedUserImpact ) {
93            $this->statsdDataFactory->increment( 'GrowthExperiments.UserImpactHandler.Cache.Hit' );
94        }
95
96        if ( $cachedUserImpact && $cachedUserImpact->isPageViewDataStale() ) {
97            // Page view data is stale; we will attempt to recalculate it.
98            $userImpact = null;
99            $this->statsdDataFactory->increment( 'GrowthExperiments.UserImpactHandler.Cache.HitStalePageViewData' );
100        } else {
101            $userImpact = $cachedUserImpact;
102        }
103
104        if ( !$userImpact ) {
105            $this->statsdDataFactory->increment( 'GrowthExperiments.UserImpactHandler.Cache.Miss' );
106            // Rate limit check.
107            $performingUser = $this->userFactory->newFromUserIdentity( $this->getAuthority()->getUser() );
108            if ( $performingUser->pingLimiter( 'growthexperimentsuserimpacthandler' ) ) {
109                if ( $cachedUserImpact ) {
110                    $this->statsdDataFactory->increment(
111                        'GrowthExperiments.UserImpactHandler.PingLimiterTripped.StaleImpactData'
112                    );
113                    // Performing user is over the rate limit for requesting data for other users, but we have stale
114                    // data so just return that, rather than nothing.
115                    return $cachedUserImpact;
116                } else {
117                    $this->statsdDataFactory->increment(
118                        'GrowthExperiments.UserImpactHandler.PingLimiterTripped.NoData'
119                    );
120                    throw new HttpException( 'Too many requests to refresh user impact data', 429 );
121                }
122            }
123            $userImpact = $this->userImpactLookup->getExpensiveUserImpact( $user );
124            if ( !$userImpact ) {
125                throw new HttpException( 'Impact data not found for user', 404 );
126            }
127            // We want to write the updated data back to the cache table; doing that in a deferred update
128            // is preferable as we don't depend on job queue functioning quickly, but that isn't allowed
129            // on GET. So if a client is using a GET request, use the job queue, but otherwise (e.g. on
130            // Special:Homepage) we'll use a POST request and save using a deferred update.
131            if ( $this->getRequest()->getMethod() === 'GET' ) {
132                $this->jobQueueGroup->lazyPush(
133                    new RefreshUserImpactJob( [
134                        'impactDataBatch' => [ $user->getId() => json_encode( $userImpact ) ],
135                    ] )
136                );
137            } else {
138                DeferredUpdates::addCallableUpdate( function () use ( $userImpact ) {
139                    $this->userImpactStore->setUserImpact( $userImpact );
140                } );
141            }
142        }
143
144        return $userImpact;
145    }
146
147    /** @inheritDoc */
148    public function getParamSettings() {
149        return [
150            'user' => [
151                self::PARAM_SOURCE => 'path',
152                ParamValidator::PARAM_TYPE => 'user',
153                ParamValidator::PARAM_REQUIRED => true,
154                UserDef::PARAM_ALLOWED_USER_TYPES => [ 'id' ],
155                UserDef::PARAM_RETURN_OBJECT => true,
156            ],
157            'lang' => [
158                self::PARAM_SOURCE => 'query',
159                ParamValidator::PARAM_TYPE => 'string',
160                ParamValidator::PARAM_REQUIRED => false,
161            ],
162            'regenerate' => [
163                self::PARAM_SOURCE => 'query',
164                ParamValidator::PARAM_TYPE => 'boolean',
165                ParamValidator::PARAM_REQUIRED => false,
166            ],
167        ];
168    }
169
170    /** @inheritDoc */
171    public function needsWriteAccess() {
172        return true;
173    }
174
175}