Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 69
0.00% covered (danger)
0.00%
0 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
EditHandler
0.00% covered (danger)
0.00%
0 / 69
0.00% covered (danger)
0.00%
0 / 8
380
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 5
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
 getTitleParameter
n/a
0 / 0
n/a
0 / 0
0
 validate
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 mapActionModuleResult
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
30
 throwHttpExceptionForActionModuleError
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
56
 mapActionModuleResponse
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 generateResponseSpec
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getHeaderParamSettings
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\Rest\Handler;
4
5use MediaWiki\Api\IApiMessage;
6use MediaWiki\Config\Config;
7use MediaWiki\Content\IContentHandlerFactory;
8use MediaWiki\MainConfigNames;
9use MediaWiki\Request\WebResponse;
10use MediaWiki\Rest\Handler;
11use MediaWiki\Rest\LocalizedHttpException;
12use MediaWiki\Rest\Response;
13use MediaWiki\Rest\TokenAwareHandlerTrait;
14use MediaWiki\Rest\Validator\Validator;
15use MediaWiki\Revision\RevisionLookup;
16use MediaWiki\Revision\SlotRecord;
17use MediaWiki\Title\TitleFormatter;
18use MediaWiki\Title\TitleParser;
19use RuntimeException;
20use Wikimedia\Message\MessageValue;
21use Wikimedia\ParamValidator\ParamValidator;
22
23/**
24 * Base class for REST API handlers that perform page edits (main slot only).
25 */
26abstract class EditHandler extends ActionModuleBasedHandler {
27    use TokenAwareHandlerTrait;
28
29    protected Config $config;
30    protected IContentHandlerFactory $contentHandlerFactory;
31    protected TitleParser $titleParser;
32    protected TitleFormatter $titleFormatter;
33    protected RevisionLookup $revisionLookup;
34
35    public function __construct(
36        Config $config,
37        IContentHandlerFactory $contentHandlerFactory,
38        TitleParser $titleParser,
39        TitleFormatter $titleFormatter,
40        RevisionLookup $revisionLookup
41    ) {
42        $this->config = $config;
43        $this->contentHandlerFactory = $contentHandlerFactory;
44        $this->titleParser = $titleParser;
45        $this->titleFormatter = $titleFormatter;
46        $this->revisionLookup = $revisionLookup;
47    }
48
49    /** @inheritDoc */
50    public function needsWriteAccess() {
51        return true;
52    }
53
54    /**
55     * Returns the requested title.
56     *
57     * @return string
58     */
59    abstract protected function getTitleParameter();
60
61    /**
62     * @inheritDoc
63     */
64    public function validate( Validator $restValidator ) {
65            parent::validate( $restValidator );
66            $this->validateToken( true );
67    }
68
69    /**
70     * @inheritDoc
71     * @phpcs:ignore Generic.Files.LineLength.TooLong
72     * @param array{error?:string,edit?:array{result:string,title:string,newrevid:int,pageid:int,newtimestamp:string,contentmodel:string}} $data
73     */
74    protected function mapActionModuleResult( array $data ) {
75        if ( isset( $data['error'] ) ) {
76            throw new LocalizedHttpException( new MessageValue( 'apierror-' . $data['error'] ), 400 );
77        }
78
79        if ( !isset( $data['edit'] ) || !$data['edit']['result'] ) {
80            throw new RuntimeException( 'Bad result structure received from ApiEditPage' );
81        }
82
83        if ( $data['edit']['result'] !== 'Success' ) {
84            // Probably an edit conflict
85            // TODO: which code for null edits?
86            throw new LocalizedHttpException(
87                new MessageValue( "rest-edit-conflict", [ $data['edit']['result'] ] ),
88                409
89            );
90        }
91
92        $title = $this->titleParser->parseTitle( $data['edit']['title'] );
93
94        // This seems wasteful. This is the downside of delegating to the action API module:
95        // if we need additional data in the response, we have to load it.
96        $revision = $this->revisionLookup->getRevisionById( (int)$data['edit']['newrevid'] );
97        $content = $revision->getContent( SlotRecord::MAIN );
98
99        return [
100            'id' => $data['edit']['pageid'],
101            'title' => $this->titleFormatter->getPrefixedText( $title ),
102            'key' => $this->titleFormatter->getPrefixedDBkey( $title ),
103            'latest' => [
104                'id' => $data['edit']['newrevid'],
105                'timestamp' => $data['edit']['newtimestamp'],
106            ],
107            'license' => [
108                'url' => $this->config->get( MainConfigNames::RightsUrl ),
109                'title' => $this->config->get( MainConfigNames::RightsText )
110            ],
111            'content_model' => $data['edit']['contentmodel'],
112            'source' => $content->serialize(),
113        ];
114    }
115
116    /**
117     * @inheritDoc
118     */
119    protected function throwHttpExceptionForActionModuleError( IApiMessage $msg, $statusCode = 400 ) {
120        $code = $msg->getApiCode();
121
122        if ( $code === 'protectedpage' ) {
123            throw new LocalizedHttpException( MessageValue::newFromSpecifier( $msg ), 403 );
124        }
125
126        if ( $code === 'badtoken' ) {
127            throw new LocalizedHttpException( MessageValue::newFromSpecifier( $msg ), 403 );
128        }
129
130        if ( $code === 'missingtitle' ) {
131            throw new LocalizedHttpException( MessageValue::newFromSpecifier( $msg ), 404 );
132        }
133
134        if ( $code === 'articleexists' ) {
135            throw new LocalizedHttpException( MessageValue::newFromSpecifier( $msg ), 409 );
136        }
137
138        if ( $code === 'editconflict' ) {
139            throw new LocalizedHttpException( MessageValue::newFromSpecifier( $msg ), 409 );
140        }
141
142        if ( $code === 'ratelimited' ) {
143            throw new LocalizedHttpException( MessageValue::newFromSpecifier( $msg ), 429 );
144        }
145
146        // Fall through to generic handling of the error (status 400).
147        parent::throwHttpExceptionForActionModuleError( $msg, $statusCode );
148    }
149
150    protected function mapActionModuleResponse(
151        WebResponse $actionModuleResponse,
152        array $actionModuleResult,
153        Response $response
154    ) {
155        parent::mapActionModuleResponse(
156            $actionModuleResponse,
157            $actionModuleResult,
158            $response
159        );
160
161        if ( $actionModuleResult['edit']['new'] ?? false ) {
162            $response->setStatus( 201 );
163        }
164    }
165
166    protected function generateResponseSpec( string $method ): array {
167        $spec = parent::generateResponseSpec( $method );
168
169        $spec['201'][parent::OPENAPI_DESCRIPTION_KEY] = 'Created';
170        $spec['201']['content']['application/json']['schema'] =
171            $spec['200']['content']['application/json']['schema'];
172
173        return $spec;
174    }
175
176    /**
177     * @inheritDoc
178     * @return array
179     */
180    public function getHeaderParamSettings(): array {
181        return [
182            'Content-Type' => [
183                self::PARAM_SOURCE => 'header',
184                ParamValidator::PARAM_TYPE => 'string',
185                // RFC 7231 ยง 3.1.1.5 allows no Content-Type header, but we require it in
186                // Handler::parseBodyData(), and return a 415 if it is not present.
187                ParamValidator::PARAM_REQUIRED => true,
188                Handler::PARAM_DESCRIPTION => new MessageValue( 'rest-requestheader-desc-contenttype' ),
189            ],
190        ];
191    }
192}