Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
70.37% covered (warning)
70.37%
76 / 108
16.67% covered (danger)
16.67%
1 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiQueryReadingLists
70.37% covered (warning)
70.37%
76 / 108
16.67% covered (danger)
16.67%
1 / 6
48.39
0.00% covered (danger)
0.00%
0 / 1
 execute
80.88% covered (warning)
80.88%
55 / 68
0.00% covered (danger)
0.00%
0 / 1
24.08
 getAllowedParams
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
1
 getHelpUrls
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getExamplesMessages
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 isInternal
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getResultItem
28.57% covered (danger)
28.57%
2 / 7
0.00% covered (danger)
0.00%
0 / 1
6.28
1<?php
2
3namespace MediaWiki\Extension\ReadingLists\Api;
4
5use ApiQueryBase;
6use DateTime;
7use DateTimeZone;
8use LogicException;
9use MediaWiki\Extension\ReadingLists\Doc\ReadingListRow;
10use MediaWiki\Extension\ReadingLists\ReadingListRepositoryException;
11use MediaWiki\Extension\ReadingLists\Utils;
12use MediaWiki\Utils\MWTimestamp;
13use Wikimedia\ParamValidator\ParamValidator;
14
15/**
16 * API meta module for getting list metadata.
17 */
18class ApiQueryReadingLists extends ApiQueryBase {
19
20    use ApiTrait;
21    use ApiQueryTrait;
22
23    /** @var string API module prefix */
24    private static $prefix = 'rl';
25
26    /**
27     * @inheritDoc
28     */
29    public function execute() {
30        try {
31            if ( !$this->getUser()->isNamed() ) {
32                $this->dieWithError( [ 'apierror-mustbeloggedin',
33                    $this->msg( 'action-viewmyprivateinfo' ) ], 'notloggedin' );
34            }
35            $this->checkUserRightsAny( 'viewmyprivateinfo' );
36
37            $listId = $this->getParameter( 'list' );
38            $changedSince = $this->getParameter( 'changedsince' );
39            $project = $this->getParameter( 'project' );
40            $title = $this->getParameter( 'title' );
41            $sort = $this->getParameter( 'sort' );
42            $dir = $this->getParameter( 'dir' );
43            $limit = $this->getParameter( 'limit' );
44            $continue = $this->getParameter( 'continue' );
45
46            $path = [ 'query', $this->getModuleName() ];
47            $result = $this->getResult();
48            $result->addIndexedTagName( $path, 'list' );
49            $repository = $this->getReadingListRepository( $this->getUser() );
50
51            $mode = null;
52            $this->requireMaxOneParameter( $this->extractRequestParams(), 'list', 'title', 'changedsince' );
53            if ( $project !== null && $title !== null ) {
54                $mode = self::$MODE_PAGE;
55            } elseif ( $project !== null || $title !== null ) {
56                $errorMessage = $this->msg( 'apierror-readinglists-project-title-param', static::$prefix );
57                $this->dieWithError( $errorMessage, 'missingparam' );
58            } elseif ( $changedSince !== null ) {
59                $expiry = Utils::getDeletedExpiry();
60                if ( $changedSince < $expiry ) {
61                    $errorMessage = $this->msg( 'apierror-readinglists-too-old', static::$prefix,
62                        wfTimestamp( TS_ISO_8601, $expiry ) );
63                    $this->dieWithError( $errorMessage );
64                }
65                $mode = self::$MODE_CHANGES;
66            } elseif ( $listId !== null ) {
67                // FIXME 'dir' and 'limit' aren't compatible either but requireMaxOneParameter
68                // does not work with parameters which have a default value
69                $params = [ 'sort', 'continue' ];
70                foreach ( $params as $name ) {
71                    $this->requireMaxOneParameter( $this->extractRequestParams(), 'list', $name );
72                }
73                $mode = self::$MODE_ID;
74            } else {
75                $mode = self::$MODE_ALL;
76            }
77
78            if ( $sort === null ) {
79                $sort = ( $mode === self::$MODE_CHANGES ) ? 'updated' : 'name';
80            }
81            $sort = self::$sortParamMap[$sort];
82            $dir = self::$sortParamMap[$dir];
83            $continue = $this->decodeContinuationParameter( $continue, $mode, $sort );
84            // timestamp from before querying the DB
85            $timestamp = new DateTime( 'now', new DateTimeZone( 'GMT' ) );
86
87            if ( $mode === self::$MODE_PAGE ) {
88                $res = $repository->getListsByPage( $project, $title, $limit + 1, $continue );
89            } elseif ( $mode === self::$MODE_CHANGES ) {
90                $res = $repository->getListsByDateUpdated( $changedSince, $sort, $dir, $limit + 1, $continue );
91            } elseif ( $mode === self::$MODE_ID ) {
92                $res = [ $repository->selectValidList( $listId ) ];
93            } else {
94                $res = $repository->getAllLists( $sort, $dir, $limit + 1, $continue );
95            }
96            foreach ( $res as $i => $row ) {
97                $item = $this->getResultItem( $row, $mode );
98                if ( $i >= $limit ) {
99                    // we reached the extra row.
100                    $this->setContinueEnumParameter( 'continue',
101                        $this->encodeContinuationParameter( $item, $mode, $sort ) );
102                    break;
103                }
104                $fits = $result->addValue( $path, null, $item );
105                if ( !$fits ) {
106                    $this->setContinueEnumParameter( 'continue',
107                        $this->encodeContinuationParameter( $item, $mode, $sort ) );
108                    break;
109                }
110            }
111
112            // Add a timestamp that, when used in the changedsince parameter in the readinglists
113            // and readinglistentries modules, guarantees that no change will be skipped (at the
114            // cost of possibly repeating some changes in the current query). See T182706 for details.
115            if ( !$continue ) {
116                // Ignore continuations (the client should just use the timestamp received in the
117                // first step). Otherwise, backdate more than the max transaction duration, to
118                // prevent the situation where a DB write happens before the current request
119                // but is only committed after it. (If there is no max transaction duration, the
120                // client is on its own.)
121                $maxUserDBWriteDuration = $this->getConfig()->get( 'MaxUserDBWriteDuration' );
122                if ( $maxUserDBWriteDuration ) {
123                    $timestamp->modify( '-' . ( $maxUserDBWriteDuration + 1 ) . ' seconds' );
124                }
125                $syncTimestamp = ( new MWTimestamp( $timestamp ) )->getTimestamp( TS_ISO_8601 );
126                $result->addValue( 'query', 'readinglists-synctimestamp', $syncTimestamp );
127            }
128        } catch ( ReadingListRepositoryException $e ) {
129            $this->dieWithException( $e );
130        }
131    }
132
133    /**
134     * @inheritDoc
135     */
136    protected function getAllowedParams() {
137        return [
138            'list' => [
139                ParamValidator::PARAM_TYPE => 'integer',
140                ParamValidator::PARAM_REQUIRED => false,
141                self::PARAM_MIN => 1,
142                ParamValidator::PARAM_DEFAULT => null,
143            ],
144            'project' => [
145                ParamValidator::PARAM_TYPE => 'string',
146            ],
147            'title' => [
148                ParamValidator::PARAM_TYPE => 'string',
149            ],
150            'changedsince' => [
151                ParamValidator::PARAM_TYPE => 'timestamp',
152                self::PARAM_HELP_MSG => $this->msg( 'apihelp-query+readinglists-param-changedsince',
153                    wfTimestamp( TS_ISO_8601, Utils::getDeletedExpiry() ) ),
154            ],
155        ] + $this->getAllowedSortParams();
156    }
157
158    /**
159     * @inheritDoc
160     */
161    public function getHelpUrls() {
162        return [
163            'https://www.mediawiki.org/wiki/Special:MyLanguage/Extension:ReadingLists#API',
164        ];
165    }
166
167    /**
168     * @inheritDoc
169     */
170    protected function getExamplesMessages() {
171        $prefix = static::$prefix;
172        return [
173            'action=query&meta=readinglists'
174                => 'apihelp-query+readinglists-example-1',
175            "action=query&meta=readinglists&{$prefix}changedsince=2013-01-01T00:00:00Z"
176                => 'apihelp-query+readinglists-example-2',
177            "action=query&meta=readinglists&{$prefix}project=https%3A%2F%2Fen.wikipedia.org"
178                . "&{$prefix}title=Dog"
179                => 'apihelp-query+readinglists-example-3',
180        ];
181    }
182
183    /**
184     * @inheritDoc
185     */
186    public function isInternal() {
187        // ReadingLists API is still experimental
188        return true;
189    }
190
191    /**
192     * Transform a row into an API result item
193     * @param ReadingListRow $row List row, with additions from addExtraData().
194     * @param string $mode One of the MODE_* constants.
195     * @return array
196     */
197    private function getResultItem( $row, $mode ) {
198        if ( $row->rl_deleted && $mode !== self::$MODE_CHANGES ) {
199            $this->logger->error( 'Deleted row returned in non-changes mode', [
200                'rl_id' => $row->rl_id,
201                'user_central_id' => $row->rl_user_id,
202            ] );
203            throw new LogicException( 'Deleted row returned in non-changes mode' );
204        }
205        return $this->getListFromRow( $row );
206    }
207
208}