wikimedia/mediawiki-extensions-Wikibase

View on GitHub
repo/rest-api/src/Application/UseCases/PatchItem/PatchedItemValidator.php

Summary

Maintainability
D
3 days
Test Coverage
<?php declare( strict_types=1 );

namespace Wikibase\Repo\RestApi\Application\UseCases\PatchItem;

use LogicException;
use Wikibase\DataModel\Entity\Item;
use Wikibase\DataModel\Entity\ItemId;
use Wikibase\DataModel\Statement\Statement;
use Wikibase\DataModel\Statement\StatementList;
use Wikibase\DataModel\Term\Fingerprint;
use Wikibase\DataModel\Term\Term;
use Wikibase\DataModel\Term\TermList;
use Wikibase\Repo\RestApi\Application\UseCaseRequestValidation\Utils;
use Wikibase\Repo\RestApi\Application\UseCases\UseCaseError;
use Wikibase\Repo\RestApi\Application\Validation\AliasesInLanguageValidator;
use Wikibase\Repo\RestApi\Application\Validation\AliasesValidator;
use Wikibase\Repo\RestApi\Application\Validation\DescriptionsSyntaxValidator;
use Wikibase\Repo\RestApi\Application\Validation\ItemDescriptionsContentsValidator;
use Wikibase\Repo\RestApi\Application\Validation\ItemDescriptionValidator;
use Wikibase\Repo\RestApi\Application\Validation\ItemLabelsContentsValidator;
use Wikibase\Repo\RestApi\Application\Validation\ItemLabelValidator;
use Wikibase\Repo\RestApi\Application\Validation\LabelsSyntaxValidator;
use Wikibase\Repo\RestApi\Application\Validation\LanguageCodeValidator;
use Wikibase\Repo\RestApi\Application\Validation\SiteIdValidator;
use Wikibase\Repo\RestApi\Application\Validation\SitelinksValidator;
use Wikibase\Repo\RestApi\Application\Validation\SitelinkValidator;
use Wikibase\Repo\RestApi\Application\Validation\StatementsValidator;
use Wikibase\Repo\RestApi\Application\Validation\StatementValidator;
use Wikibase\Repo\RestApi\Application\Validation\ValidationError;
use Wikibase\Repo\RestApi\Domain\ReadModel\Item as ItemReadModel;
use Wikibase\Repo\RestApi\Domain\ReadModel\Sitelinks;

// disable because it forces comments for switch-cases that look like fall-throughs but aren't
// phpcs:disable PSR2.ControlStructures.SwitchDeclaration.TerminatingComment

/**
 * @license GPL-2.0-or-later
 */
class PatchedItemValidator {

    private LabelsSyntaxValidator $labelsSyntaxValidator;
    private ItemLabelsContentsValidator $labelsContentsValidator;
    private DescriptionsSyntaxValidator $descriptionsSyntaxValidator;
    private ItemDescriptionsContentsValidator $descriptionsContentsValidator;
    private AliasesValidator $aliasesValidator;
    private SitelinksValidator $sitelinksValidator;
    private StatementsValidator $statementsValidator;

    public function __construct(
        LabelsSyntaxValidator $labelsSyntaxValidator,
        ItemLabelsContentsValidator $labelsContentsValidator,
        DescriptionsSyntaxValidator $descriptionsSyntaxValidator,
        ItemDescriptionsContentsValidator $descriptionsContentsValidator,
        AliasesValidator $aliasesValidator,
        SitelinksValidator $sitelinksValidator,
        StatementsValidator $statementsValidator
    ) {
        $this->labelsSyntaxValidator = $labelsSyntaxValidator;
        $this->labelsContentsValidator = $labelsContentsValidator;
        $this->descriptionsSyntaxValidator = $descriptionsSyntaxValidator;
        $this->descriptionsContentsValidator = $descriptionsContentsValidator;
        $this->aliasesValidator = $aliasesValidator;
        $this->sitelinksValidator = $sitelinksValidator;
        $this->statementsValidator = $statementsValidator;
    }

    /**
     * @throws UseCaseError
     */
    public function validateAndDeserialize(
        ItemReadModel $item,
        array $serialization,
        Item $originalItem,
        array $originalSerialization
    ): Item {
        if ( !isset( $serialization[ 'id' ] ) ) { // ignore ID removal
            $serialization[ 'id' ] = $originalItem->getId()->getSerialization();
        }

        $this->assertNoIllegalModification( $serialization, $originalItem );
        $this->assertValidFields( $serialization );
        $this->assertValidLabelsAndDescriptions( $serialization, $originalItem );
        $this->assertValidAliases( $serialization );
        $this->assertValidSitelinks( $item, $serialization );
        $this->assertValidStatements( $serialization, $originalItem, $originalSerialization['statements'] );

        return new Item(
            new ItemId( $serialization[ 'id' ] ),
            new Fingerprint(
                $this->labelsContentsValidator->getValidatedLabels(),
                $this->descriptionsContentsValidator->getValidatedDescriptions(),
                $this->aliasesValidator->getValidatedAliases()
            ),
            $this->sitelinksValidator->getValidatedSitelinks(),
            $this->statementsValidator->getValidatedStatements()
        );
    }

    private function assertNoIllegalModification( array $serialization, Item $originalItem ): void {
        if ( $serialization[ 'id' ] !== $originalItem->getId()->getSerialization() ) {
            throw UseCaseError::newPatchResultModifiedReadOnlyValue( '/id' );
        }
    }

    private function assertValidFields( array $serialization ): void {
        // 'id' is not modifiable and 'type' is ignored, so we only check the expected array fields
        foreach ( [ 'labels', 'descriptions', 'aliases', 'sitelinks', 'statements' ] as $field ) {
            if ( isset( $serialization[$field] ) && !is_array( $serialization[$field] ) ) {
                throw UseCaseError::newPatchResultInvalidValue( "/$field", $serialization[$field] );
            }
        }
    }

    private function assertValidLabelsAndDescriptions( array $serialization, Item $originalItem ): void {
        $labels = $serialization['labels'] ?? [];
        $descriptions = $serialization['descriptions'] ?? [];
        $validationError = $this->labelsSyntaxValidator->validate( $labels, '/labels' ) ??
                           $this->descriptionsSyntaxValidator->validate( $descriptions, '/descriptions' ) ??
                           $this->labelsContentsValidator->validate(
                               $this->labelsSyntaxValidator->getPartiallyValidatedLabels(),
                               $this->descriptionsSyntaxValidator->getPartiallyValidatedDescriptions(),
                               $this->getModifiedLanguages(
                                   $originalItem->getLabels(),
                                   $this->labelsSyntaxValidator->getPartiallyValidatedLabels()
                               )
                           ) ??
                           $this->descriptionsContentsValidator->validate(
                               $this->descriptionsSyntaxValidator->getPartiallyValidatedDescriptions(),
                               $this->labelsSyntaxValidator->getPartiallyValidatedLabels(),
                               $this->getModifiedLanguages(
                                   $originalItem->getDescriptions(),
                                   $this->descriptionsSyntaxValidator->getPartiallyValidatedDescriptions()
                               )
                           );

        if ( $validationError ) {
            $this->handleLanguageCodeValidationError( $validationError );
            $this->handleLabelsValidationError( $validationError );
            $this->handleDescriptionsValidationError( $validationError );
            throw new LogicException( "Unknown validation error: {$validationError->getCode()}" );
        }
    }

    private function handleLanguageCodeValidationError( ValidationError $validationError ): void {
        if ( $validationError->getCode() !== LanguageCodeValidator::CODE_INVALID_LANGUAGE_CODE ) {
            return;
        }

        $context = $validationError->getContext();
            throw UseCaseError::newPatchResultInvalidKey(
                $context[LanguageCodeValidator::CONTEXT_PATH],
                $context[LanguageCodeValidator::CONTEXT_LANGUAGE_CODE]
            );
    }

