Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
72.19% covered (warning)
72.19%
109 / 151
71.43% covered (warning)
71.43%
10 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
AddForm
72.19% covered (warning)
72.19%
109 / 151
71.43% covered (warning)
71.43%
10 / 14
38.45
0.00% covered (danger)
0.00%
0 / 1
 factory
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
1
 __construct
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 execute
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
5
 getAllowedParams
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
1
 isWriteMode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isInternal
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 needsToken
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 mustBePosted
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getExamplesMessages
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
2
 getFormWithMaxId
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getBaseLexemeRevisionFromRequest
55.56% covered (warning)
55.56%
10 / 18
0.00% covered (danger)
0.00%
0 / 1
5.40
 buildSaveFlags
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
4.13
 saveNewLexemeRevision
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
2
 fillApiResultFromStatus
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare( strict_types = 1 );
4
5namespace Wikibase\Lexeme\MediaWiki\Api;
6
7use LogicException;
8use MediaWiki\Api\ApiBase;
9use MediaWiki\Api\ApiCreateTempUserTrait;
10use MediaWiki\Api\ApiMain;
11use MediaWiki\Api\ApiUsageException;
12use MediaWiki\Message\Message;
13use RuntimeException;
14use Wikibase\DataModel\Entity\EntityDocument;
15use Wikibase\DataModel\Entity\EntityIdParser;
16use Wikibase\DataModel\Serializers\SerializerFactory;
17use Wikibase\Lexeme\Domain\Model\Exceptions\ConflictException;
18use Wikibase\Lexeme\Domain\Model\Form;
19use Wikibase\Lexeme\Domain\Model\FormId;
20use Wikibase\Lexeme\Domain\Model\Lexeme;
21use Wikibase\Lexeme\MediaWiki\Api\Error\LexemeNotFound;
22use Wikibase\Lexeme\Serialization\FormSerializer;
23use Wikibase\Lexeme\WikibaseLexemeServices;
24use Wikibase\Lib\FormatableSummary;
25use Wikibase\Lib\Store\EntityRevision;
26use Wikibase\Lib\Store\EntityRevisionLookup;
27use Wikibase\Lib\Store\LookupConstants;
28use Wikibase\Lib\Store\StorageException;
29use Wikibase\Lib\Summary;
30use Wikibase\Repo\Api\ApiErrorReporter;
31use Wikibase\Repo\Api\ApiHelperFactory;
32use Wikibase\Repo\Api\ResultBuilder;
33use Wikibase\Repo\ChangeOp\ChangeOpException;
34use Wikibase\Repo\EditEntity\EditEntityStatus;
35use Wikibase\Repo\EditEntity\MediaWikiEditEntityFactory;
36use Wikibase\Repo\Store\Store;
37use Wikibase\Repo\SummaryFormatter;
38use Wikimedia\ParamValidator\ParamValidator;
39
40/**
41 * @license GPL-2.0-or-later
42 */
43class AddForm extends ApiBase {
44
45    use ApiCreateTempUserTrait;
46
47    private const LATEST_REVISION = 0;
48
49    private AddFormRequestParser $requestParser;
50    private ResultBuilder $resultBuilder;
51    private ApiErrorReporter $errorReporter;
52    private FormSerializer $formSerializer;
53    private MediaWikiEditEntityFactory $editEntityFactory;
54    private SummaryFormatter $summaryFormatter;
55    private EntityRevisionLookup $entityRevisionLookup;
56
57    public static function factory(
58        ApiMain $mainModule,
59        string $moduleName,
60        ApiHelperFactory $apiHelperFactory,
61        SerializerFactory $baseDataModelSerializerFactory,
62        MediaWikiEditEntityFactory $editEntityFactory,
63        EntityIdParser $entityIdParser,
64        Store $store,
65        SummaryFormatter $summaryFormatter
66    ): self {
67        $formSerializer = new FormSerializer(
68            $baseDataModelSerializerFactory->newTermListSerializer(),
69            $baseDataModelSerializerFactory->newStatementListSerializer()
70        );
71
72        return new self(
73            $mainModule,
74            $moduleName,
75            new AddFormRequestParser(
76                $entityIdParser,
77                WikibaseLexemeServices::getEditFormChangeOpDeserializer()
78            ),
79            $formSerializer,
80            $store->getEntityRevisionLookup( Store::LOOKUP_CACHING_DISABLED ),
81            $editEntityFactory,
82            $summaryFormatter,
83            $apiHelperFactory
84        );
85    }
86
87    public function __construct(
88        ApiMain $mainModule,
89        string $moduleName,
90        AddFormRequestParser $requestParser,
91        FormSerializer $formSerializer,
92        EntityRevisionLookup $entityRevisionLookup,
93        MediaWikiEditEntityFactory $editEntityFactory,
94        SummaryFormatter $summaryFormatter,
95        ApiHelperFactory $apiHelperFactory
96    ) {
97        parent::__construct( $mainModule, $moduleName );
98
99        $this->resultBuilder = $apiHelperFactory->getResultBuilder( $this );
100        $this->errorReporter = $apiHelperFactory->getErrorReporter( $this );
101        $this->requestParser = $requestParser;
102        $this->formSerializer = $formSerializer;
103        $this->editEntityFactory = $editEntityFactory;
104        $this->entityRevisionLookup = $entityRevisionLookup;
105        $this->summaryFormatter = $summaryFormatter;
106    }
107
108    /**
109     * @see ApiBase::execute()
110     *
111     * @throws ApiUsageException
112     */
113    public function execute(): void {
114        /*
115         * {
116              "representations": [
117                "en-gb": {
118                  "value": "colour",
119                  "language": "en-gb"
120                },
121                "en-us": {
122                  "value": "color",
123                  "language": "en-us"
124                }
125              ],
126              "grammaticalFeatures": [
127                "Q1",
128                "Q2"
129              ]
130            }
131         *
132         */
133
134        //FIXME: Representation text normalization
135
136        //TODO: Documenting response structure. Is it possible?
137
138        $params = $this->extractRequestParams();
139        $request = $this->requestParser->parse( $params );
140
141        $lexemeRevision = $this->getBaseLexemeRevisionFromRequest( $request );
142        /** @var Lexeme $lexeme */
143        $lexeme = $lexemeRevision->getEntity();
144        $changeOp = $request->getChangeOp();
145
146        $summary = new Summary();
147        try {
148            $changeOp->apply( $lexeme, $summary );
149        } catch ( ChangeOpException $exception ) {
150            $this->errorReporter->dieException( $exception, 'unprocessable-request' );
151        }
152
153        $baseRevId = $request->getBaseRevId() ?: $lexemeRevision->getRevisionId();
154
155        $flags = $this->buildSaveFlags( $params );
156        $status = $this->saveNewLexemeRevision( $lexeme, $baseRevId, $summary, $flags, $params['tags'] ?: [] );
157
158        if ( !$status->isGood() ) {
159            $this->dieStatus( $status );
160        }
161
162        $this->fillApiResultFromStatus( $status, $params );
163    }
164
165    protected function getAllowedParams(): array {
166        return array_merge( [
167            AddFormRequestParser::PARAM_LEXEME_ID => [
168                ParamValidator::PARAM_TYPE => 'string',
169                ParamValidator::PARAM_REQUIRED => true,
170            ],
171            AddFormRequestParser::PARAM_DATA => [
172                ParamValidator::PARAM_TYPE => 'text',
173                ParamValidator::PARAM_REQUIRED => true,
174            ],
175            AddFormRequestParser::PARAM_BASEREVID => [
176                ParamValidator::PARAM_TYPE => 'integer',
177            ],
178            'tags' => [
179                ParamValidator::PARAM_TYPE => 'tags',
180                ParamValidator::PARAM_ISMULTI => true,
181            ],
182            'bot' => [
183                ParamValidator::PARAM_TYPE => 'boolean',
184                ParamValidator::PARAM_DEFAULT => false,
185            ],
186        ], $this->getCreateTempUserParams() );
187    }
188
189    public function isWriteMode(): bool {
190        return true;
191    }
192
193    /**
194     * As long as this codebase is in development and APIs might change any time without notice, we
195     * mark all as internal. This adds an "unstable" notice, but does not hide them in any way.
196     */
197    public function isInternal(): bool {
198        return true;
199    }
200
201    public function needsToken(): string {
202        return 'csrf';
203    }
204
205    public function mustBePosted(): bool {
206        return true;
207    }
208
209    protected function getExamplesMessages(): array {
210        $lexemeId = 'L12';
211        $exampleData = [
212            'representations' => [
213                'en-us' => [ 'value' => 'color', 'language' => 'en-us' ],
214                'en-gb' => [ 'value' => 'colour', 'language' => 'en-gb' ],
215            ],
216            'grammaticalFeatures' => [
217                'Q1', 'Q2',
218            ],
219        ];
220
221        $query = http_build_query( [
222            'action' => $this->getModuleName(),
223            AddFormRequestParser::PARAM_LEXEME_ID => $lexemeId,
224            AddFormRequestParser::PARAM_DATA => json_encode( $exampleData ),
225        ] );
226
227        $languages = array_column( $exampleData['representations'], 'language' );
228        $representations = array_column( $exampleData['representations'], 'value' );
229
230        $representationsText = $this->getLanguage()->commaList( $representations );
231        $languagesText = $this->getLanguage()->commaList( $languages );
232        $grammaticalFeaturesText = $this->getLanguage()->commaList( $exampleData['grammaticalFeatures'] );
233
234        $exampleMessage = new Message(
235            'apihelp-wbladdform-example-1',
236            [
237                $lexemeId,
238                $representationsText,
239                $languagesText,
240                $grammaticalFeaturesText,
241            ]
242        );
243
244        return [
245            urldecode( $query ) => $exampleMessage,
246        ];
247    }
248
249    private function getFormWithMaxId( Lexeme $lexeme ): Form {
250        // TODO: This is all rather nasty
251        $maxIdNumber = $lexeme->getForms()->maxFormIdNumber();
252        // TODO: Use some service to get the ID object!
253        $formId = new FormId( $lexeme->getId() . '-F' . $maxIdNumber );
254        return $lexeme->getForm( $formId );
255    }
256
257    /**
258     * @throws ApiUsageException
259     */
260    private function getBaseLexemeRevisionFromRequest( AddFormRequest $request ): EntityRevision {
261        $lexemeId = $request->getLexemeId();
262        try {
263            $lexemeRevision = $this->entityRevisionLookup->getEntityRevision(
264                $lexemeId,
265                self::LATEST_REVISION,
266                LookupConstants::LATEST_FROM_MASTER
267            );
268        } catch ( StorageException $e ) {
269            // TODO Test it
270            if ( $e->getStatus() ) {
271                $this->dieStatus( $e->getStatus() );
272            } else {
273                throw new LogicException(
274                    'StorageException caught with no status',
275                    0,
276                    $e
277                );
278            }
279        }
280
281        if ( !$lexemeRevision ) {
282            $error = new LexemeNotFound( $lexemeId );
283            $this->dieWithError( $error->asApiMessage( AddFormRequestParser::PARAM_LEXEME_ID, [] ) );
284        }
285
286        // @phan-suppress-next-line PhanTypeMismatchReturnNullable
287        return $lexemeRevision;
288    }
289
290    private function buildSaveFlags( array $params ): int {
291        $flags = EDIT_UPDATE;
292        if ( isset( $params['bot'] ) && $params['bot'] &&
293            $this->getPermissionManager()->userHasRight( $this->getUser(), 'bot' )
294        ) {
295            $flags |= EDIT_FORCE_BOT;
296        }
297        return $flags;
298    }
299
300    private function saveNewLexemeRevision(
301        EntityDocument $lexeme,
302        int $baseRevId,
303        FormatableSummary $summary,
304        int $flags,
305        array $tags
306    ): EditEntityStatus {
307        $editEntity = $this->editEntityFactory->newEditEntity(
308            $this->getContext(),
309            $lexeme->getId(),
310            $baseRevId
311        );
312        $summaryString = $this->summaryFormatter->formatSummary(
313            $summary
314        );
315
316        $tokenThatDoesNotNeedChecking = false;
317        try {
318            $status = $editEntity->attemptSave(
319                $lexeme,
320                $summaryString,
321                $flags,
322                $tokenThatDoesNotNeedChecking,
323                null,
324                $tags
325            );
326        } catch ( ConflictException $exception ) {
327            $this->dieWithException( new RuntimeException( 'Edit conflict: ' . $exception->getMessage() ) );
328        }
329
330        return $status;
331    }
332
333    private function fillApiResultFromStatus( EditEntityStatus $status, array $params ): void {
334        $entityRevision = $status->getRevision();
335
336        /** @var Lexeme $editedLexeme */
337        $editedLexeme = $entityRevision->getEntity();
338        '@phan-var Lexeme $editedLexeme';
339        $newForm = $this->getFormWithMaxId( $editedLexeme );
340        $serializedForm = $this->formSerializer->serialize( $newForm );
341
342        $this->resultBuilder->addRevisionIdFromStatusToResult( $status, null );
343        $this->resultBuilder->markSuccess();
344        $this->getResult()->addValue( null, 'form', $serializedForm );
345        $this->resultBuilder->addTempUser( $status, fn ( $user ) => $this->getTempUserRedirectUrl( $params, $user ) );
346    }
347
348}