wikimedia/mediawiki-extensions-Wikibase

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

Summary

Maintainability
C
1 day
Test Coverage
<?php

declare( strict_types = 1 );

namespace Wikibase\Repo\Api;

use Deserializers\Exceptions\DeserializationException;
use MediaWiki\Api\ApiBase;
use MediaWiki\Api\ApiCreateTempUserTrait;
use MediaWiki\Api\ApiMain;
use Psr\Log\LoggerInterface;
use Wikibase\DataModel\Deserializers\DeserializerFactory;
use Wikibase\DataModel\Entity\EntityDocument;
use Wikibase\DataModel\Entity\EntityIdParser;
use Wikibase\DataModel\Reference;
use Wikibase\DataModel\ReferenceList;
use Wikibase\DataModel\Services\Statement\StatementGuidParser;
use Wikibase\DataModel\Services\Statement\StatementGuidValidator;
use Wikibase\DataModel\Snak\SnakList;
use Wikibase\DataModel\Statement\Statement;
use Wikibase\Lib\SettingsArray;
use Wikibase\Lib\Summary;
use Wikibase\Repo\ChangeOp\ChangeOp;
use Wikibase\Repo\ChangeOp\ChangeOpFactoryProvider;
use Wikibase\Repo\ChangeOp\StatementChangeOpFactory;
use Wikibase\Repo\SnakFactory;
use Wikimedia\ParamValidator\ParamValidator;

/**
 * API module for creating a reference or setting the value of an existing one.
 *
 * @license GPL-2.0-or-later
 * @author Jeroen De Dauw < jeroendedauw@gmail.com >
 * @author Tobias Gritschacher < tobias.gritschacher@wikimedia.de >
 */
class SetReference extends ApiBase {

    use FederatedPropertyApiValidatorTrait;
    use ApiCreateTempUserTrait;

    /**
     * @var StatementChangeOpFactory
     */
    private $statementChangeOpFactory;

    /**
     * @var ApiErrorReporter
     */
    protected $errorReporter;

    /**
     * @var DeserializerFactory
     */
    private $deserializerFactory;

    /**
     * @var StatementModificationHelper
     */
    private $modificationHelper;

    /**
     * @var StatementGuidParser
     */
    private $guidParser;

    /** @var LoggerInterface */
    private $logger;

    /**
     * @var ResultBuilder
     */
    private $resultBuilder;

    /**
     * @var EntitySavingHelper
     */
    private $entitySavingHelper;

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

    public function __construct(
        ApiMain $mainModule,
        string $moduleName,
        DeserializerFactory $deserializerFactory,
        ApiErrorReporter $errorReporter,
        StatementChangeOpFactory $statementChangeOpFactory,
        StatementModificationHelper $modificationHelper,
        StatementGuidParser $guidParser,
        LoggerInterface $logger,
        callable $resultBuilderInstantiator,
        callable $entitySavingHelperInstantiator,
        bool $federatedPropertiesEnabled,
        array $sandboxEntityIds
    ) {
        parent::__construct( $mainModule, $moduleName );

        $this->deserializerFactory = $deserializerFactory;
        $this->errorReporter = $errorReporter;
        $this->statementChangeOpFactory = $statementChangeOpFactory;
        $this->modificationHelper = $modificationHelper;
        $this->guidParser = $guidParser;
        $this->logger = $logger;
        $this->resultBuilder = $resultBuilderInstantiator( $this );
        $this->entitySavingHelper = $entitySavingHelperInstantiator( $this );
        $this->federatedPropertiesEnabled = $federatedPropertiesEnabled;
        $this->sandboxEntityIds = $sandboxEntityIds;
    }

