Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.74% covered (success)
94.74%
126 / 133
72.73% covered (warning)
72.73%
8 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
UpdateHandler
94.74% covered (success)
94.74%
126 / 133
72.73% covered (warning)
72.73%
8 / 11
31.14
0.00% covered (danger)
0.00%
0 / 1
 getTitleParameter
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setJsonDiffFunction
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getParamSettings
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 getBodyParamSettings
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
1 / 1
1
 getRequestBodyDescription
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getActionModuleParameters
86.96% covered (warning)
86.96%
20 / 23
0.00% covered (danger)
0.00%
0 / 1
7.11
 mapActionModuleResult
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 throwHttpExceptionForActionModuleError
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 getConflictData
91.89% covered (success)
91.89%
34 / 37
0.00% covered (danger)
0.00%
0 / 1
11.06
 getDiff
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getResponseBodySchemaFileName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\Rest\Handler;
4
5use MediaWiki\Api\IApiMessage;
6use MediaWiki\Content\TextContent;
7use MediaWiki\Json\FormatJson;
8use MediaWiki\ParamValidator\TypeDef\ArrayDef;
9use MediaWiki\Rest\Handler;
10use MediaWiki\Rest\LocalizedHttpException;
11use MediaWiki\Revision\RevisionRecord;
12use MediaWiki\Revision\SlotRecord;
13use MediaWiki\Title\Title;
14use MediaWiki\Utils\MWTimestamp;
15use Wikimedia\Message\MessageValue;
16use Wikimedia\ParamValidator\ParamValidator;
17use Wikimedia\Timestamp\TimestampFormat as TS;
18
19/**
20 * Core REST API endpoint that handles page updates (main slot only)
21 */
22class UpdateHandler extends EditHandler {
23
24    /**
25     * @var callable
26     */
27    private $jsonDiffFunction;
28
29    /**
30     * @inheritDoc
31     */
32    protected function getTitleParameter() {
33        return $this->getValidatedParams()['title'];
34    }
35
36    /**
37     * Sets the function to use for JSON diffs, for testing.
38     */
39    public function setJsonDiffFunction( callable $jsonDiffFunction ) {
40        $this->jsonDiffFunction = $jsonDiffFunction;
41    }
42
43    /**
44     * @inheritDoc
45     */
46    public function getParamSettings() {
47        return [
48            'title' => [
49                self::PARAM_SOURCE => 'path',
50                ParamValidator::PARAM_TYPE => 'string',
51                ParamValidator::PARAM_REQUIRED => true,
52                self::PARAM_DESCRIPTION => new MessageValue( 'rest-param-desc-update-title' ),
53                self::PARAM_EXAMPLE => 'Wikipedia:Sandbox',
54            ],
55        ] + parent::getParamSettings();
56    }
57
58    /**
59     * @inheritDoc
60     */
61    public function getBodyParamSettings(): array {
62        return [
63            'source' => [
64                self::PARAM_SOURCE => 'body',
65                ParamValidator::PARAM_TYPE => 'string',
66                ParamValidator::PARAM_REQUIRED => true,
67                Handler::PARAM_DESCRIPTION => new MessageValue( 'rest-param-desc-source' ),
68                Handler::PARAM_EXAMPLE => 'Hello, world!',
69            ],
70            'comment' => [
71                self::PARAM_SOURCE => 'body',
72                ParamValidator::PARAM_TYPE => 'string',
73                ParamValidator::PARAM_REQUIRED => true,
74                Handler::PARAM_DESCRIPTION => new MessageValue( 'rest-param-desc-comment' ),
75                Handler::PARAM_EXAMPLE => 'Testing out the REST API',
76            ],
77            'content_model' => [
78                self::PARAM_SOURCE => 'body',
79                ParamValidator::PARAM_TYPE => 'string',
80                ParamValidator::PARAM_REQUIRED => false,
81                Handler::PARAM_DESCRIPTION => new MessageValue( 'rest-param-desc-contentmodel' ),
82                Handler::PARAM_EXAMPLE => 'wikitext',
83            ],
84            'latest' => [
85                self::PARAM_SOURCE => 'body',
86                ParamValidator::PARAM_TYPE => 'array',
87                ParamValidator::PARAM_REQUIRED => false,
88                ArrayDef::PARAM_SCHEMA => ArrayDef::makeObjectSchema(
89                    [ 'id' => 'integer' ],
90                    [ 'timestamp' => 'string' ], // from GET response, will be ignored
91                ),
92                Handler::PARAM_DESCRIPTION => new MessageValue( 'rest-param-desc-update-latest' ),
93                Handler::PARAM_EXAMPLE => [
94                    'id' => 1347490218,
95                    'timestamp' => '2026-04-07T01:20:41Z'
96                ],
97            ],
98        ] + $this->getTokenParamDefinition();
99    }
100
101    public function getRequestBodyDescription(): MessageValue|string|null {
102        return new MessageValue( 'rest-requestbody-desc-update-page' );
103    }
104
105    /**
106     * @inheritDoc
107     */
108    protected function getActionModuleParameters() {
109        $body = $this->getValidatedBodyArray();
110        $title = $this->getTitleParameter();
111        $baseRevId = $body['latest']['id'] ?? 0;
112
113        $contentmodel = $body['content_model'] ?: null;
114
115        if ( $contentmodel !== null && !$this->contentHandlerFactory->isDefinedModel( $contentmodel ) ) {
116            throw new LocalizedHttpException(
117                new MessageValue( 'rest-bad-content-model', [ $contentmodel ] ), 400
118            );
119        }
120
121        // Use a known good CSRF token if a token is not needed because we are
122        // using a method of authentication that protects against CSRF, like OAuth.
123        $token = $this->needsToken() ? $this->getToken() : $this->getUser()->getEditToken();
124
125        $params = [
126            'action' => 'edit',
127            'title' => $title,
128            'text' => $body['source'],
129            'summary' => $body['comment'],
130            'token' => $token
131        ];
132
133        if ( $contentmodel !== null ) {
134            $params['contentmodel'] = $contentmodel;
135        }
136
137        if ( $baseRevId > 0 ) {
138            $params['baserevid'] = $baseRevId;
139            $params['nocreate'] = true;
140        } else {
141            $params['createonly'] = true;
142        }
143
144        return $params;
145    }
146
147    /**
148     * @inheritDoc
149     */
150    protected function mapActionModuleResult( array $data ) {
151        if ( isset( $data['edit']['nochange'] ) ) {
152            // Null-edit, no new revision was created. The new revision is the same as the old.
153            // We may want to signal this more explicitly to the client in the future.
154
155            $title = $this->titleParser->parseTitle( $this->getValidatedParams()['title'] );
156            $title = Title::newFromLinkTarget( $title );
157            $currentRev = $this->revisionLookup->getRevisionByTitle( $title );
158
159            $data['edit']['newrevid'] = $currentRev->getId();
160            $data['edit']['newtimestamp']
161                = MWTimestamp::convert( TS::ISO_8601, $currentRev->getTimestamp() );
162        }
163
164        return parent::mapActionModuleResult( $data );
165    }
166
167    /**
168     * @inheritDoc
169     */
170    protected function throwHttpExceptionForActionModuleError( IApiMessage $msg, $statusCode = 400 ) {
171        $code = $msg->getApiCode();
172
173        // Provide a message instructing the client to provide the base revision ID for updates.
174        if ( $code === 'articleexists' ) {
175            $title = $this->getTitleParameter();
176            throw new LocalizedHttpException(
177                new MessageValue( 'rest-update-cannot-create-page', [ $title ] ),
178                409
179            );
180        }
181
182        if ( $code === 'editconflict' ) {
183            $data = $this->getConflictData();
184            throw new LocalizedHttpException( $msg, 409, $data );
185        }
186
187        parent::throwHttpExceptionForActionModuleError( $msg, $statusCode );
188    }
189
190    /**
191     * Returns an associative array to be used in the response in the event of edit conflicts.
192     *
193     * The resulting array contains the following keys:
194     * - base: revision ID of the base revision
195     * - current: revision ID of the latest revision (new base after resolving the conflict)
196     * - local: the difference between the content submitted and the base revision
197     * - remote: the difference between the latest revision of the page and the base revision
198     *
199     * If the differences cannot be determined, an empty array is returned.
200     *
201     * @return array
202     */
203    private function getConflictData() {
204        $body = $this->getValidatedBodyArray();
205        $baseRevId = $body['latest']['id'] ?? 0;
206        $title = $this->titleParser->parseTitle( $this->getValidatedParams()['title'] );
207
208        $baseRev = $this->revisionLookup->getRevisionById( $baseRevId );
209        $title = Title::newFromLinkTarget( $title );
210        $currentRev = $this->revisionLookup->getRevisionByTitle( $title );
211
212        if ( !$baseRev || !$currentRev ) {
213            return [];
214        }
215
216        $baseContent = $baseRev->getContent(
217            SlotRecord::MAIN,
218            RevisionRecord::FOR_THIS_USER,
219            $this->getAuthority()
220        );
221        $currentContent = $currentRev->getContent(
222            SlotRecord::MAIN,
223            RevisionRecord::FOR_THIS_USER,
224            $this->getAuthority()
225        );
226
227        if ( !$baseContent || !$currentContent ) {
228            return [];
229        }
230
231        $model = $body['content_model'] ?: $baseContent->getModel();
232        $contentHandler = $this->contentHandlerFactory->getContentHandler( $model );
233        $newContent = $contentHandler->unserializeContent( $body['source'] );
234
235        if ( !$baseContent instanceof TextContent
236            || !$currentContent instanceof TextContent
237            || !$newContent instanceof TextContent
238        ) {
239            return [];
240        }
241
242        $localDiff = $this->getDiff( $baseContent, $newContent );
243        $remoteDiff = $this->getDiff( $baseContent, $currentContent );
244
245        if ( !$localDiff || !$remoteDiff ) {
246            return [];
247        }
248
249        return [
250            'base' => $baseRev->getId(),
251            'current' => $currentRev->getId(),
252            'local' => $localDiff,
253            'remote' => $remoteDiff,
254        ];
255    }
256
257    /**
258     * Returns a text diff encoded as an array, to be included in the response data.
259     *
260     * @param TextContent $from
261     * @param TextContent $to
262     *
263     * @return array|null
264     */
265    private function getDiff( TextContent $from, TextContent $to ) {
266        if ( !is_callable( $this->jsonDiffFunction ) ) {
267            return null;
268        }
269
270        $json = ( $this->jsonDiffFunction )( $from->getText(), $to->getText(), 2 );
271        return FormatJson::decode( $json, true );
272    }
273
274    public function getResponseBodySchemaFileName( string $method ): ?string {
275        return __DIR__ . '/Schema/ExistingPageSource.json';
276    }
277}