    private function handleLabelsValidationError( ValidationError $validationError ): void {
        $context = $validationError->getContext();

        switch ( $validationError->getCode() ) {
            case LabelsSyntaxValidator::CODE_LABELS_NOT_ASSOCIATIVE:
                throw UseCaseError::newPatchResultInvalidValue( '/labels', $context[ LabelsSyntaxValidator::CONTEXT_VALUE ] );
            case LabelsSyntaxValidator::CODE_EMPTY_LABEL:
                $languageCode = $context[LabelsSyntaxValidator::CONTEXT_LANGUAGE];
                throw UseCaseError::newPatchResultInvalidValue( "/labels/$languageCode", '' );
            case LabelsSyntaxValidator::CODE_INVALID_LABEL_TYPE:
                $language = $context[LabelsSyntaxValidator::CONTEXT_LANGUAGE];
                $value = $context[LabelsSyntaxValidator::CONTEXT_LABEL];
                throw UseCaseError::newPatchResultInvalidValue( "/labels/$language", $value );
            case ItemLabelValidator::CODE_INVALID:
                $language = $context[ItemLabelValidator::CONTEXT_LANGUAGE];
                $value = $context[ItemLabelValidator::CONTEXT_LABEL];
                throw UseCaseError::newPatchResultInvalidValue( "/labels/$language", $value );
            case ItemLabelValidator::CODE_TOO_LONG:
                $maxLabelLength = $context[ItemLabelValidator::CONTEXT_LIMIT];
                $language = $context[ItemLabelValidator::CONTEXT_LANGUAGE];
                throw UseCaseError::newValueTooLong( "/labels/$language", $maxLabelLength, true );
            case ItemLabelValidator::CODE_LABEL_DESCRIPTION_DUPLICATE:
                throw UseCaseError::newDataPolicyViolation(
                    UseCaseError::POLICY_VIOLATION_ITEM_LABEL_DESCRIPTION_DUPLICATE,
                    [
                        UseCaseError::CONTEXT_LANGUAGE => $context[ItemLabelValidator::CONTEXT_LANGUAGE],
                        UseCaseError::CONTEXT_CONFLICTING_ITEM_ID => $context[ItemLabelValidator::CONTEXT_CONFLICTING_ITEM_ID],
                    ]
                );
            case ItemLabelValidator::CODE_LABEL_SAME_AS_DESCRIPTION:
                throw UseCaseError::newDataPolicyViolation(
                    UseCaseError::POLICY_VIOLATION_LABEL_DESCRIPTION_SAME_VALUE,
                    [ UseCaseError::CONTEXT_LANGUAGE => $context[ItemLabelValidator::CONTEXT_LANGUAGE] ]
                );
        }
    }

    private function handleDescriptionsValidationError( ValidationError $validationError ): void {
        $context = $validationError->getContext();

        switch ( $validationError->getCode() ) {
            case DescriptionsSyntaxValidator::CODE_DESCRIPTIONS_NOT_ASSOCIATIVE:
                throw UseCaseError::newPatchResultInvalidValue( '/descriptions', $context[ DescriptionsSyntaxValidator::CONTEXT_VALUE ] );
            case DescriptionsSyntaxValidator::CODE_EMPTY_DESCRIPTION:
                $languageCode = $context[DescriptionsSyntaxValidator::CONTEXT_LANGUAGE];
                throw UseCaseError::newPatchResultInvalidValue( "/descriptions/$languageCode", '' );
            case DescriptionsSyntaxValidator::CODE_INVALID_DESCRIPTION_TYPE:
                throw UseCaseError::newPatchResultInvalidValue(
                    "/descriptions/{$context[DescriptionsSyntaxValidator::CONTEXT_LANGUAGE]}",
                    $context[DescriptionsSyntaxValidator::CONTEXT_DESCRIPTION]
                );
            case ItemDescriptionValidator::CODE_INVALID:
                throw UseCaseError::newPatchResultInvalidValue(
                    "/descriptions/{$context[ItemDescriptionValidator::CONTEXT_LANGUAGE]}",
                    $context[ItemDescriptionValidator::CONTEXT_DESCRIPTION]
                );
            case ItemDescriptionValidator::CODE_TOO_LONG:
                $languageCode = $context[ItemDescriptionValidator::CONTEXT_LANGUAGE];
                $maxDescriptionLength = $context[ItemDescriptionValidator::CONTEXT_LIMIT];
                throw UseCaseError::newValueTooLong( "/descriptions/$languageCode", $maxDescriptionLength, true );
            case ItemDescriptionValidator::CODE_DESCRIPTION_SAME_AS_LABEL:
                throw UseCaseError::newDataPolicyViolation(
                    UseCaseError::POLICY_VIOLATION_LABEL_DESCRIPTION_SAME_VALUE,
                    [ UseCaseError::CONTEXT_LANGUAGE => $context[ItemDescriptionValidator::CONTEXT_LANGUAGE] ]
                );
            case ItemDescriptionValidator::CODE_DESCRIPTION_LABEL_DUPLICATE:
                throw UseCaseError::newDataPolicyViolation(
                    UseCaseError::POLICY_VIOLATION_ITEM_LABEL_DESCRIPTION_DUPLICATE,
                    [
                        UseCaseError::CONTEXT_LANGUAGE => $context[ItemDescriptionValidator::CONTEXT_LANGUAGE],
                        UseCaseError::CONTEXT_CONFLICTING_ITEM_ID => $context[ItemDescriptionValidator::CONTEXT_CONFLICTING_ITEM_ID],
                    ]
                );
        }
    }