    public static function factory(
        ApiMain $mainModule,
        string $moduleName,
        ApiHelperFactory $apiHelperFactory,
        DeserializerFactory $deserializerFactory,
        ChangeOpFactoryProvider $changeOpFactoryProvider,
        EntityIdParser $entityIdParser,
        LoggerInterface $logger,
        SettingsArray $repoSettings,
        SnakFactory $snakFactory,
        StatementGuidParser $statementGuidParser,
        StatementGuidValidator $statementGuidValidator
    ): self {
        $modificationHelper = new StatementModificationHelper(
            $snakFactory,
            $entityIdParser,
            $statementGuidValidator,
            $apiHelperFactory->getErrorReporter( $mainModule )
        );

        return new self(
            $mainModule,
            $moduleName,
            $deserializerFactory,
            $apiHelperFactory->getErrorReporter( $mainModule ),
            $changeOpFactoryProvider->getStatementChangeOpFactory(),
            $modificationHelper,
            $statementGuidParser,
            $logger,
            function ( $module ) use ( $apiHelperFactory ) {
                return $apiHelperFactory->getResultBuilder( $module );
            },
            function ( $module ) use ( $apiHelperFactory ) {
                return $apiHelperFactory->getEntitySavingHelper( $module );
            },
            $repoSettings->getSetting( 'federatedPropertiesEnabled' ),
            $repoSettings->getSetting( 'sandboxEntityIds' )
        );
    }

    /**
     * @inheritDoc
     */
    public function execute(): void {
        $params = $this->extractRequestParams();
        $this->validateParameters( $params );

        $entityId = $this->guidParser->parse( $params['statement'] )->getEntityId();
        $this->validateAlteringEntityById( $entityId );

        $entity = $this->entitySavingHelper->loadEntity( $params, $entityId );

        $summary = $this->modificationHelper->createSummary( $params, $this );

        $claim = $this->modificationHelper->getStatementFromEntity( $params['statement'], $entity );

        if ( isset( $params['reference'] ) ) {
            $this->validateReferenceHash( $claim, $params['reference'] );
        }

        if ( isset( $params['snaks-order' ] ) ) {
            $snaksOrder = $this->getArrayFromParam( $params['snaks-order'], 'snaks-order' );
        } else {
            $snaksOrder = [];
        }

        $deserializer = $this->deserializerFactory->newSnakListDeserializer();
        /** @var SnakList $snakList */
        try {
            $snakList = $deserializer->deserialize( $this->getArrayFromParam( $params['snaks'], 'snaks' ) );
        } catch ( DeserializationException $e ) {
            $this->errorReporter->dieError(
                'Failed to get reference from reference Serialization ' . $e->getMessage(),
                'snak-instantiation-failure'
            );
        }
        $snakList->orderByProperty( $snaksOrder );

        $newReference = new Reference( $snakList );
        $changeOp = $this->getChangeOp( $newReference );

        $newReference = $this->applyChangeOpAndReturnChangedReference(
            $changeOp, $entity, $summary, $claim, $newReference );

        $status = $this->entitySavingHelper->attemptSaveEntity( $entity, $summary, $params, $this->getContext() );
        $this->resultBuilder->addRevisionIdFromStatusToResult( $status, 'pageinfo' );
        $this->resultBuilder->markSuccess();
        $this->resultBuilder->addReference( $newReference );
        $this->resultBuilder->addTempUser( $status, fn( $user ) => $this->getTempUserRedirectUrl( $params, $user ) );
    }

    private function validateParameters( array $params ): void {
        if ( !( $this->modificationHelper->validateStatementGuid( $params['statement'] ) ) ) {
            $this->errorReporter->dieError( 'Invalid claim guid', 'invalid-guid' );
        }
    }

    private function validateReferenceHash( Statement $statement, string $referenceHash ): void {
        if ( !$statement->getReferences()->hasReferenceHash( $referenceHash ) ) {
            $this->errorReporter->dieError(
                'Statement does not have a reference with the given hash',
                'no-such-reference'
            );
        }
    }

    private function getArrayFromParam( string $arrayParam, string $parameter ): array {
        $rawArray = json_decode( $arrayParam, true );

        if ( !is_array( $rawArray ) || !count( $rawArray ) ) {
            $this->errorReporter->dieError(
                'No array or invalid JSON given for parameter: ' . $parameter,
                'invalid-json'
            );
        }

        // @phan-suppress-next-line PhanTypeMismatchReturnNullable null handled as !is_array
        return $rawArray;
    }

    private function getChangeOp( Reference $reference ): ChangeOp {
        $params = $this->extractRequestParams();

        $guid = $params['statement'];
        $hash = $params['reference'] ?? '';
        $index = $params['index'] ?? null;

        return $this->statementChangeOpFactory->newSetReferenceOp( $guid, $reference, $hash, $index );
    }

    /**
     * Apply $changeop to $entity (updating $summary) and return the Reference that was changed.
     * (Due to data value normalization in the ChangeOp factory,
     * this may not be the exact same reference as $newReference.)
     */
    private function applyChangeOpAndReturnChangedReference(
        ChangeOp $changeOp,
        EntityDocument $entity,
        Summary $summary,
        Statement $statement,
        Reference $newReference
    ): Reference {
        $oldReferences = clone $statement->getReferences();

        $this->modificationHelper->applyChangeOp( $changeOp, $entity, $summary );

        $changedReferences = [];
        foreach ( $statement->getReferences()->getIterator() as $reference ) {
            if ( !$oldReferences->hasReference( $reference ) ) {
                $changedReferences[] = $reference;
            }
        }

        switch ( count( $changedReferences ) ) {
            case 0:
                // no reference changed hash, return original $newReference
                // (could be a null edit, or its index or snaks-order could have changed)
                return $newReference;
            case 1:
                return $changedReferences[0];
            default:
                // this should never happen, but let’s warn instead of crashing
                $this->logger->warning( __METHOD__ . ': changed {count} references, expected 0-1', [
                    'count' => count( $changedReferences ),
                    'oldReferences' => $oldReferences->serialize(),
                    'newReferences' => $statement->getReferences()->serialize(),
                    'changedReferences' => ( new ReferenceList( $changedReferences ) )->serialize(),
                    'entityId' => $entity->getId()->getSerialization(),
                ] );
                return $newReference; // it’s the best we have
        }
    }

    /**
     * @inheritDoc
     */
    public function isWriteMode(): bool {
        return true;
    }

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

    /**
     * @inheritDoc
     */
    protected function getAllowedParams(): array {
        return array_merge(
            [
                'statement' => [
                    ParamValidator::PARAM_TYPE => 'string',
                    ParamValidator::PARAM_REQUIRED => true,
                ],
                'snaks' => [
                    ParamValidator::PARAM_TYPE => 'text',
                    ParamValidator::PARAM_REQUIRED => true,
                ],
                'snaks-order' => [
                    ParamValidator::PARAM_TYPE => 'string',
                ],
                'reference' => [
                    ParamValidator::PARAM_TYPE => 'string',
                ],
                'index' => [
                    ParamValidator::PARAM_TYPE => 'integer',
                ],
                'summary' => [
                    ParamValidator::PARAM_TYPE => 'string',
                ],
                'tags' => [
                    ParamValidator::PARAM_TYPE => 'tags',
                    ParamValidator::PARAM_ISMULTI => true,
                ],
                'token' => null,
                'baserevid' => [
                    ParamValidator::PARAM_TYPE => 'integer',
                ],
                'bot' => false,
            ],
            $this->getCreateTempUserParams(),
            parent::getAllowedParams()
        );
    }

    /**
     * @inheritDoc
     */
    protected function getExamplesMessages(): array {
        $guid = $this->sandboxEntityIds[ 'mainItem' ] . '$D4FDE516-F20C-4154-ADCE-7C5B609DFDFF';
        $hash = '1eb8793c002b1d9820c833d234a1b54c8e94187e';

        return [
            'action=wbsetreference&statement=' . $guid . '&snaks='
                . '{"P212":[{"snaktype":"value","property":"P212","datavalue":{"type":"string",'
                . '"value":"foo"}}]}&baserevid=7201010&token=foobar'
                => [ 'apihelp-wbsetreference-example-1', $guid ],
            'action=wbsetreference&statement=' . $guid . ''
                . '&reference=' . $hash . '&snaks='
                . '{"P212":[{"snaktype":"value","property":"P212","datavalue":{"type":"string",'
                . '"value":"bar"}}]}&baserevid=7201010&token=foobar'
                => [ 'apihelp-wbsetreference-example-2', $guid, $hash ],
            'action=wbsetreference&statement=' . $guid . '&snaks='
                . '{"P212":[{"snaktype":"novalue","property":"P212"}]}'
                . '&index=0&baserevid=7201010&token=foobar'
                => [ 'apihelp-wbsetreference-example-3', $guid ],
        ];
    }

}