wikimedia/mediawiki-extensions-Wikibase

View on GitHub
repo/includes/Api/EditEntity.php

Summary

Maintainability
D
2 days
Test Coverage
<?php

declare( strict_types = 1 );

namespace Wikibase\Repo\Api;

use MediaWiki\Api\ApiMain;
use MediaWiki\Api\ApiUsageException;
use MediaWiki\Title\Title;
use Serializers\Exceptions\SerializationException;
use Wikibase\DataModel\Entity\ClearableEntity;
use Wikibase\DataModel\Entity\EntityDocument;
use Wikibase\DataModel\Entity\EntityId;
use Wikibase\DataModel\Entity\EntityIdParser;
use Wikibase\DataModel\Entity\Property;
use Wikibase\Lib\DataTypeDefinitions;
use Wikibase\Lib\SettingsArray;
use Wikibase\Lib\Store\EntityRevisionLookup;
use Wikibase\Lib\Store\LookupConstants;
use Wikibase\Lib\Summary;
use Wikibase\Repo\ChangeOp\ChangedLanguagesCollector;
use Wikibase\Repo\ChangeOp\ChangedLanguagesCounter;
use Wikibase\Repo\ChangeOp\ChangeOp;
use Wikibase\Repo\ChangeOp\ChangeOpException;
use Wikibase\Repo\ChangeOp\ChangeOpResult;
use Wikibase\Repo\ChangeOp\Deserialization\ChangeOpDeserializationException;
use Wikibase\Repo\ChangeOp\EntityChangeOpProvider;
use Wikibase\Repo\ChangeOp\NonLanguageBoundChangesCounter;
use Wikibase\Repo\Store\Store;
use Wikimedia\ParamValidator\ParamValidator;
use Wikimedia\Stats\IBufferingStatsdDataFactory;

/**
 * Derived class for API modules modifying a single entity identified by id xor a combination of
 * site and page title.
 *
 * @license GPL-2.0-or-later
 */
class EditEntity extends ModifyEntity {

    public const PARAM_DATA = 'data';

    public const PARAM_CLEAR = 'clear';

    /**
     * @var IBufferingStatsdDataFactory
     */
    private $statsdDataFactory;

    /**
     * @var EntityRevisionLookup
     */
    private $revisionLookup;

    /**
     * @var EntityIdParser
     */
    private $idParser;

    /**
     * @var string[]
     */
    private $propertyDataTypes;

    /**
     * @var EntityChangeOpProvider
     */
    private $entityChangeOpProvider;

    /**
     * @var EditSummaryHelper
     */
    private $editSummaryHelper;

    /**
     * @var string[]
     */
    private $sandboxEntityIds;

    public function __construct(
        ApiMain $mainModule,
        string $moduleName,
        IBufferingStatsdDataFactory $statsdDataFactory,
        EntityRevisionLookup $revisionLookup,
        EntityIdParser $idParser,
        array $propertyDataTypes,
        EntityChangeOpProvider $entityChangeOpProvider,
        EditSummaryHelper $editSummaryHelper,
        bool $federatedPropertiesEnabled,
        array $sandboxEntityIds
    ) {
        parent::__construct( $mainModule, $moduleName, $federatedPropertiesEnabled );

        $this->statsdDataFactory = $statsdDataFactory;
        $this->revisionLookup = $revisionLookup;
        $this->idParser = $idParser;
        $this->propertyDataTypes = $propertyDataTypes;

        $this->entityChangeOpProvider = $entityChangeOpProvider;
        $this->editSummaryHelper = $editSummaryHelper;
        $this->sandboxEntityIds = $sandboxEntityIds;
    }

    public static function factory(
        ApiMain $mainModule,
        string $moduleName,
        IBufferingStatsdDataFactory $statsdDataFactory,
        DataTypeDefinitions $dataTypeDefinitions,
        EntityChangeOpProvider $entityChangeOpProvider,
        EntityIdParser $entityIdParser,
        SettingsArray $settings,
        Store $store
    ): self {
        return new self(
            $mainModule,
            $moduleName,
            $statsdDataFactory,
            $store->getEntityRevisionLookup( Store::LOOKUP_CACHING_DISABLED ),
            $entityIdParser,
            $dataTypeDefinitions->getTypeIds(),
            $entityChangeOpProvider,
            new EditSummaryHelper(
                new ChangedLanguagesCollector(),
                new ChangedLanguagesCounter(),
                new NonLanguageBoundChangesCounter()
            ),
            $settings->getSetting( 'federatedPropertiesEnabled' ),
            $settings->getSetting( 'sandboxEntityIds' )
        );
    }

    /**
     * @see ApiBase::needsToken
     *
     * @return string
     */
    public function needsToken(): string {
        return 'csrf';
    }

    /**
     * @see ApiBase::isWriteMode()
     *
     * @return bool Always true.
     */
    public function isWriteMode(): bool {
        return true;
    }

    /**
     * @param EntityId $entityId
     *
     * @return bool
     */
    private function entityExists( EntityId $entityId ): bool {
        $title = $this->getTitleLookup()->getTitleForId( $entityId );
        return ( $title !== null && $title->exists() );
    }

    protected function prepareParameters( array $params ): array {
        $this->validateDataParameter( $params );
        $params[self::PARAM_DATA] = json_decode( $params[self::PARAM_DATA], true );
        return parent::prepareParameters( $params );
    }

    protected function validateEntitySpecificParameters(
        array $preparedParameters,
        EntityDocument $entity,
        int $baseRevId
    ): void {
        $data = $preparedParameters[self::PARAM_DATA];
        $this->validateDataProperties( $data, $entity, $baseRevId );

        $exists = $this->entityExists( $entity->getId() );

        if ( $preparedParameters[self::PARAM_CLEAR] ) {
            if ( $preparedParameters['baserevid'] && $exists ) {
                $latestRevisionResult = $this->revisionLookup->getLatestRevisionId(
                    $entity->getId(),
                     LookupConstants::LATEST_FROM_MASTER
                );

                $returnFalse = function () {
                    return false;
                };
                $latestRevision = $latestRevisionResult->onConcreteRevision( function ( $revId ) {
                    return $revId;
                } )
                    ->onRedirect( $returnFalse )
                    ->onNonexistentEntity( $returnFalse )
                    ->map();

                if ( $baseRevId !== $latestRevision ) {
                    $this->errorReporter->dieError(
                        'Tried to clear entity using baserevid of entity not equal to current revision',
                        'editconflict'
                    );
                }
            }
        }

        // if we create a new property, make sure we set the datatype
        if ( !$exists && $entity instanceof Property ) {
            if ( !isset( $data['datatype'] )
                || !in_array( $data['datatype'], $this->propertyDataTypes )
            ) {
                $this->errorReporter->dieWithError(
                    'wikibase-api-not-recognized-datatype',
                    'param-illegal'
                );
            }
        }
    }

    protected function modifyEntity( EntityDocument $entity, ChangeOp $changeOp, array $preparedParameters ): Summary {
        $data = $preparedParameters[self::PARAM_DATA];

        $exists = $this->entityExists( $entity->getId() );

        if ( $preparedParameters[self::PARAM_CLEAR] ) {
            $this->dieIfNotClearable( $entity );
            $this->statsdDataFactory->increment( 'wikibase.api.EditEntity.modifyEntity.clear' );
        } else {
            $this->statsdDataFactory->increment( 'wikibase.api.EditEntity.modifyEntity.no-clear' );
        }

        if ( !$exists ) {
            // if we create a new property, make sure we set the datatype
            if ( $entity instanceof Property ) {
                $entity->setDataTypeId( $data['datatype'] );
            }

            $this->statsdDataFactory->increment( 'wikibase.api.EditEntity.modifyEntity.create' );
        }

        if ( $preparedParameters[self::PARAM_CLEAR] ) {
            $oldEntity = clone $entity;
            $entity->clear();

            // Validate it only by applying the changeOp on the current entity
            // instead of an empty one due avoid issues like T243158.
            // We are going to save the cleared entity instead,
            $changeOpResult = $this->applyChangeOp( $changeOp, $oldEntity );

            try {
                $changeOp->apply( $entity );
            } catch ( ChangeOpException $ex ) {
                $this->errorReporter->dieException( $ex, 'modification-failed' );
            }

        } else {
            $changeOpResult = $this->applyChangeOp( $changeOp, $entity );
        }

        try {
            $this->getResult()->addValue( null, 'entity',
                $this->getResultBuilder()->getModifiedEntityArray( $entity, 'all', null, [], [] ) );
        } catch ( SerializationException $e ) {
            $this->addWarning(
            'wikibase-editentity-warning-serializeresult',
                null,
                [ 'exceptionMessage' => $e->getMessage() ]
            );
        }

        return $this->getSummary( $preparedParameters, $entity, $changeOpResult );
    }

    private function getSummary(
        array $preparedParameters,
        EntityDocument $entity,
        ChangeOpResult $changeOpResult
    ): Summary {
        $summary = $this->createSummary( $preparedParameters );

        if ( $this->isUpdatingExistingEntity( $preparedParameters ) ) {
            if ( $preparedParameters[self::PARAM_CLEAR] !== false ) {
                $summary->setAction( 'override' );
            } else {
                $this->editSummaryHelper->prepareEditSummary( $summary, $changeOpResult );
            }
        } else {
            $summary->setAction( 'create-' . $entity->getType() );
        }

        return $summary;
    }

    private function isUpdatingExistingEntity( array $preparedParameters ): bool {
        $isTargetingEntity = isset( $preparedParameters['id'] );
        $isTargetingPage = isset( $preparedParameters['site'] ) && isset( $preparedParameters['title'] );

        return $isTargetingEntity xor $isTargetingPage;
    }

    /**
     * @param array $preparedParameters
     * @param EntityDocument $entity
     *
     * @throws ApiUsageException
     * @return ChangeOp
     */
    protected function getChangeOp( array $preparedParameters, EntityDocument $entity ): ChangeOp {
        $data = $preparedParameters[self::PARAM_DATA];

        if ( isset( $preparedParameters['id'] ) || $entity->getId() ) {
            $data['id'] = $preparedParameters['id'] ?? $entity->getId()->getSerialization();
        }

        try {
            return $this->entityChangeOpProvider->newEntityChangeOp( $entity->getType(), $data );
        } catch ( ChangeOpDeserializationException $exception ) {
            $this->errorReporter->dieException( $exception, $exception->getErrorCode() );
        }
    }

    private function validateDataParameter( array $params ): void {
        if ( !isset( $params[self::PARAM_DATA] ) ) {
            $this->errorReporter->dieError( 'No data to operate upon', 'no-data' );
        }
    }

    /**
     * @param mixed $data
     * @param EntityDocument $entity
     * @param int $revisionId
     */
    private function validateDataProperties( $data, EntityDocument $entity, int $revisionId ): void {
        $entityId = $entity->getId();
        $title = $entityId === null ? null : $this->getTitleLookup()->getTitleForId( $entityId );

        $this->checkValidJson( $data );
        $this->checkEntityId( $data, $entityId );
        $this->checkEntityType( $data, $entity );
        $this->checkPageIdProp( $data, $title );
        $this->checkNamespaceProp( $data, $title );
        $this->checkTitleProp( $data, $title );
        $this->checkRevisionProp( $data, $revisionId );
    }

    /**
     * @param mixed $data
     */
    private function checkValidJson( $data ): void {
        if ( $data === null ) {
            $this->errorReporter->dieError( 'Invalid json: The supplied JSON structure could not be parsed or '
                . 'recreated as a valid structure', 'invalid-json' );
        }

        // NOTE: json_decode will decode any JS literal or structure, not just objects!
        $this->assertArray( $data, 'Top level structure must be a JSON object' );

        foreach ( $data as $prop => $args ) {
            // Catch json_decode returning an indexed array (list).
            $this->assertString( $prop, 'Top level structure must be a JSON object (no keys found)' );

            if ( $prop === 'remove' ) {
                $this->errorReporter->dieWithError(
                    'wikibase-api-illegal-entity-remove',
                    'not-recognized'
                );
            }
        }
    }

    private function checkPageIdProp( array $data, ?Title $title ): void {
        if ( isset( $data['pageid'] )
            && ( $title === null || $title->getArticleID() !== $data['pageid'] )
        ) {
            $this->errorReporter->dieError(
                'Illegal field used in call, "pageid", must either be correct or not given',
                'param-illegal'
            );
        }
    }

    private function checkNamespaceProp( array $data, ?Title $title ): void {
        // not completely convinced that we can use title to get the namespace in this case
        if ( isset( $data['ns'] )
            && ( $title === null || $title->getNamespace() !== $data['ns'] )
        ) {
            $this->errorReporter->dieError(
                'Illegal field used in call: "namespace", must either be correct or not given',
                'param-illegal'
            );
        }
    }

    private function checkTitleProp( array $data, ?Title $title ): void {
        if ( isset( $data['title'] )
            && ( $title === null || $title->getPrefixedText() !== $data['title'] )
        ) {
            $this->errorReporter->dieError(
                'Illegal field used in call: "title", must either be correct or not given',
                'param-illegal'
            );
        }
    }

    private function checkRevisionProp( array $data, int $revisionId ): void {
        if ( isset( $data['lastrevid'] )
            && ( $revisionId !== $data['lastrevid'] )
        ) {
            $this->errorReporter->dieError(
                'Illegal field used in call: "lastrevid", must either be correct or not given',
                'param-illegal'
            );
        }
    }

    private function checkEntityId( array $data, ?EntityId $entityId ): void {
        if ( isset( $data['id'] ) ) {
            if ( !$entityId ) {
                $this->errorReporter->dieError(
                    'Illegal field used in call: "id", must not be given when creating a new entity',
                    'param-illegal'
                );
            }

            $dataId = $this->idParser->parse( $data['id'] );
            if ( !$entityId->equals( $dataId ) ) {
                $this->errorReporter->dieError(
                    'Invalid field used in call: "id", must match id parameter',
                    'param-invalid'
                );
            }
        }
    }

    private function checkEntityType( array $data, EntityDocument $entity ): void {
        if ( isset( $data['type'] )
            && $entity->getType() !== $data['type']
        ) {
            $this->errorReporter->dieError(
                'Invalid field used in call: "type", must match type associated with id',
                'param-invalid'
            );
        }
    }

    /**
     * @inheritDoc
     */
    protected function getAllowedParams(): array {
        return array_merge(
            parent::getAllowedParams(),
            [
                self::PARAM_DATA => [
                    ParamValidator::PARAM_TYPE => 'text',
                    ParamValidator::PARAM_REQUIRED => true,
                ],
                self::PARAM_CLEAR => [
                    ParamValidator::PARAM_TYPE => 'boolean',
                    ParamValidator::PARAM_DEFAULT => false,
                ],
            ]
        );
    }

    /**
     * @inheritDoc
     */
    protected function getExamplesMessages(): array {
        $id = $this->sandboxEntityIds[ 'mainItem' ];

        return [
            // Creating new entities
            'action=wbeditentity&new=item&data={}'
                => 'apihelp-wbeditentity-example-1',
            'action=wbeditentity&new=item&data={"labels":{'
                . '"de":{"language":"de","value":"de-value"},'
                . '"en":{"language":"en","value":"en-value"}}}'
                => 'apihelp-wbeditentity-example-2',
            'action=wbeditentity&new=property&data={'
                . '"labels":{"en-gb":{"language":"en-gb","value":"Propertylabel"}},'
                . '"descriptions":{"en-gb":{"language":"en-gb","value":"Propertydescription"}},'
                . '"datatype":"string"}'
                => 'apihelp-wbeditentity-example-3',
            // Clearing entities
            'action=wbeditentity&clear=true&id=' . $id . '&data={}'
                => [ 'apihelp-wbeditentity-example-4', $id ],
            'action=wbeditentity&clear=true&id=' . $id . '&data={'
                . '"labels":{"en":{"language":"en","value":"en-value"}}}'
                => [ 'apihelp-wbeditentity-example-5', $id ],
            // Adding term
            'action=wbeditentity&id=' . $id . '&data='
                . '{"labels":[{"language":"no","value":"Bar","add":""}]}'
                => 'apihelp-wbeditentity-example-11',
            // Removing term
            'action=wbeditentity&id=' . $id . '&data='
                . '{"labels":[{"language":"en","value":"Foo","remove":""}]}'
                => 'apihelp-wbeditentity-example-12',
            // Setting stuff
            'action=wbeditentity&id=' . $id . '&data={'
                . '"sitelinks":{"nowiki":{"site":"nowiki","title":"København"}}}'
                => 'apihelp-wbeditentity-example-6',
            'action=wbeditentity&id=' . $id . '&data={'
                . '"descriptions":{"nb":{"language":"nb","value":"nb-Description-Here"}}}'
                => 'apihelp-wbeditentity-example-7',
            'action=wbeditentity&id=' . $id . '&data={"claims":[{"mainsnak":{"snaktype":"value",'
                . '"property":"P56","datavalue":{"value":"ExampleString","type":"string"}},'
                . '"type":"statement","rank":"normal"}]}'
                => 'apihelp-wbeditentity-example-8',
            'action=wbeditentity&id=' . $id . '&data={"claims":['
                . '{"id":"' . $id . '$D8404CDA-25E4-4334-AF13-A3290BCD9C0F","remove":""},'
                . '{"id":"' . $id . '$GH678DSA-01PQ-28XC-HJ90-DDFD9990126X","remove":""}]}'
                => 'apihelp-wbeditentity-example-9',
            'action=wbeditentity&id=' . $id . '&data={"claims":[{'
                . '"id":"' . $id . '$GH678DSA-01PQ-28XC-HJ90-DDFD9990126X","mainsnak":{"snaktype":"value",'
                . '"property":"P56","datavalue":{"value":"ChangedString","type":"string"}},'
                . '"type":"statement","rank":"normal"}]}'
                => 'apihelp-wbeditentity-example-10',
        ];
    }

    /**
     * @param mixed $value
     * @param string $message
     */
    private function assertArray( $value, string $message ): void {
        $this->assertType( 'array', $value, $message );
    }

    /**
     * @param mixed $value
     * @param string $message
     */
    private function assertString( $value, string $message ): void {
        $this->assertType( 'string', $value, $message );
    }

    /**
     * @param string $type
     * @param mixed $value
     * @param string $message
     */
    private function assertType( string $type, $value, string $message ): void {
        if ( gettype( $value ) !== $type ) {
            $this->errorReporter->dieError( $message, 'not-recognized-' . $type );
        }
    }

    private function dieIfNotClearable( EntityDocument $entity ): void {
        if ( !( $entity instanceof ClearableEntity ) ) {
            $this->errorReporter->dieError(
                'Cannot clear an entity of type ' . $entity->getType(),
                'param-illegal'
            );
        }
    }

}