    private function assertValidAliases( array $serialization ): void {
        $aliasesSerialization = $serialization[ 'aliases' ] ?? [];
        $validationError = $this->aliasesValidator->validate( $aliasesSerialization, '/aliases' );
        if ( $validationError ) {
            $errorCode = $validationError->getCode();
            $context = $validationError->getContext();
            switch ( $errorCode ) {
                case AliasesValidator::CODE_INVALID_VALUE:
                    throw UseCaseError::newPatchResultInvalidValue(
                        $context[AliasesValidator::CONTEXT_PATH],
                        $context[AliasesValidator::CONTEXT_VALUE]
                    );
                case LanguageCodeValidator::CODE_INVALID_LANGUAGE_CODE:
                    throw UseCaseError::newPatchResultInvalidKey( '/aliases', $context[LanguageCodeValidator::CONTEXT_LANGUAGE_CODE] );
                case AliasesInLanguageValidator::CODE_INVALID:
                    throw UseCaseError::newPatchResultInvalidValue(
                        $context[AliasesInLanguageValidator::CONTEXT_PATH],
                        $context[AliasesInLanguageValidator::CONTEXT_VALUE]
                    );
                case AliasesInLanguageValidator::CODE_TOO_LONG:
                    $path = $context[AliasesInLanguageValidator::CONTEXT_PATH];
                    $limit = $context[AliasesInLanguageValidator::CONTEXT_LIMIT];
                    throw UseCaseError::newValueTooLong( $path, $limit, true );
                default:
                    throw new LogicException( "Unexpected validation error code: $errorCode" );
            }
        }
    }

    private function getModifiedLanguages( TermList $original, TermList $modified ): array {
        return array_keys( array_filter(
            iterator_to_array( $modified ),
            fn( Term $term ) => !$original->hasTermForLanguage( $term->getLanguageCode() ) ||
                                !$original->getByLanguage( $term->getLanguageCode() )->equals( $term )
        ) );
    }

    private function assertValidSitelinks( ItemReadModel $item, array $serialization ): void {
        $itemId = $serialization['id'];
        $sitelinksSerialization = $serialization['sitelinks'] ?? [];
        $originalSitelinks = $item->getSitelinks();
        $validationError = $this->sitelinksValidator->validate(
            $itemId,
            $sitelinksSerialization,
            $this->getModifiedSitelinksSites( $item->getSitelinks(), $sitelinksSerialization ),
            '/sitelinks'
        );

        if ( $validationError ) {
            $this->handleSitelinksValidationError( $validationError, $sitelinksSerialization );
        }
        $this->assertUrlsNotModified( $originalSitelinks, $sitelinksSerialization );
    }

    private function getModifiedSitelinksSites( Sitelinks $originalSitelinks, array $patchedSitelinks ): array {
        return array_filter(
            array_keys( $patchedSitelinks ),
            function( string $siteId ) use ( $patchedSitelinks, $originalSitelinks ) {
                $originalBadges = fn() => array_map( fn( ItemId $i ) => (string)$i, $originalSitelinks[$siteId]->getBadges() );

                return !isset( $originalSitelinks[$siteId] )
                    || ( $patchedSitelinks[$siteId]['title'] ?? '' ) !== $originalSitelinks[$siteId]->getTitle()
                    || ( $patchedSitelinks[$siteId]['badges'] ?? [] ) !== $originalBadges();
            }
        );
    }

    private function handleSitelinksValidationError( ValidationError $validationError, array $sitelinksSerialization ): void {
        $context = $validationError->getContext();
        $siteId = fn() => $context[ SitelinkValidator::CONTEXT_SITE_ID ];
        switch ( $validationError->getCode() ) {
            case SitelinksValidator::CODE_INVALID_SITELINK:
                throw UseCaseError::newPatchResultInvalidValue(
                    "/sitelinks/{$context[SitelinksValidator::CONTEXT_SITE_ID]}",
                    $sitelinksSerialization[$context[SitelinksValidator::CONTEXT_SITE_ID]]
                );
            case SitelinksValidator::CODE_SITELINKS_NOT_ASSOCIATIVE:
                throw UseCaseError::newPatchResultInvalidValue( '/sitelinks', $sitelinksSerialization );
            case SiteIdValidator::CODE_INVALID_SITE_ID:
                throw UseCaseError::newPatchResultInvalidKey( '/sitelinks', $context[SiteIdValidator::CONTEXT_SITE_ID_VALUE] );
            case SitelinkValidator::CODE_TITLE_MISSING:
                throw UseCaseError::newMissingFieldInPatchResult( $context[SitelinkValidator::CONTEXT_PATH], 'title' );
            case SitelinkValidator::CODE_EMPTY_TITLE:
            case SitelinkValidator::CODE_INVALID_TITLE:
            case SitelinkValidator::CODE_INVALID_FIELD_TYPE:
                throw UseCaseError::newPatchResultInvalidValue(
                    $context[SitelinkValidator::CONTEXT_PATH],
                    $context[SitelinkValidator::CONTEXT_VALUE]
                );
            case SitelinkValidator::CODE_TITLE_NOT_FOUND:
                throw UseCaseError::newPatchResultReferencedResourceNotFound(
                    '/sitelinks/' . $siteId() . '/title',
                    $sitelinksSerialization[ $siteId() ][ 'title' ]
                );
            case SitelinkValidator::CODE_INVALID_BADGE:
            case SitelinkValidator::CODE_BADGE_NOT_ALLOWED:
                $badge = (string)$context[ SitelinkValidator::CONTEXT_VALUE];
                $badgeIndex = Utils::getIndexOfValueInSerialization( $badge, $sitelinksSerialization[$siteId()]['badges'] );
                throw UseCaseError::newPatchResultInvalidValue( "/sitelinks/{$siteId()}/badges/$badgeIndex", $badge );
            case SitelinkValidator::CODE_SITELINK_CONFLICT:
                $conflictingItemId = $context[ SitelinkValidator::CONTEXT_CONFLICTING_ITEM_ID ];
                throw UseCaseError::newDataPolicyViolation(
                    UseCaseError::POLICY_VIOLATION_SITELINK_CONFLICT,
                    [
                        UseCaseError::CONTEXT_CONFLICTING_ITEM_ID => "$conflictingItemId",
                        UseCaseError::CONTEXT_SITE_ID => $siteId(),
                    ]
                );
        }
    }

    private function assertUrlsNotModified( Sitelinks $originalSitelinks, array $patchedSitelinkSerialization ): void {
        foreach ( $patchedSitelinkSerialization as $siteId => $sitelink ) {
            if (
                isset( $sitelink[ 'url' ] ) &&
                isset( $originalSitelinks[ $siteId ] ) &&
                $originalSitelinks[ $siteId ]->getUrl() !== $sitelink[ 'url' ]
            ) {
                throw UseCaseError::newPatchResultModifiedReadOnlyValue( "/sitelinks/$siteId/url" );
            }
        }
    }

    private function assertValidStatements( array $serialization, Item $originalItem, array $originalStatementsSerialization ): void {
        $validationError = $this->statementsValidator->validateModifiedStatements(
            $originalStatementsSerialization,
            $originalItem->getStatements(),
            $serialization['statements'] ?? [],
            '/statements'
        );
        if ( $validationError ) {
            $context = $validationError->getContext();
            switch ( $validationError->getCode() ) {
                case StatementsValidator::CODE_STATEMENTS_NOT_ASSOCIATIVE:
                case StatementsValidator::CODE_STATEMENT_GROUP_NOT_SEQUENTIAL:
                case StatementsValidator::CODE_STATEMENT_NOT_ARRAY:
                    throw UseCaseError::newPatchResultInvalidValue(
                        $context[StatementsValidator::CONTEXT_PATH],
                        $context[StatementsValidator::CONTEXT_VALUE]
                    );
                case StatementsValidator::CODE_PROPERTY_ID_MISMATCH:
                    throw new UseCaseError(
                        UseCaseError::PATCHED_STATEMENT_GROUP_PROPERTY_ID_MISMATCH,
                        "Statement's Property ID does not match the Statement group key",
                        [
                            UseCaseError::CONTEXT_PATH => $context[StatementsValidator::CONTEXT_PATH],
                            UseCaseError::CONTEXT_STATEMENT_GROUP_PROPERTY_ID => $context[StatementsValidator::CONTEXT_PROPERTY_ID_KEY],
                            UseCaseError::CONTEXT_STATEMENT_PROPERTY_ID => $context[StatementsValidator::CONTEXT_PROPERTY_ID_VALUE],
                        ]
                    );
                case StatementValidator::CODE_INVALID_FIELD_TYPE:
                case StatementValidator::CODE_INVALID_FIELD:
                    throw UseCaseError::newPatchResultInvalidValue(
                        $context[StatementValidator::CONTEXT_PATH],
                        $context[StatementValidator::CONTEXT_VALUE]
                    );
                case StatementValidator::CODE_MISSING_FIELD:
                    throw UseCaseError::newMissingFieldInPatchResult(
                        $context[StatementValidator::CONTEXT_PATH],
                        $context[StatementValidator::CONTEXT_FIELD]
                    );
                case StatementValidator::CODE_PROPERTY_NOT_FOUND:
                    throw UseCaseError::newPatchResultReferencedResourceNotFound(
                        $context[StatementValidator::CONTEXT_PATH],
                        $context[StatementValidator::CONTEXT_VALUE]
                    );

                default:
                    throw new LogicException( "Unknown validation error code: {$validationError->getCode()}" );
            }
        }

        $originalStatements = $originalItem->getStatements();
        $originalStatementsIds = $this->getStatementIds( $originalStatements );
        $patchedStatements = $this->statementsValidator->getValidatedStatements();
        $patchedStatementsIds = $this->getStatementIds( $patchedStatements );
        foreach ( array_count_values( $patchedStatementsIds ) as $id => $occurrence ) {
            if ( $occurrence > 1 || !in_array( $id, $originalStatementsIds ) ) {
                $path = "{$this->getStatementIdPath( $serialization['statements'], $id )}/id";
                throw UseCaseError::newPatchResultModifiedReadOnlyValue( $path );
            }

            $originalPropertyId = $originalStatements->getFirstStatementWithGuid( $id )->getPropertyId();
            if ( !$patchedStatements->getFirstStatementWithGuid( $id )->getPropertyId()->equals(
                $originalPropertyId
            ) ) {
                $path = "{$this->getStatementIdPath( $serialization['statements'], $id )}/property/id";
                throw UseCaseError::newPatchResultModifiedReadOnlyValue( $path );
            }
        }
    }

    private function getStatementIds( StatementList $statementList ): array {
        return array_filter( array_map(
            fn( Statement $statement ) => $statement->getGuid(),
            iterator_to_array( $statementList )
        ) );
    }

    private function getStatementIdPath( array $serialization, string $id ): string {
        foreach ( $serialization as $propertyId => $statementGroup ) {
            foreach ( $statementGroup as $groupIndex => $statement ) {
                if ( isset( $statement['id'] ) && $statement['id'] === $id ) {
                    return "/statements/$propertyId/$groupIndex";
                }
            }
        }

        throw new LogicException( "Statement ID '$id' not found in patch result" );
    }

}