mediawiki-extensions-Visual.../includes/ParsoidHelper.php
Bartosz Dziewoński 951348dbbf ParsoidHelper: Stop logging missing etags
While a missing etag on a RESTBase wiki still indicates a problem,
we worked around these problems long ago in T233320 and we don't
really care about the logs any more, and the logging is now dominated
by requests on private wikis where etags are not expected.

Bug: T316234
Change-Id: I4cc29847524863af2c5642cb60371893533a9df8
2022-08-25 21:23:35 +02:00

334 lines
11 KiB
PHP

<?php
/**
* Helper functions for contacting Parsoid/RESTBase.
*
* Because clearly we don't have enough APIs yet for accomplishing this Herculean task.
*
* @file
* @ingroup Extensions
* @copyright 2022 VisualEditor Team and others; see AUTHORS.txt
* @license MIT
*/
namespace MediaWiki\Extension\VisualEditor;
use Config;
use ConfigException;
use Language;
use MediaWiki\MediaWikiServices;
use MediaWiki\Revision\RevisionRecord;
use ParsoidVirtualRESTService;
use Psr\Log\LoggerInterface;
use RestbaseVirtualRESTService;
use StatusValue;
use Title;
use VirtualRESTService;
use VirtualRESTServiceClient;
use Wikimedia\Assert\Assert;
class ParsoidHelper {
/**
* A direct Parsoid client for zero-configuration mode.
* Initially `false`, then once we determine whether we're using zeroconf
* mode or not then it will be a ?VisualEditorParsoidClient.
* @var VisualEditorParsoidClient|null|false
*/
private $directClient = false;
/** @var VirtualRESTServiceClient */
private $serviceClient = null;
/** @var Config */
private $config;
/** @var LoggerInterface */
private $logger;
/** @var string|false */
private $forwardCookies;
/**
* @param Config $config
* @param LoggerInterface $logger
* @param string|false $forwardCookies
*/
public function __construct( Config $config, LoggerInterface $logger, $forwardCookies ) {
$this->config = $config;
$this->logger = $logger;
$this->forwardCookies = $forwardCookies;
}
/**
* Fetches the VisualEditorParsoidClient used for direct access to
* Parsoid.
* @return ?VisualEditorParsoidClient null if a VirtualRESTService is
* to be used.
*/
private function getDirectClient(): ?VisualEditorParsoidClient {
if ( $this->directClient === false ) {
// We haven't checked configuration yet.
// Check to see if any of the restbase-related configuration
// variables are set, and bail if so:
$vrs = $this->config->get( 'VirtualRestConfig' );
if ( isset( $vrs['modules'] ) &&
( isset( $vrs['modules']['restbase'] ) ||
isset( $vrs['modules']['parsoid'] ) )
) {
$this->directClient = null;
return null;
}
// Eventually we'll do something fancy, but I'm hacking here...
global $wgVisualEditorParsoidAutoConfig;
if ( !$wgVisualEditorParsoidAutoConfig ) {
// explicit opt out
$this->directClient = null;
return null;
}
// Default to using the direct client.
$this->directClient = VisualEditorParsoidClient::factory();
}
return $this->directClient;
}
/**
* Creates the virtual REST service object to be used in VE's API calls. The
* method determines whether to instantiate a ParsoidVirtualRESTService or a
* RestbaseVirtualRESTService object based on configuration directives: if
* $wgVirtualRestConfig['modules']['restbase'] is defined, RESTBase is chosen,
* otherwise Parsoid is used (either by using the MW Core config, or the
* VE-local one).
*
* @return VirtualRESTService the VirtualRESTService object to use
*/
private function getVRSObject(): VirtualRESTService {
Assert::precondition(
!$this->getDirectClient(),
"Direct Parsoid access is configured but the VirtualRESTService was used"
);
global $wgVisualEditorParsoidAutoConfig;
// the params array to create the service object with
$params = [];
// the VRS class to use, defaults to Parsoid
$class = ParsoidVirtualRESTService::class;
// The global virtual rest service config object, if any
$vrs = $this->config->get( 'VirtualRestConfig' );
if ( isset( $vrs['modules'] ) && isset( $vrs['modules']['restbase'] ) ) {
// if restbase is available, use it
$params = $vrs['modules']['restbase'];
// backward compatibility
$params['parsoidCompat'] = false;
$class = RestbaseVirtualRESTService::class;
} elseif ( isset( $vrs['modules'] ) && isset( $vrs['modules']['parsoid'] ) ) {
// there's a global parsoid config, use it next
$params = $vrs['modules']['parsoid'];
$params['restbaseCompat'] = true;
} elseif ( $wgVisualEditorParsoidAutoConfig ) {
$params = $vrs['modules']['parsoid'] ?? [];
$params['restbaseCompat'] = true;
// forward cookies on private wikis
$params['forwardCookies'] = !MediaWikiServices::getInstance()
->getPermissionManager()->isEveryoneAllowed( 'read' );
} else {
// No global modules defined, so no way to contact the document server.
throw new ConfigException( "The VirtualRESTService for the document server is not defined;" .
" see https://www.mediawiki.org/wiki/Extension:VisualEditor" );
}
// merge the global and service-specific params
if ( isset( $vrs['global'] ) ) {
$params = array_merge( $vrs['global'], $params );
}
// set up cookie forwarding
if ( $params['forwardCookies'] ) {
$params['forwardCookies'] = $this->forwardCookies;
} else {
$params['forwardCookies'] = false;
}
// create the VRS object and return it
return new $class( $params );
}
/**
* Creates the object which directs queries to the virtual REST service, depending on the path.
*
* @return VirtualRESTServiceClient
*/
private function getVRSClient(): VirtualRESTServiceClient {
if ( !$this->serviceClient ) {
$this->serviceClient = new VirtualRESTServiceClient(
MediaWikiServices::getInstance()->getHttpRequestFactory()->createMultiClient() );
$this->serviceClient->mount( '/restbase/', $this->getVRSObject() );
}
return $this->serviceClient;
}
/**
* Accessor function for all RESTbase requests
*
* @param Title $title The title of the page to use as the parsing context
* @param string $method The HTTP method, either 'GET' or 'POST'
* @param string $path The RESTbase api path
* @param array $params Request parameters
* @param array $reqheaders Request headers
* @return StatusValue If successful, the value is the RESTbase server's response as an array
* with keys 'code', 'reason', 'headers' and 'body'
*/
private function requestRestbase(
Title $title, string $method, string $path, array $params, array $reqheaders = []
): StatusValue {
// Should be synchronised with requestParsoidData() in
// modules/ve-mw/preinit/ve.init.mw.ArticleTargetLoader.js
$profile = 'https://www.mediawiki.org/wiki/Specs/HTML/' .
VisualEditorParsoidClient::PARSOID_VERSION;
$reqheaders += [
'Accept' =>
"text/html; charset=utf-8; profile=\"$profile\"",
'Accept-Language' => $title->getPageLanguage()->getCode(),
'User-Agent' => 'VisualEditor-MediaWiki/' . MW_VERSION,
'Api-User-Agent' => 'VisualEditor-MediaWiki/' . MW_VERSION,
'Promise-Non-Write-API-Action' => 'true',
];
$request = [
'method' => $method,
'url' => '/restbase/local/v1/' . $path,
( $method === 'GET' ? 'query' : 'body' ) => $params,
'headers' => $reqheaders,
];
$response = $this->getVRSClient()->run( $request );
if ( $response['error'] !== '' ) {
return StatusValue::newFatal( 'apierror-visualeditor-docserver-http-error',
wfEscapeWikiText( $response['error'] ) );
} elseif ( $response['code'] !== 200 ) {
// error null, code not 200
$json = json_decode( $response['body'], true );
$message = $json['detail'] ?? '(no message)';
return StatusValue::newFatal( 'apierror-visualeditor-docserver-http', $response['code'], $message );
}
return StatusValue::newGood( $response );
}
/**
* Request page HTML from RESTBase
*
* @param RevisionRecord $revision Page revision
* @param ?Language $pageLanguage Page language (default: `null`)
* @return StatusValue If successful, the value is the RESTbase server's response as an array
* with keys 'code', 'reason', 'headers' and 'body'
*/
public function requestRestbasePageHtml( RevisionRecord $revision, ?Language $pageLanguage = null ): StatusValue {
$title = Title::newFromLinkTarget( $revision->getPageAsLinkTarget() );
$client = $this->getDirectClient();
if ( $client ) {
return StatusValue::newGood( $client->getPageHtml(
$revision, $pageLanguage ?: $title->getPageLanguage()
) );
}
return $this->requestRestbase(
$title,
'GET',
'page/html/' . urlencode( $title->getPrefixedDBkey() ) .
'/' . $revision->getId() .
'?redirect=false&stash=true',
[],
[
'Accept-Language' => ( $pageLanguage ?: $title->getPageLanguage() )->getCode(),
]
);
}
/**
* Transform HTML to wikitext via Parsoid through RESTbase. Wrapper for ::postData().
*
* @param Title $title The title of the page
* @param string $html The HTML of the page to be transformed
* @param int|null $oldid What oldid revision, if any, to base the request from (default: `null`)
* @param string|null $etag The ETag to set in the HTTP request header
* @param ?Language $pageLanguage Page language (default: `null`)
* @return StatusValue If successful, the value is the RESTbase server's response as an array
* with keys 'code', 'reason', 'headers' and 'body'
*/
public function transformHTML(
Title $title, string $html, int $oldid = null, string $etag = null, ?Language $pageLanguage = null
): StatusValue {
$client = $this->getDirectClient();
if ( $client ) {
return StatusValue::newGood( $client->transformHtml(
$title, $pageLanguage ?: $title->getPageLanguage(), $html, $oldid, $etag
) );
}
$data = [ 'html' => $html ];
$path = 'transform/html/to/wikitext/' . urlencode( $title->getPrefixedDBkey() ) .
( $oldid === null ? '' : '/' . $oldid );
// Adapted from RESTBase mwUtil.parseETag()
// ETag is not expected when:
// * Doing anything on a non-RESTBase wiki
// * Creating a new page on a RESTBase wiki (oldid=0)
if ( $etag !== null && !( preg_match( '/
^(?:W\\/)?"?
' . preg_quote( "$oldid", '/' ) . '
(?:\\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}))
(?:\\/([^"]+))?
"?$
/x', $etag ) ) ) {
$this->logger->info(
__METHOD__ . ": Received funny ETag from client: '{etag}'",
[
'etag' => $etag,
'oldid' => $oldid,
'requestPath' => $path,
]
);
}
return $this->requestRestbase(
$title,
'POST', $path, $data,
[
'If-Match' => $etag,
'Accept-Language' => ( $pageLanguage ?: $title->getPageLanguage() )->getCode(),
]
);
}
/**
* Transform wikitext to HTML via Parsoid through RESTbase. Wrapper for ::postData().
*
* @param Title $title The title of the page to use as the parsing context
* @param string $wikitext The wikitext fragment to parse
* @param bool $bodyOnly Whether to provide only the contents of the `<body>` tag
* @param int|null $oldid What oldid revision, if any, to base the request from (default: `null`)
* @param bool $stash Whether to stash the result in the server-side cache (default: `false`)
* @param ?Language $pageLanguage Page language (default: `null`)
* @return StatusValue If successful, the value is the RESTbase server's response as an array
* with keys 'code', 'reason', 'headers' and 'body'
*/
public function transformWikitext(
Title $title, string $wikitext, bool $bodyOnly, int $oldid = null, bool $stash = false,
?Language $pageLanguage = null
): StatusValue {
$client = $this->getDirectClient();
if ( $client ) {
return StatusValue::newGood( $client->transformWikitext(
$title, $pageLanguage ?: $title->getPageLanguage(),
$wikitext, $bodyOnly, $oldid, $stash
) );
}
return $this->requestRestbase(
$title,
'POST',
'transform/wikitext/to/html/' . urlencode( $title->getPrefixedDBkey() ) .
( $oldid === null ? '' : '/' . $oldid ),
[
'wikitext' => $wikitext,
'body_only' => $bodyOnly ? 1 : 0,
'stash' => $stash ? 1 : 0
],
[
'Accept-Language' => ( $pageLanguage ?: $title->getPageLanguage() )->getCode(),
]
);
}
}