Gergő Tisza has uploaded a new change for review. https://gerrit.wikimedia.org/r/320324
Change subject: Add PageViewService to make the extension non-Wikimedia-specific ...................................................................... Add PageViewService to make the extension non-Wikimedia-specific Change-Id: I0ef75e0b94994270992ef07a1698c99820ff7ff3 Depends-On: I3835b054ceac0fa0bcd58b41efa6bf78a0fafae7 --- M extension.json M i18n/en.json M i18n/qqq.json M includes/Hooks.php A includes/PageViewService.php A includes/ServiceWiring.php A includes/WikimediaPageViewService.php A tests/phpunit/ServiceWiringTest.php A tests/phpunit/WikimediaPageViewServiceTest.php A tests/smoke/WikimediaPageViewServiceSmokeTest.php 10 files changed, 1,083 insertions(+), 60 deletions(-) git pull ssh://gerrit.wikimedia.org:29418/mediawiki/extensions/PageViewInfo refs/changes/24/320324/1 diff --git a/extension.json b/extension.json index 2e8961a..8a24ff3 100644 --- a/extension.json +++ b/extension.json @@ -12,7 +12,9 @@ ] }, "AutoloadClasses": { - "MediaWiki\\Extensions\\PageViewInfo\\Hooks": "includes/Hooks.php" + "MediaWiki\\Extensions\\PageViewInfo\\Hooks": "includes/Hooks.php", + "MediaWiki\\Extensions\\PageViewInfo\\PageViewService": "includes/PageViewService.php", + "MediaWiki\\Extensions\\PageViewInfo\\WikimediaPageViewService": "includes/WikimediaPageViewService.php" }, "MessagesDirs": { "PageViewInfo": [ @@ -38,9 +40,16 @@ "localBasePath": "resources", "remoteExtPath": "PageViewInfo/resources" }, + "ConfigRegistry": { + "PageViewInfo": "GlobalVarConfig::newInstance" + }, + "ServiceWiringFiles": [ + "includes/ServiceWiring.php" + ], "config": { - "PageViewInfoEndpoint": "https://wikimedia.org/api/rest_v1/metrics/pageviews", - "PageViewInfoDomain": false + "PageViewInfoWikimediaEndpoint": "https://wikimedia.org/api/rest_v1", + "PageViewInfoWikimediaDomain": false, + "PageViewInfoWikimediaRequestLimit": 5 }, "manifest_version": 1 } diff --git a/i18n/en.json b/i18n/en.json index 7a2174f..339d0b0 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -7,5 +7,6 @@ "pvi-desc": "Adds page view information to the info action", "pvi-month-count": "Page views in the past 30 days", "pvi-close": "Close", - "pvi-range": "$1 - $2" + "pvi-range": "$1 - $2", + "pvi-invalidresponse": "Invalid response" } diff --git a/i18n/qqq.json b/i18n/qqq.json index 85b449e..ba260de 100644 --- a/i18n/qqq.json +++ b/i18n/qqq.json @@ -8,5 +8,6 @@ "pvi-desc": "{{desc|name=PageViewInfo|url=https://www.mediawiki.org/wiki/Extension:PageViewInfo}}", "pvi-month-count": "Label for table cell containing page views in past 30 days", "pvi-close": "Text on button to close a dialog\n{{Identical|Close}}", - "pvi-range": "Title of dialog, which is the date range the graph is for. $1 is the starting date, $2 is the ending date." + "pvi-range": "Title of dialog, which is the date range the graph is for. $1 is the starting date, $2 is the ending date.", + "pvi-invalidresponse": "Error message when the REST API response data does not have the expected structure." } diff --git a/includes/Hooks.php b/includes/Hooks.php index 5302afd..e94ac8c 100644 --- a/includes/Hooks.php +++ b/includes/Hooks.php @@ -5,8 +5,8 @@ use IContextSource; use FormatJson; use Html; -use MWHttpRequest; -use MediaWiki\Logger\LoggerFactory; +use MediaWiki\MediaWikiServices; +use ObjectCache; use Title; class Hooks { @@ -17,15 +17,18 @@ */ public static function onInfoAction( IContextSource $ctx, array &$pageInfo ) { $views = self::getMonthViews( $ctx->getTitle() ); - if ( $views === false ) { + if ( !$views ) { return; } - $count = 0; - foreach ( $views['items'] as $item ) { - $count += $item['views']; - } + + $total = array_sum( $views ); + reset( $views ); + $start = self::toYmdHis( key( $views ) ); + end( $views ); + $end = self::toYmdHis( key( $views ) ); + $lang = $ctx->getLanguage(); - $formatted = $lang->formatNum( $count ); + $formatted = $lang->formatNum( $total ); $pageInfo['header-basic'][] = [ $ctx->msg( 'pvi-month-count' ), Html::element( 'div', [ 'class' => 'mw-pvi-month' ], $formatted ) @@ -35,73 +38,64 @@ file_get_contents( __DIR__ . '/../graphs/month.json' ), true ); - $info['data'][0]['values'] = $views['items']; + foreach ( $views as $day => $count ) { + $info['data'][0]['values'][] = [ 'timestamp' => self::toYmd( $day ), 'views' => $count ]; + } $ctx->getOutput()->addModules( 'ext.pageviewinfo' ); // Ymd -> YmdHis - $plus = '000000'; $user = $ctx->getUser(); $ctx->getOutput()->addJsConfigVars( [ 'wgPageViewInfo' => [ 'graph' => $info, - 'start' => $lang->userDate( $views['start'] . $plus, $user ), - 'end' => $lang->userDate( $views['end'] . $plus, $user ), + 'start' => $lang->userDate( $start, $user ), + 'end' => $lang->userDate( $end, $user ), ], ] ); } - /** - * @param Title $title - * @param string $startDate Ymd format - * @param string $endDate Ymd format - * @return string - */ - protected static function buildApiUrl( Title $title, $startDate, $endDate ) { - global $wgPageViewInfoEndpoint, $wgPageViewInfoDomain, $wgServerName; - if ( $wgPageViewInfoDomain ) { - $serverName = $wgPageViewInfoDomain; - } else { - $serverName = $wgServerName; - } - - // Use plain urlencode instead of wfUrlencode because we need - // "/" to be encoded, which wfUrlencode doesn't. - $encodedTitle = urlencode( $title->getPrefixedDBkey() ); - return "$wgPageViewInfoEndpoint/per-article/$serverName" - . "/all-access/user/$encodedTitle/daily/$startDate/$endDate"; - } - protected static function getMonthViews( Title $title ) { - global $wgMemc; - - $key = wfMemcKey( 'pvi', 'month', md5( $title->getPrefixedText() ) ); - $data = $wgMemc->get( $key ); + $cache = ObjectCache::getLocalClusterInstance(); + $key = $cache->makeKey( 'pvi', 'month', md5( $title->getPrefixedText() ) ); + $data = $cache->get( $key ); if ( $data ) { return $data; } - $today = date( 'Ymd' ); - $lastMonth = date( 'Ymd', time() - ( 60 * 60 * 24 * 30 ) ); - $url = self::buildApiUrl( $title, $lastMonth, $today ); - $req = MWHttpRequest::factory( $url, [ 'timeout' => 10 ], __METHOD__ ); - $status = $req->execute(); - if ( !$status->isOK() ) { - LoggerFactory::getInstance( 'PageViewInfo' ) - ->error( "Failed fetching $url: {$status->getWikiText()}", [ - 'url' => $url, - 'title' => $title->getPrefixedText() - ] ); + /** @var PageViewService $pageViewService */ + $pageViewService = MediaWikiServices::getInstance()->getService( 'PageViewService' ); + if ( !$pageViewService->supports( PageViewService::METRIC_VIEW, + PageViewService::SCOPE_ARTICLE ) + ) { return false; } - $data = FormatJson::decode( $req->getContent(), true ); - // Add our start/end periods - $data['start'] = $lastMonth; - $data['end'] = $today; + $status = $pageViewService->getPageData( [ $title ], 30, PageViewService::METRIC_VIEW ); + if ( !$status->isOK() ) { + $cache->set( $key, false, 300 ); + } - // Cache for an hour - $wgMemc->set( $key, $data, 60 * 60 ); - + $data = $status->getValue()[$title->getPrefixedDBkey()]; + $cache->set( $key, $data, $pageViewService->getCacheExpiry( PageViewService::METRIC_VIEW, + PageViewService::SCOPE_ARTICLE ) ); return $data; } + + /** + * Convert YYYY-MM-DD to YYYYMMDD + * @param string $date + * @return string + */ + protected static function toYmd( $date ) { + return substr( $date, 0, 4 ) . substr( $date, 5, 2 ) . substr( $date, 8, 2 ); + } + + /** + * Convert YYYY-MM-DD to TS_MW + * @param string $date + * @return string + */ + protected static function toYmdHis( $date ) { + return substr( $date, 0, 4 ) . substr( $date, 5, 2 ) . substr( $date, 8, 2 ) . '000000'; + } } diff --git a/includes/PageViewService.php b/includes/PageViewService.php new file mode 100644 index 0000000..9db1710 --- /dev/null +++ b/includes/PageViewService.php @@ -0,0 +1,82 @@ +<?php + +namespace MediaWiki\Extensions\PageViewInfo; + +use StatusValue; +use Title; + +/** + * PageViewService provides an abstraction for different methods to access pageview data + * (HitCounter extension DB tables, Piwik API, Google Analytics API etc). + */ +interface PageViewService { + /** Page view count */ + const METRIC_VIEW = 'view'; + /** Unique visitors (devices) for some period, typically last 30 days */ + const METRIC_UNIQUE = 'unique'; + + /** Return data for a given article */ + const SCOPE_ARTICLE = 'article'; + /** Return a list of the top articles */ + const SCOPE_TOP = 'top'; + /** Return data for the whole site */ + const SCOPE_SITE = 'site'; + + /** + * Whether the service can provide data for the given metric/scope combination. + * @param string $metric One of the METRIC_* constants. + * @param string $scope One of the METRIC_* constants. + * @return boolean + */ + public function supports( $metric, $scope ); + + /** + * Returns an array of daily counts for the last $days days, in the format + * title => [ date => count, ... ] + * where date is in ISO format (YYYY-MM-DD). Which time zone to use is left to the implementation + * (although UTC is the recommended one, unless the site has a very narrow audience). Exactly + * which days are returned is also up to the implentation; recent days with incomplete data + * should be omitted. (Typically that means that the returned date range will end with the + * previous day, but given a sufficiently slow backend, the last full day for which data is + * available and the last full calendar day might not be the same thing). + * Count will be null when there is no data or there was an error. The order of titles will be + * the same as in the parameter $titles, but some implementations might return fewer titles than + * requested, if fetching more data is considered too expensive. In that case the returned data + * will be for a prefix slice of the $titles array. + * @param Title[] $titles + * @param int $days The number of days. + * @param string $metric One of the METRIC_* constants. + * @return StatusValue A status object with the data. Its success property will contain + * per-title success information. + */ + public function getPageData( array $titles, $days, $metric = self::METRIC_VIEW ); + + /** + * Returns an array of total daily counts for the whole site, in the format + * date => count + * where date is in ISO format (YYYY-MM-DD). The same considerations apply as for getPageData(). + * @param int $days The number of days. + * @param string $metric One of the METRIC_* constants. + * @return StatusValue A status object with the data. + */ + public function getSiteData( $days, $metric = self::METRIC_VIEW ); + + /** + * Returns a list of the top pages according to some metric, sorted in descending order + * by that metric, in + * title => count + * format (where title has the same format as Title::getPrefixedDBKey()). + * @param string $metric One of the METRIC_* constants. + * @return StatusValue A status object with the data. + */ + public function getTopPages( $metric = self::METRIC_VIEW ); + + /** + * Returns the length of time for which it is acceptable to cache the results. + * Typically this would be the end of the current day in whatever timezone the data is in. + * @param string $metric One of the METRIC_* constants. + * @param string $scope One of the METRIC_* constants. + * @return int Time in seconds + */ + public function getCacheExpiry( $metric, $scope ); +} diff --git a/includes/ServiceWiring.php b/includes/ServiceWiring.php new file mode 100644 index 0000000..cbd86db --- /dev/null +++ b/includes/ServiceWiring.php @@ -0,0 +1,20 @@ +<?php + +namespace MediaWiki\Extensions\PageViewInfo; + +use MediaWiki\Logger\LoggerFactory; +use MediaWiki\MediaWikiServices; + +return [ + 'PageViewService' => function ( MediaWikiServices $services ) { + $mainConfig = $services->getMainConfig(); + $extensionConfig = $services->getConfigFactory()->makeConfig( 'PageViewInfo' ); + $endpoint = $extensionConfig->get( 'PageViewInfoWikimediaEndpoint' ); + $project = $extensionConfig->get( 'PageViewInfoWikimediaDomain' ) + ?: $mainConfig->get( 'ServerName' ); + $pageViewService = new WikimediaPageViewService( $endpoint, [ 'project' => $project ], + $extensionConfig->get( 'PageViewInfoWikimediaRequestLimit' ) ); + $pageViewService->setLogger( LoggerFactory::getInstance( 'PageViewInfo' ) ); + return $pageViewService; + }, +]; diff --git a/includes/WikimediaPageViewService.php b/includes/WikimediaPageViewService.php new file mode 100644 index 0000000..441a000 --- /dev/null +++ b/includes/WikimediaPageViewService.php @@ -0,0 +1,342 @@ +<?php + +namespace MediaWiki\Extensions\PageViewInfo; + +use FormatJson; +use InvalidArgumentException; +use MWHttpRequest; +use Psr\Log\LoggerAwareInterface; +use Psr\Log\LoggerInterface; +use Psr\Log\LogLevel; +use Psr\Log\NullLogger; +use Status; +use StatusValue; +use Title; + +/** + * PageViewService implementation for Wikimedia wikis, using the pageview API + * @see https://wikitech.wikimedia.org/wiki/Analytics/PageviewAPI + */ +class WikimediaPageViewService implements PageViewService, LoggerAwareInterface { + /** @var callable ( URL, caller ) => MWHttpRequest */ + protected $requestFactory; + /** @var LoggerInterface */ + protected $logger; + + /** @var string */ + protected $endpoint; + /** @var int|false Max number of pages to look up (false for unlimited) */ + protected $lookupLimit; + + /** @var string */ + protected $project; + /** @var string 'all-access', 'desktop', 'mobile-app' or 'mobile-web' */ + protected $access; + /** @var string 'all-agents', 'user', 'spider' or 'bot' */ + protected $agent; + /** @var string 'hourly', 'daily' or 'monthly' */ + protected $granularity = 'daily'; // allowing other options would make the interafce too complex + /** @var int UNIX timestamp of 0:00 of the last day with complete data */ + protected $lastCompleteDay; + + /** @var array Cache for getEmptyDateRange() */ + protected $range; + + /** + * @param string $endpoint Wikimedia pageview API endpoint + * @param array $apiOptions Associative array of API URL parameters + * see https://wikimedia.org/api/rest_v1/#!/Pageviews_data + * project is the only required parameter. Granularity, start and end are not supported. + * @param int|false Max number of pages to look up (false for unlimited). Data will be returned + * for no more than this many titles in a getPageData() call. + */ + public function __construct( $endpoint, array $apiOptions, $lookupLimit ) { + $this->endpoint = rtrim( $endpoint, '/' ); + $this->lookupLimit = $lookupLimit; + $apiOptions += [ + 'access' => 'all-access', + 'agent' => 'user', + ]; + $this->verifyApiOptions( $apiOptions ); + + $this->project = $apiOptions['project']; + $this->access = $apiOptions['access']; + $this->agent = $apiOptions['agent']; + + // Skip the current day for which only partial information is available, and also + // the previous day as data processing is sometimes a day behind so the numbers can be wrong. + $this->lastCompleteDay = strtotime( '0:0 2 days ago' ); + + $this->requestFactory = [ $this, 'requestFactory' ]; + $this->logger = new NullLogger(); + } + + public function setLogger( LoggerInterface $logger ) { + $this->logger = $logger; + } + + public function supports( $metric, $scope ) { + if ( $metric === self::METRIC_VIEW ) { + return true; + } elseif ( $metric === self::METRIC_UNIQUE ) { + return $scope === self::SCOPE_SITE && $this->access !== 'mobile-app'; + } + return false; + } + + public function getPageData( array $titles, $days, $metric = self::METRIC_VIEW ) { + if ( $metric !== self::METRIC_VIEW ) { + throw new InvalidArgumentException( 'Invalid metric: ' . $metric ); + } + if ( !$titles ) { + return StatusValue::newGood( [] ); + } elseif ( $this->lookupLimit !== false ) { + $titles = array_slice( $titles, 0, $this->lookupLimit ); + } + if ( $days <= 0 ) { + throw new InvalidArgumentException( 'Invalid days: ' .$days ); + } + + $status = StatusValue::newGood(); + $result = []; + foreach ( $titles as $title ) { + /** @var Title $title */ + $result[$title->getPrefixedDBkey()] = $this->getEmptyDateRange( $days ); + $requestStatus = $this->makeRequest( + $this->getRequestUrl( self::SCOPE_ARTICLE, $title, $days ) ); + if ( $requestStatus->isOK() ) { + $data = $requestStatus->getValue(); + if ( isset( $data['items'] ) && is_array( $data['items'] ) ) { + foreach ( $data['items'] as $item ) { + $ts = $item['timestamp']; + $day = substr( $ts, 0, 4 ) . '-' . substr( $ts, 4, 2 ) . '-' . substr( $ts, 6, 2 ); + $result[$title->getPrefixedDBkey()][$day] = $item['views']; + } + $status->success[$title->getPrefixedDBkey()] = true; + } else { + $status->error( 'pvi-invalidresponse' ); + $status->success[$title->getPrefixedDBkey()] = false; + } + } else { + $status->success[$title->getPrefixedDBkey()] = false; + } + $status->merge( $requestStatus ); + } + $status->successCount = count( array_filter( $status->success ) ); + $status->failCount = count( $status->success ) - $status->successCount; + $status->setResult( array_filter( $status->success ), $result ); + return $status; + } + + public function getSiteData( $days, $metric = self::METRIC_VIEW ) { + if ( $metric !== self::METRIC_VIEW && $metric !== self::METRIC_UNIQUE ) { + throw new InvalidArgumentException( 'Invalid metric: ' . $metric ); + } elseif ( $metric === self::METRIC_UNIQUE && $this->access === 'mobile-app' ) { + throw new InvalidArgumentException( + 'Unique device counts for mobile apps are not supported' ); + } + if ( $days <= 0 ) { + throw new InvalidArgumentException( 'Invalid days: ' .$days ); + } + $result = $this->getEmptyDateRange( $days ); + $status = $this->makeRequest( $this->getRequestUrl( $metric, null, $days ) ); + if ( $status->isOK() ) { + $data = $status->getValue(); + if ( isset( $data['items'] ) && is_array( $data['items'] ) ) { + foreach ( $data['items'] as $item ) { + $ts = $item['timestamp']; + $day = substr( $ts, 0, 4 ) . '-' . substr( $ts, 4, 2 ) . '-' . substr( $ts, 6, 2 ); + $count = $metric === self::METRIC_VIEW ? $item['views'] : $item['devices']; + $result[$day] = $count; + } + } else { + $status->fatal( 'pvi-invalidresponse' ); + } + } + $status->setResult( $status->isOK(), $result ); + return $status; + } + + public function getTopPages( $metric = self::METRIC_VIEW ) { + $result = []; + if ( $metric !== self::METRIC_VIEW ) { + throw new InvalidArgumentException( 'Invalid metric: ' . $metric ); + } + $status = $this->makeRequest( $this->getRequestUrl( self::SCOPE_TOP ) ); + if ( $status->isOK() ) { + $data = $status->getValue(); + if ( isset( $data['items'] ) && is_array( $data['items'] ) && !$data['items'] ) { + // empty result set, no error; makeRequest generates this on 404 + } elseif ( + isset( $data['items'][0]['articles'] ) && + is_array( $data['items'][0]['articles'] ) + ) { + foreach ( $data['items'][0]['articles'] as $item ) { + $result[$item['article']] = $item['views']; + } + } else { + $status->fatal( 'pvi-invalidresponse' ); + } + } + $status->setResult( $status->isOK(), $result ); + return $status; + } + + public function getCacheExpiry( $metric, $scope ) { + // data is valid until the end of the day + $endOfDay = strtotime( '0:0 next day' ); + return $endOfDay - time(); + } + + /** + * @param array $apiOptions + * @throws InvalidArgumentException + */ + protected function verifyApiOptions( array $apiOptions ) { + if ( !isset( $apiOptions['project'] ) ) { + throw new InvalidArgumentException( "'project' is required" ); + } elseif ( !in_array( $apiOptions['access'], + [ 'all-access', 'desktop', 'mobile-app', 'mobile-web' ], true ) ) { + throw new InvalidArgumentException( 'Invalid access: ' . $apiOptions['access'] ); + } elseif ( !in_array( $apiOptions['agent'], + [ 'all-agents', 'user', 'spider', 'bot' ], true ) ) { + throw new InvalidArgumentException( 'Invalid agent: ' . $apiOptions['agent'] ); + } elseif ( isset( $apiOptions['granularity'] ) ) { + throw new InvalidArgumentException( 'Changing granularity is not supported' ); + } + } + + /** + * @param string $scope SCOPE_* constant or METRIC_UNIQUE + * @param Title|null $title + * @param int|null $days + * @return string + */ + protected function getRequestUrl( $scope, Title $title = null, $days = null ) { + list( $start, $end ) = $this->getStartEnd( $days ); + switch ( $scope ) { + case self::SCOPE_ARTICLE: + if ( !$title ) { + throw new InvalidArgumentException( 'Title is required when using article scope' ); + } + // Use plain urlencode instead of wfUrlencode because we need + // "/" to be encoded, which wfUrlencode doesn't. + $encodedTitle = urlencode( $title->getPrefixedDBkey() ); + $start = substr( $start, 0, 8 ); // YYYYMMDD + $end = substr( $end, 0, 8 ); + return "$this->endpoint/metrics/pageviews/per-article/$this->project/$this->access/" + . "$this->agent/$encodedTitle/$this->granularity/$start/$end"; + case self::METRIC_VIEW: + case self::SCOPE_SITE: + $start = substr( $start, 0, 10 ); // YYYYMMDDHH + $end = substr( $end, 0, 10 ); + return "$this->endpoint/metrics/pageviews/aggregate/$this->project/$this->access/$this->agent/" + . "$this->granularity/$start/$end"; + case self::SCOPE_TOP: + $year = substr( $end, 0, 4 ); + $month = substr( $end, 4, 2 ); + $day = substr( $end, 6, 2 ); + return "$this->endpoint/metrics/pageviews/top/$this->project/$this->access/$year/$month/$day"; + case self::METRIC_UNIQUE: + $access = [ + 'all-access' => 'all-sites', + 'desktop' => 'desktop-site', + 'mobile-web' => 'mobile-site', + ][$this->access]; + $start = substr( $start, 0, 8 ); // YYYYMMDD + $end = substr( $end, 0, 8 ); + return "$this->endpoint/metrics/unique-devices/$this->project/$access/" + . "$this->granularity/$start/$end"; + default: + throw new InvalidArgumentException( 'Invalid scope: ' . $scope ); + } + } + + /** + * @param string $url + * @return StatusValue + */ + protected function makeRequest( $url ) { + /** @var MWHttpRequest $request */ + $request = call_user_func( $this->requestFactory, $url, __METHOD__ ); + $status = $request->execute(); + $parseStatus = FormatJson::parse( $request->getContent(), FormatJson::FORCE_ASSOC ); + if ( $status->isOK() ) { + $status->merge( $parseStatus, true ); + } + + $apiErrorData = []; + if ( !$status->isOK() && $parseStatus->isOK() && is_array( $parseStatus->getValue() ) ) { + $apiErrorData = $parseStatus->getValue(); // hash of: type, title, method, uri, [detail] + if ( isset( $apiErrorData['detail'] ) && is_array( $apiErrorData['detail'] ) ) { + $apiErrorData['detail'] = implode( ', ', $apiErrorData['detail'] ); + } + } + if ( + $request->getStatus() === 404 && + isset( $apiErrorData['type'] ) && + $apiErrorData['type'] === 'https://restbase.org/errors/not_found' + ) { + // the pageview API will return with a 404 when the page has 0 views :/ + $status = StatusValue::newGood( [ 'items' => [] ] ); + } + if ( !$status->isGood() ) { + $error = Status::wrap( $status )->getWikiText( null, null, 'en' ); + $severity = $status->isOK() ? LogLevel::INFO : LogLevel::ERROR; + $msg = $status->isOK() ? 'Problems fetching {url}: {error}' : 'Failed fetching {url}: {error}'; + $prefixedApiErrorData = array_combine( array_map( function ( $k ) { + return 'apierror_' . $k; + }, array_keys( $apiErrorData ) ), $apiErrorData ); + $this->logger->log( $severity, $msg, [ + 'url' => $url, + 'error' => $error, + ] + $prefixedApiErrorData ); + } + if ( !$status->isOK() && isset( $apiErrorData['detail'] ) ) { + $status->error( new \RawMessage( $apiErrorData['detail'] ) ); + } + + return $status; + } + + /** + * Ugly hack for the lack of an injectable MWHttpRequest factory + * @param string $url + * @param string $caller __METHOD__ + * @return MWHttpRequest + */ + protected function requestFactory( $url, $caller ) { + return MWHttpRequest::factory( $url, [ 'timeout' => 10 ], $caller ); + } + + /** + * The pageview API omits dates if there is no data. Fill it with nulls to make client-side + * processing easier. + * @param int $days + * @return array YYYY-MM-DD => null + */ + protected function getEmptyDateRange( $days ) { + if ( !$this->range ) { + $this->range = []; + // we only care about the date part, so add some hours to avoid errors when there is a + // leap second or some other weirdness + $end = $this->lastCompleteDay + 12 * 3600; + $start = $end - ( $days - 1 ) * 24 * 3600; + for ( $ts = $start; $ts <= $end; $ts += 24 * 3600 ) { + $this->range[gmdate( 'Y-m-d', $ts )] = null; + } + } + return $this->range; + } + + /** + * Get start and end timestamp in YYYYMMDDHH format + * @param int $days + * @return string[] + */ + protected function getStartEnd( $days ) { + $end = $this->lastCompleteDay + 12 * 3600; + $start = $end - ( $days - 1 ) * 24 * 3600; + return [ gmdate( 'Ymd', $start ) . '00', gmdate( 'Ymd', $end ) . '00' ]; + } +} diff --git a/tests/phpunit/ServiceWiringTest.php b/tests/phpunit/ServiceWiringTest.php new file mode 100644 index 0000000..38310d2 --- /dev/null +++ b/tests/phpunit/ServiceWiringTest.php @@ -0,0 +1,12 @@ +<?php + +namespace MediaWiki\Extensions\PageViewInfo; + +use MediaWiki\MediaWikiServices; + +class ServiceWiringTest extends \PHPUnit_Framework_TestCase { + public function testService() { + $service = MediaWikiServices::getInstance()->getService( 'PageViewService' ); + $this->assertInstanceOf( PageViewService::class, $service ); + } +} diff --git a/tests/phpunit/WikimediaPageViewServiceTest.php b/tests/phpunit/WikimediaPageViewServiceTest.php new file mode 100644 index 0000000..f9accba --- /dev/null +++ b/tests/phpunit/WikimediaPageViewServiceTest.php @@ -0,0 +1,448 @@ +<?php + +namespace MediaWiki\Extensions\PageViewInfo; + +class WikimediaPageViewServiceTest extends \PHPUnit_Framework_TestCase { + /** @var [ \PHPUnit_Framework_MockObject_MockObject, callable ] */ + protected $calls = []; + + public function setUp() { + parent::setUp(); + $this->calls = []; + } + + protected function assertThrows( $class, callable $test ) { + try { + $test(); + } catch ( \Exception $e ) { + $this->assertInstanceOf( $class, $e ); + return; + } + $this->fail( 'No exception was thrown, expected ' . $class ); + } + + /** + * Creates and returns a mock MWHttpRequest which will be used for the next call + * @param WikimediaPageViewService $service + * @param callable $assertUrl A callable that gets the URL + * @return \PHPUnit_Framework_MockObject_MockObject + */ + protected function mockNextRequest( + WikimediaPageViewService $service, callable $assertUrl = null + ) { + $mock = $this->getMockBuilder( \MWHttpRequest::class )->disableOriginalConstructor()->getMock(); + $this->calls[] = [ $mock, $assertUrl ]; + $wrapper = \TestingAccessWrapper::newFromObject( $service ); + $wrapper->requestFactory = function ( $url ) { + if ( !$this->calls ) { + $this->fail( 'Unexpected call!' ); + } + list( $mock, $assertUrl ) = array_shift( $this->calls ); + if ( $assertUrl ) { + $assertUrl( $url ); + } + return $mock; + }; + return $mock; + } + + /** + * Changes the start/end dates + * @param WikimediaPageViewService $service + * @param string $end YYYY-MM-DD + */ + protected function mockDate( WikimediaPageViewService $service, $end ) { + $wrapper = \TestingAccessWrapper::newFromObject( $service ); + $wrapper->lastCompleteDay = strtotime( $end . 'T00:00Z' ); + $wrapper->range = null; + } + + /** + * Imitate a no-data 404 error from the REST API + */ + protected function get404ErrorJson() { + return json_encode( [ + 'type' => 'https://restbase.org/errors/not_found', + 'title' => 'Not found.', + 'method' => 'get', + 'detail' => 'The date(s) you used are valid, but we either do not have data for those date(s), ' + . 'or the project you asked for is not loaded yet. Please check ' + . 'https://wikimedia.org/api/rest_v1/?doc for more information.', + 'uri' => 'whatever, won\'t be used', + ] ); + } + + public function testConstructor() { + $this->assertThrows( \InvalidArgumentException::class, function () { + new WikimediaPageViewService( 'null:', [], false ); + } ); + new WikimediaPageViewService( 'null:', [ 'project' => 'http://example.com/' ], false ); + } + + public function testGetPageData() { + $service = new WikimediaPageViewService( 'http://endpoint.example.com/', + [ 'project' => 'project.example.com' ], false ); + $this->mockDate( $service, '2000-01-05' ); + + // valid request + $mockFoo = $this->mockNextRequest( $service, function ( $url ) { + $this->assertSame( 'http://endpoint.example.com/metrics/pageviews/per-article/' + . 'project.example.com/all-access/user/Foo/daily/20000101/20000105', $url ); + } ); + $mockBar = $this->mockNextRequest( $service, function ( $url ) { + $this->assertSame( 'http://endpoint.example.com/metrics/pageviews/per-article/' + . 'project.example.com/all-access/user/Bar/daily/20000101/20000105', $url ); + } ); + foreach ( [ 'Foo' => $mockFoo, 'Bar' => $mockBar ] as $page => $mock ) { + /** @var \PHPUnit_Framework_MockObject_MockObject $mock */ + $mock->expects( $this->once() )->method( 'execute' )->willReturn( \Status::newGood() ); + $mock->expects( $this->any() )->method( 'getContent' )->willReturn( json_encode( [ + 'items' => [ + [ + 'project' => 'project.example.com', + 'article' => $page, + 'granularity' => 'daily', + 'timestamp' => '2000010100', + 'access' => 'all-access', + 'agent' => 'user', + 'views' => $page === 'Foo' ? 1000 : 500, + ], + [ + 'project' => 'project.example.com', + 'article' => $page, + 'granularity' => 'daily', + 'timestamp' => '2000010200', + 'access' => 'all-access', + 'agent' => 'user', + 'views' => $page === 'Foo' ? 100 : 50, + ], + [ + 'project' => 'project.example.com', + 'article' => $page, + 'granularity' => 'daily', + 'timestamp' => '2000010400', + 'access' => 'all-access', + 'agent' => 'user', + 'views' => $page === 'Foo' ? 10 : 5, + ], + ] + ] ) ); + $mock->expects( $this->any() )->method( 'getStatus' )->willReturn( 200 ); + } + $status = $service->getPageData( [ \Title::newFromText( 'Foo' ), + \Title::newFromText( 'Bar' ) ], 5 ); + if ( !$status->isGood() ) { + $this->fail( \Status::wrap( $status )->getWikiText() ); + } + $this->assertSame( [ + 'Foo' => [ + '2000-01-01' => 1000, + '2000-01-02' => 100, + '2000-01-03' => null, + '2000-01-04' => 10, + '2000-01-05' => null, + ], + 'Bar' => [ + '2000-01-01' => 500, + '2000-01-02' => 50, + '2000-01-03' => null, + '2000-01-04' => 5, + '2000-01-05' => null, + ], + ], $status->getValue() ); + $this->assertSame( [ 'Foo' => true, 'Bar' => true ], $status->success ); + $this->assertSame( 2, $status->successCount ); + $this->assertSame( 0, $status->failCount ); + + $this->mockDate( $service, '2000-01-01' ); + // valid, 404 and error, combined + $this->calls = []; + $mockA = $this->mockNextRequest( $service ); + $mockA->expects( $this->once() )->method( 'execute' )->willReturn( \Status::newGood() ); + $mockA->expects( $this->any() )->method( 'getContent' )->willReturn( json_encode( [ + 'items' => [ + [ + 'project' => 'project.example.com', + 'article' => 'A', + 'granularity' => 'daily', + 'timestamp' => '2000010100', + 'access' => 'all-access', + 'agent' => 'user', + 'views' => 1, + ], + ], + ] ) ); + $mockA->expects( $this->any() )->method( 'getStatus' )->willReturn( 200 ); + $mockB = $this->mockNextRequest( $service ); + $mockB->expects( $this->once() )->method( 'execute' )->willReturn( \Status::newFatal( '404' ) ); + $mockB->expects( $this->any() )->method( 'getContent' )->willReturn( $this->get404ErrorJson() ); + $mockB->expects( $this->any() )->method( 'getStatus' )->willReturn( 404 ); + $mockC = $this->mockNextRequest( $service ); + $mockC->expects( $this->once() )->method( 'execute' )->willReturn( \Status::newFatal( '500' ) ); + $mockC->expects( $this->any() )->method( 'getStatus' )->willReturn( 500 ); + $status = $service->getPageData( [ \Title::newFromText( 'A' ), + \Title::newFromText( 'B' ), \Title::newFromText( 'C' ) ], 1 ); + $this->assertFalse( $status->isGood() ); + if ( !$status->isOK() ) { + $this->fail( \Status::wrap( $status )->getWikiText() ); + } + $this->assertSame( [ + 'A' => [ + '2000-01-01' => 1, + ], + 'B' => [ + '2000-01-01' => null, + ], + 'C' => [ + '2000-01-01' => null, + ], + ], $status->getValue() ); + $this->assertTrue( $status->hasMessage( '500' ) ); + $this->assertSame( [ 'A' => true, 'B' => true, 'C' => false ], $status->success ); + $this->assertSame( 2, $status->successCount ); + $this->assertSame( 1, $status->failCount ); + + // all error out + $this->calls = []; + $mockA = $this->mockNextRequest( $service ); + $mockA->expects( $this->once() )->method( 'execute' )->willReturn( \Status::newFatal( '500' ) ); + $mockA->expects( $this->any() )->method( 'getStatus' )->willReturn( 500 ); + $mockB = $this->mockNextRequest( $service ); + $mockB->expects( $this->once() )->method( 'execute' )->willReturn( \Status::newFatal( '500' ) ); + $mockB->expects( $this->any() )->method( 'getStatus' )->willReturn( 500 ); + $status = $service->getPageData( [ \Title::newFromText( 'A' ), \Title::newFromText( 'B' ) ], 1 ); + $this->assertFalse( $status->isOK() ); + $this->assertSame( [ 'A' => false, 'B' => false ], $status->success ); + $this->assertSame( 0, $status->successCount ); + $this->assertSame( 2, $status->failCount ); + } + + public function testGetSiteData() { + $service = new WikimediaPageViewService( 'http://endpoint.example.com/', + [ 'project' => 'project.example.com' ], false ); + $this->mockDate( $service, '2000-01-05' ); + + // valid request + $mock = $this->mockNextRequest( $service, function ( $url ) { + $this->assertSame( 'http://endpoint.example.com/metrics/pageviews/aggregate/' + . 'project.example.com/all-access/user/daily/2000010100/2000010500', $url ); + } ); + $mock->expects( $this->once() )->method( 'execute' )->willReturn( \Status::newGood() ); + $mock->expects( $this->any() )->method( 'getContent' )->willReturn( json_encode( [ + 'items' => [ + [ + 'project' => 'project.example.com', + 'access' => 'all-access', + 'agent' => 'user', + 'granularity' => 'daily', + 'timestamp' => '2000010100', + 'views' => 1000, + ], + [ + 'project' => 'project.example.com', + 'access' => 'all-access', + 'agent' => 'user', + 'granularity' => 'daily', + 'timestamp' => '2000010200', + 'views' => 100, + ], + [ + 'project' => 'project.example.com', + 'access' => 'all-access', + 'agent' => 'user', + 'granularity' => 'daily', + 'timestamp' => '2000010400', + 'views' => 10, + ], + ] + ] ) ); + $mock->expects( $this->any() )->method( 'getStatus' )->willReturn( 200 ); + $status = $service->getSiteData( 5 ); + if ( !$status->isGood() ) { + $this->fail( \Status::wrap( $status )->getWikiText() ); + } + $this->assertSame( [ + '2000-01-01' => 1000, + '2000-01-02' => 100, + '2000-01-03' => null, + '2000-01-04' => 10, + '2000-01-05' => null, + ], $status->getValue() ); + + // 404 + $this->calls = []; + $mock = $this->mockNextRequest( $service ); + $mock->expects( $this->once() )->method( 'execute' )->willReturn( \Status::newFatal( '404' ) ); + $mock->expects( $this->any() )->method( 'getContent' )->willReturn( $this->get404ErrorJson() ); + $mock->expects( $this->any() )->method( 'getStatus' )->willReturn( 404 ); + $status = $service->getSiteData( 5 ); + if ( !$status->isGood() ) { + $this->fail( \Status::wrap( $status )->getWikiText() ); + } + $this->assertSame( [ + '2000-01-01' => null, + '2000-01-02' => null, + '2000-01-03' => null, + '2000-01-04' => null, + '2000-01-05' => null, + ], $status->getValue() ); + + // genuine error + $this->calls = []; + $mock = $this->mockNextRequest( $service ); + $mock->expects( $this->once() )->method( 'execute' )->willReturn( \Status::newFatal( '500' ) ); + $mock->expects( $this->any() )->method( 'getStatus' )->willReturn( 500 ); + $status = $service->getSiteData( 5 ); + $this->assertFalse( $status->isOK() ); + $this->assertTrue( $status->hasMessage( '500' ) ); + } + + public function testGetSiteData_unique() { + $service = new WikimediaPageViewService( 'http://endpoint.example.com/', + [ 'project' => 'project.example.com' ], false ); + $this->mockDate( $service, '2000-01-05' ); + + // valid request + $mock = $this->mockNextRequest( $service, function ( $url ) { + $this->assertSame( 'http://endpoint.example.com/metrics/unique-devices/' + . 'project.example.com/all-sites/daily/20000101/20000105', $url ); + } ); + $mock->expects( $this->once() )->method( 'execute' )->willReturn( \Status::newGood() ); + $mock->expects( $this->any() )->method( 'getContent' )->willReturn( json_encode( [ + 'items' => [ + [ + 'project' => 'project.example.com', + 'access-site' => 'all-sites', + 'granularity' => 'daily', + 'timestamp' => '20000101', + 'devices' => 1000, + ], + [ + 'project' => 'project.example.com', + 'access-site' => 'all-sites', + 'granularity' => 'daily', + 'timestamp' => '20000102', + 'devices' => 100, + ], + [ + 'project' => 'project.example.com', + 'access-site' => 'all-sites', + 'granularity' => 'daily', + 'timestamp' => '20000104', + 'devices' => 10, + ], + ] + ] ) ); + $mock->expects( $this->any() )->method( 'getStatus' )->willReturn( 200 ); + $status = $service->getSiteData( 5, PageViewService::METRIC_UNIQUE ); + if ( !$status->isGood() ) { + $this->fail( \Status::wrap( $status )->getWikiText() ); + } + $this->assertSame( [ + '2000-01-01' => 1000, + '2000-01-02' => 100, + '2000-01-03' => null, + '2000-01-04' => 10, + '2000-01-05' => null, + ], $status->getValue() ); + + // 404 + $this->calls = []; + $mock = $this->mockNextRequest( $service ); + $mock->expects( $this->once() )->method( 'execute' )->willReturn( \Status::newFatal( '404' ) ); + $mock->expects( $this->any() )->method( 'getContent' )->willReturn( $this->get404ErrorJson() ); + $mock->expects( $this->any() )->method( 'getStatus' )->willReturn( 404 ); + $status = $service->getSiteData( 5, PageViewService::METRIC_UNIQUE ); + if ( !$status->isGood() ) { + $this->fail( \Status::wrap( $status )->getWikiText() ); + } + $this->assertSame( [ + '2000-01-01' => null, + '2000-01-02' => null, + '2000-01-03' => null, + '2000-01-04' => null, + '2000-01-05' => null, + ], $status->getValue() ); + + // genuine error + $this->calls = []; + $mock = $this->mockNextRequest( $service ); + $mock->expects( $this->once() )->method( 'execute' )->willReturn( \Status::newFatal( '500' ) ); + $mock->expects( $this->any() )->method( 'getStatus' )->willReturn( 500 ); + $status = $service->getSiteData( 5, PageViewService::METRIC_UNIQUE ); + $this->assertFalse( $status->isOK() ); + $this->assertTrue( $status->hasMessage( '500' ) ); + } + + public function testGetTopPages() { + $service = new WikimediaPageViewService( 'http://endpoint.example.com/', + [ 'project' => 'project.example.com' ], false ); + $this->mockDate( $service, '2000-01-05' ); + + // valid request + $mock = $this->mockNextRequest( $service, function ( $url ) { + $this->assertSame( 'http://endpoint.example.com/metrics/pageviews/top/' + . 'project.example.com/all-access/2000/01/05', $url ); + } ); + $mock->expects( $this->once() )->method( 'execute' )->willReturn( \Status::newGood() ); + $mock->expects( $this->any() )->method( 'getContent' )->willReturn( json_encode( [ + 'items' => [ + [ + 'project' => 'project.example.com', + 'access' => 'all-access', + 'year' => '2000', + 'month' => '01', + 'day' => '05', + 'articles' => [ + [ + 'article' => 'Main_Page', + 'views' => 1000, + 'rank' => 1, + ], + [ + 'article' => 'Special:Search', + 'views' => 100, + 'rank' => 2, + ], + [ + 'article' => '404.php', + 'views' => 10, + 'rank' => 3, + ], + ], + ], + ] + ] ) ); + $mock->expects( $this->any() )->method( 'getStatus' )->willReturn( 200 ); + $status = $service->getTopPages(); + if ( !$status->isGood() ) { + $this->fail( \Status::wrap( $status )->getWikiText() ); + } + $this->assertSame( [ + 'Main_Page' => 1000, + 'Special:Search' => 100, + '404.php' => 10, + ], $status->getValue() ); + + // 404 + $this->calls = []; + $mock = $this->mockNextRequest( $service ); + $mock->expects( $this->once() )->method( 'execute' )->willReturn( \Status::newFatal( '404' ) ); + $mock->expects( $this->any() )->method( 'getContent' )->willReturn( $this->get404ErrorJson() ); + $mock->expects( $this->any() )->method( 'getStatus' )->willReturn( 404 ); + $status = $service->getTopPages(); + if ( !$status->isGood() ) { + $this->fail( \Status::wrap( $status )->getWikiText() ); + } + $this->assertSame( [], $status->getValue() ); + + // genuine error + $this->calls = []; + $mock = $this->mockNextRequest( $service ); + $mock->expects( $this->once() )->method( 'execute' )->willReturn( \Status::newFatal( '500' ) ); + $mock->expects( $this->any() )->method( 'getStatus' )->willReturn( 500 ); + $status = $service->getTopPages(); + $this->assertFalse( $status->isOK() ); + $this->assertTrue( $status->hasMessage( '500' ) ); + } +} diff --git a/tests/smoke/WikimediaPageViewServiceSmokeTest.php b/tests/smoke/WikimediaPageViewServiceSmokeTest.php new file mode 100644 index 0000000..3c47cc3 --- /dev/null +++ b/tests/smoke/WikimediaPageViewServiceSmokeTest.php @@ -0,0 +1,114 @@ +<?php + +namespace MediaWiki\Extensions\PageViewInfo; + +use Status; +use StatusValue; + +class WikimediaPageViewServiceSmokeTest extends \PHPUnit_Framework_TestCase { + protected $data; + + protected function getService() { + global $wgPageViewInfoWikimediaEndpoint; + return new WikimediaPageViewService( $wgPageViewInfoWikimediaEndpoint, + [ 'project' => 'en.wikipedia.org' ], 3 ); + } + + public function testGetPageData() { + $service = $this->getService(); + $randomTitle = ucfirst( \MWCryptRand::generateHex( 32 ) ); + $titles = [ 'Main_Page', 'Mycotoxin', $randomTitle ]; + $status = $service->getPageData( array_map( function ( $t ) { + return \Title::newFromText( $t ); + }, $titles ), 5 ); + if ( !$status->isOK() ) { + $this->fail( \Status::wrap( $status )->getWikiText() ); + } + $data = $status->getValue(); + $this->assertInternalType( 'array', $data, $this->debug( $data, $status ) ); + $this->assertCount( 3, $data, $this->debug( $data, $status ) ); + $day = gmdate( 'Y-m-d', time() - 3 * 24 * 3600 ); + foreach ( $titles as $title ) { + $this->assertArrayHasKey( $title, $data, $this->debug( $data, $status ) ); + $this->assertInternalType( 'array', $data[$title], $this->debug( $data, $status ) ); + $this->assertCount( 5, $data[$title], $this->debug( $data, $status ) ); + $this->assertArrayHasKey( $day, $data[$title], $this->debug( $data, $status ) ); + } + $this->assertInternalType( 'int', $data['Main_Page'][$day], $this->debug( $data, $status ) ); + $this->assertGreaterThan( 1000, $data['Main_Page'][$day], $this->debug( $data, $status ) ); + $this->assertInternalType( 'int', $data['Mycotoxin'][$day], $this->debug( $data, $status ) ); + $this->assertLessThan( 1000, $data['Mycotoxin'][$day], $this->debug( $data, $status ) ); + $this->assertNull( $data[$randomTitle][$day], $this->debug( $data, $status ) ); + } + + public function testGetSiteData() { + $service = $this->getService(); + $status = $service->getSiteData( 5 ); + if ( !$status->isOK() ) { + $this->fail( \Status::wrap( $status )->getWikiText() ); + } + $data = $status->getValue(); + $this->assertInternalType( 'array', $data, $this->debug( $data, $status ) ); + $this->assertCount( 5, $data, $this->debug( $data, $status ) ); + $day = gmdate( 'Y-m-d', time() - 3 * 24 * 3600 ); + $this->assertArrayHasKey( $day, $data, $this->debug( $data, $status ) ); + $this->assertInternalType( 'int', $data[$day], $this->debug( $data, $status ) ); + $this->assertGreaterThan( 100000, $data[$day], $this->debug( $data, $status ) ); + } + + public function testGetSiteData_unique() { + $service = $this->getService(); + $status = $service->getSiteData( 5, PageViewService::METRIC_UNIQUE ); + if ( !$status->isOK() ) { + $this->fail( \Status::wrap( $status )->getWikiText() ); + } + $data = $status->getValue(); + $this->assertInternalType( 'array', $data, $this->debug( $data, $status ) ); + $this->assertCount( 5, $data, $this->debug( $data, $status ) ); + $day = gmdate( 'Y-m-d', time() - 3 * 24 * 3600 ); + $this->assertArrayHasKey( $day, $data, $this->debug( $data, $status ) ); + $this->assertInternalType( 'int', $data[$day], $this->debug( $data, $status ) ); + $this->assertGreaterThan( 100000, $data[$day], $this->debug( $data, $status ) ); + } + + public function testGetTopPages() { + $service = $this->getService(); + $status = $service->getTopPages(); + if ( !$status->isOK() ) { + $this->fail( \Status::wrap( $status )->getWikiText() ); + } + $data = $status->getValue(); + $this->assertInternalType( 'array', $data, $this->debug( $data, $status ) ); + $this->assertArrayHasKey( 'Main_Page', $data, $this->debug( $data, $status ) ); + $this->assertSame( 'Main_Page', key( $data ), $this->debug( $data, $status ) ); + $this->assertGreaterThan( 100000, $data['Main_Page'], $this->debug( $data, $status ) ); + } + + public function testRequestError() { + $service = $this->getService(); + $wrapper = \TestingAccessWrapper::newFromObject( $service ); + $wrapper->access = 'fail'; + $logger = new \TestLogger( true, null, true ); + $service->setLogger( $logger ); + $status = $service->getPageData( [ \Title::newFromText( 'Main_Page' ) ], 5 ); + $this->assertFalse( $status->isOK() ); + $logBuffer = $logger->getBuffer(); + $this->assertNotEmpty( $logBuffer ); + $this->assertArrayHasKey( 'apierror_type', $logBuffer[0][2] ); + $this->assertSame( 'https://mediawiki.org/wiki/HyperSwitch/errors/bad_request', + $logBuffer[0][2]['apierror_type'] ); + } + + /** + * @param array $data + * @param StatusValue $status + * @return string + */ + protected function debug( $data, $status ) { + $debug = 'Assertion failed for data:' . PHP_EOL . var_export( $data, true ); + if ( !$status->isGood() ) { + $debug .= PHP_EOL . 'Status:' . PHP_EOL . Status::wrap( $status )->getWikiText(); + } + return $debug; + } +} -- To view, visit https://gerrit.wikimedia.org/r/320324 To unsubscribe, visit https://gerrit.wikimedia.org/r/settings Gerrit-MessageType: newchange Gerrit-Change-Id: I0ef75e0b94994270992ef07a1698c99820ff7ff3 Gerrit-PatchSet: 1 Gerrit-Project: mediawiki/extensions/PageViewInfo Gerrit-Branch: master Gerrit-Owner: Gergő Tisza <gti...@wikimedia.org> _______________________________________________ MediaWiki-commits mailing list MediaWiki-commits@lists.wikimedia.org https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits