From bfe4ddd8108efe00f9395397e92f65f6c66b6a4d Mon Sep 17 00:00:00 2001 From: Kunal Mehta Date: Tue, 14 Oct 2014 17:31:15 -0700 Subject: [PATCH] Implement extension registration from an extension.json file Introduces wfLoadExtension()/wfLoadSkin() which should be used in LocalSettings.php rather than require-ing a PHP entry point. Extensions and skins would add "extension.json" or "skin.json" files in their root, which contains all the information typically present in PHP entry point files (classes to autoload, special pages, API modules, etc.) A full schema can be found at docs/extension.schema.json, and a script to validate these to the schema is provided. An additional script is provided to convert typical PHP entry point files into their JSON equivalents. The basic flow of loading an extension goes like: * Get the ExtensionRegistry singleton instance * ExtensionRegistry takes a filename, reads the file or tries to get the parsed JSON from APC if possible. * The JSON is run through a Processor instance, which registers things with the appropriate global settings. * The output of the processor is cached in APC if possible. * The extension/skin is marked as loaded in the ExtensionRegistry and a callback function is executed if one was specified. For ideal performance, a batch loading method is also provided: * The absolute path name to the JSON file is queued in the ExtensionRegistry instance. * When loadFromQueue() is called, it constructs a hash unique to the members of the current queue, and sees if the queue has been cached in APC. If not, it processes each file individually, and combines the result of each Processor into one giant array, which is cached in APC. * The giant array then sets various global settings, defines constants, and calls callbacks. To invalidate the cached processed info, by default the mtime of each JSON file is checked. However that can be slow if you have a large number of extensions, so you can set $wgExtensionInfoMTime to the mtime of one file, and `touch` it whenever you update your extensions. Change-Id: I7074b65d07c5c7d4e3f1fb0755d74a0b07ed4596 --- autoload.php | 5 + composer.json | 1 + docs/extension.schema.json | 609 +++++++++++++++++++++ includes/DefaultSettings.php | 17 + includes/GlobalFunctions.php | 74 +++ includes/Setup.php | 5 +- includes/WebStart.php | 3 + includes/registration/ExtensionProcessor.php | 259 +++++++++ includes/registration/ExtensionRegistry.php | 260 +++++++++ includes/registration/Processor.php | 27 + maintenance/convertExtensionToRegistration.php | 113 ++++ maintenance/doMaintenance.php | 1 + maintenance/validateRegistrationFile.php | 37 ++ .../registration/ExtensionProcessorTest.php | 135 +++++ 14 files changed, 1545 insertions(+), 1 deletion(-) create mode 100644 docs/extension.schema.json create mode 100644 includes/registration/ExtensionProcessor.php create mode 100644 includes/registration/ExtensionRegistry.php create mode 100644 includes/registration/Processor.php create mode 100644 maintenance/convertExtensionToRegistration.php create mode 100644 maintenance/validateRegistrationFile.php create mode 100644 tests/phpunit/includes/registration/ExtensionProcessorTest.php diff --git a/autoload.php b/autoload.php index 208ab171369..f4935b8c837 100644 --- a/autoload.php +++ b/autoload.php @@ -244,6 +244,7 @@ $wgAutoloadLocalClasses = array( 'ContentHandler' => __DIR__ . '/includes/content/ContentHandler.php', 'ContextSource' => __DIR__ . '/includes/context/ContextSource.php', 'ContribsPager' => __DIR__ . '/includes/specials/SpecialContributions.php', + 'ConvertExtensionToRegistration' => __DIR__ . '/maintenance/convertExtensionToRegistration.php', 'ConvertLinks' => __DIR__ . '/maintenance/convertLinks.php', 'ConvertUserOptions' => __DIR__ . '/maintenance/convertUserOptions.php', 'ConverterRule' => __DIR__ . '/languages/ConverterRule.php', @@ -373,6 +374,8 @@ $wgAutoloadLocalClasses = array( 'ExplodeIterator' => __DIR__ . '/includes/utils/StringUtils.php', 'ExportProgressFilter' => __DIR__ . '/maintenance/backup.inc', 'ExtensionLanguages' => __DIR__ . '/maintenance/language/languages.inc', + 'ExtensionProcessor' => __DIR__ . '/includes/registration/ExtensionProcessor.php', + 'ExtensionRegistry' => __DIR__ . '/includes/registration/ExtensionRegistry.php', 'ExternalStore' => __DIR__ . '/includes/externalstore/ExternalStore.php', 'ExternalStoreDB' => __DIR__ . '/includes/externalstore/ExternalStoreDB.php', 'ExternalStoreHttp' => __DIR__ . '/includes/externalstore/ExternalStoreHttp.php', @@ -888,6 +891,7 @@ $wgAutoloadLocalClasses = array( 'Preprocessor_DOM' => __DIR__ . '/includes/parser/Preprocessor_DOM.php', 'Preprocessor_Hash' => __DIR__ . '/includes/parser/Preprocessor_Hash.php', 'ProcessCacheLRU' => __DIR__ . '/includes/libs/ProcessCacheLRU.php', + 'Processor' => __DIR__ . '/includes/registration/Processor.php', 'ProfileSection' => __DIR__ . '/includes/profiler/ProfileSection.php', 'Profiler' => __DIR__ . '/includes/profiler/Profiler.php', 'ProfilerOutput' => __DIR__ . '/includes/profiler/output/ProfilerOutput.php', @@ -1263,6 +1267,7 @@ $wgAutoloadLocalClasses = array( 'UsersPager' => __DIR__ . '/includes/specials/SpecialListusers.php', 'UtfNormal' => __DIR__ . '/includes/normal/UtfNormal.php', 'UzConverter' => __DIR__ . '/languages/classes/LanguageUz.php', + 'ValidateRegistrationFile' => __DIR__ . '/maintenance/validateRegistrationFile.php', 'ViewAction' => __DIR__ . '/includes/actions/ViewAction.php', 'VirtualRESTService' => __DIR__ . '/includes/libs/virtualrest/VirtualRESTService.php', 'VirtualRESTServiceClient' => __DIR__ . '/includes/libs/virtualrest/VirtualRESTServiceClient.php', diff --git a/composer.json b/composer.json index 59017213697..6591ac7f785 100644 --- a/composer.json +++ b/composer.json @@ -24,6 +24,7 @@ "oojs/oojs-ui": "0.6.1" }, "require-dev": { + "justinrainbow/json-schema": "~1.3", "phpunit/phpunit": "*" }, "suggest": { diff --git a/docs/extension.schema.json b/docs/extension.schema.json new file mode 100644 index 00000000000..4b29872eefc --- /dev/null +++ b/docs/extension.schema.json @@ -0,0 +1,609 @@ +{ + "$schema": "http://json-schema.org/schema#", + "description": "MediaWiki extension.json schema", + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The extension's canonical name." + }, + "info-files": { + "type": "array", + "description": "A list of filenames that should be loaded, in addition to this one" + }, + "type": { + "type": "string", + "description": "The extension's type, as an index to $wgExtensionCredits.", + "default": "other", + "enum": [ + "api", + "antispam", + "datavalues", + "media", + "parserhook", + "semantic", + "skin", + "specialpage", + "variable", + "other" + ] + }, + "author": { + "type": [ + "string", + "array" + ], + "description": "Extension's authors.", + "items": { + "type": "string" + }, + "additionalItems": false + }, + "path": { + "type": "string" + }, + "version": { + "type": "string", + "description": "The version of this release of the extension." + }, + "url": { + "type": "string", + "description": "URL to the homepage for the extension.", + "format": "uri" + }, + "description": { + "type": "string", + "description": "Raw description of the extension." + }, + "descriptionmsg": { + "type": "string", + "description": "Message key for a i18n message describing the extension." + }, + "license-name": { + "type": "string", + "description": "Short identifier for the license under which the extension is released.", + "enum": [ + "AFL-1.1", + "AFL-1.2", + "AFL-2.0", + "AFL-2.1", + "AFL-3.0", + "APL-1.0", + "Aladdin", + "ANTLR-PD", + "Apache-1.0", + "Apache-1.1", + "Apache-2.0", + "APSL-1.0", + "APSL-1.1", + "APSL-1.2", + "APSL-2.0", + "Artistic-1.0", + "Artistic-1.0-cl8", + "Artistic-1.0-Perl", + "Artistic-2.0", + "AAL", + "BitTorrent-1.0", + "BitTorrent-1.1", + "BSL-1.0", + "BSD-2-Clause", + "BSD-2-Clause-FreeBSD", + "BSD-2-Clause-NetBSD", + "BSD-3-Clause", + "BSD-3-Clause-Clear", + "BSD-4-Clause", + "BSD-4-Clause-UC", + "CECILL-1.0", + "CECILL-1.1", + "CECILL-2.0", + "CECILL-B", + "CECILL-C", + "ClArtistic", + "CNRI-Python", + "CNRI-Python-GPL-Compatible", + "CPOL-1.02", + "CDDL-1.0", + "CDDL-1.1", + "CPAL-1.0", + "CPL-1.0", + "CATOSL-1.1", + "Condor-1.1", + "CC-BY-1.0", + "CC-BY-2.0", + "CC-BY-2.5", + "CC-BY-3.0", + "CC-BY-ND-1.0", + "CC-BY-ND-2.0", + "CC-BY-ND-2.5", + "CC-BY-ND-3.0", + "CC-BY-NC-1.0", + "CC-BY-NC-2.0", + "CC-BY-NC-2.5", + "CC-BY-NC-3.0", + "CC-BY-NC-ND-1.0", + "CC-BY-NC-ND-2.0", + "CC-BY-NC-ND-2.5", + "CC-BY-NC-ND-3.0", + "CC-BY-NC-SA-1.0", + "CC-BY-NC-SA-2.0", + "CC-BY-NC-SA-2.5", + "CC-BY-NC-SA-3.0", + "CC-BY-SA-1.0", + "CC-BY-SA-2.0", + "CC-BY-SA-2.5", + "CC-BY-SA-3.0", + "CC0-1.0", + "CUA-OPL-1.0", + "D-FSL-1.0", + "WTFPL", + "EPL-1.0", + "eCos-2.0", + "ECL-1.0", + "ECL-2.0", + "EFL-1.0", + "EFL-2.0", + "Entessa", + "ErlPL-1.1", + "EUDatagrid", + "EUPL-1.0", + "EUPL-1.1", + "Fair", + "Frameworx-1.0", + "FTL", + "AGPL-1.0", + "AGPL-3.0", + "GFDL-1.1", + "GFDL-1.2", + "GFDL-1.3", + "GPL-1.0", + "GPL-1.0+", + "GPL-2.0", + "GPL-2.0+", + "GPL-2.0-with-autoconf-exception", + "GPL-2.0-with-bison-exception", + "GPL-2.0-with-classpath-exception", + "GPL-2.0-with-font-exception", + "GPL-2.0-with-GCC-exception", + "GPL-3.0", + "GPL-3.0+", + "GPL-3.0-with-autoconf-exception", + "GPL-3.0-with-GCC-exception", + "LGPL-2.1", + "LGPL-2.1+", + "LGPL-3.0", + "LGPL-3.0+", + "LGPL-2.0", + "LGPL-2.0+", + "gSOAP-1.3b", + "HPND", + "IBM-pibs", + "IPL-1.0", + "Imlib2", + "IJG", + "Intel", + "IPA", + "ISC", + "JSON", + "LPPL-1.3a", + "LPPL-1.0", + "LPPL-1.1", + "LPPL-1.2", + "LPPL-1.3c", + "Libpng", + "LPL-1.02", + "LPL-1.0", + "MS-PL", + "MS-RL", + "MirOS", + "MIT", + "Motosoto", + "MPL-1.0", + "MPL-1.1", + "MPL-2.0", + "MPL-2.0-no-copyleft-exception", + "Multics", + "NASA-1.3", + "Naumen", + "NBPL-1.0", + "NGPL", + "NOSL", + "NPL-1.0", + "NPL-1.1", + "Nokia", + "NPOSL-3.0", + "NTP", + "OCLC-2.0", + "ODbL-1.0", + "PDDL-1.0", + "OGTSL", + "OLDAP-2.2.2", + "OLDAP-1.1", + "OLDAP-1.2", + "OLDAP-1.3", + "OLDAP-1.4", + "OLDAP-2.0", + "OLDAP-2.0.1", + "OLDAP-2.1", + "OLDAP-2.2", + "OLDAP-2.2.1", + "OLDAP-2.3", + "OLDAP-2.4", + "OLDAP-2.5", + "OLDAP-2.6", + "OLDAP-2.7", + "OPL-1.0", + "OSL-1.0", + "OSL-2.0", + "OSL-2.1", + "OSL-3.0", + "OLDAP-2.8", + "OpenSSL", + "PHP-3.0", + "PHP-3.01", + "PostgreSQL", + "Python-2.0", + "QPL-1.0", + "RPSL-1.0", + "RPL-1.1", + "RPL-1.5", + "RHeCos-1.1", + "RSCPL", + "Ruby", + "SAX-PD", + "SGI-B-1.0", + "SGI-B-1.1", + "SGI-B-2.0", + "OFL-1.0", + "OFL-1.1", + "SimPL-2.0", + "Sleepycat", + "SMLNJ", + "SugarCRM-1.1.3", + "SISSL", + "SISSL-1.2", + "SPL-1.0", + "Watcom-1.0", + "NCSA", + "VSL-1.0", + "W3C", + "WXwindows", + "Xnet", + "X11", + "XFree86-1.1", + "YPL-1.0", + "YPL-1.1", + "Zimbra-1.3", + "Zlib", + "ZPL-1.1", + "ZPL-2.0", + "ZPL-2.1", + "Unlicense" + ] + }, + "ResourceLoaderModules": { + "type": "object", + "description": "ResourceLoader modules to register", + "additionalProperties": false, + "patternProperties": { + "^[a-zA-Z0-9\\.]+$": { + "type": "object", + "description": "A single ResourceLoader module descriptor", + "properties": { + "localBasePath": { + "type": "string", + "description": "Base path to prepend to all local paths in $options. Defaults to $IP" + }, + "remoteBasePath": { + "type": "string", + "description": "Base path to prepend to all remote paths in $options. Defaults to $wgScriptPath" + }, + "remoteExtPath": { + "type": "string", + "description": "Equivalent of remoteBasePath, but relative to $wgExtensionAssetsPath" + }, + "scripts": { + "type": "array", + "description": "Scripts to always include (array of file paths)", + "items": { + "type": "string" + } + }, + "languageScripts": { + "type": "object", + "description": "Scripts to include in specific language contexts (mapping of language code to file path(s))", + "patternProperties": { + "^[a-zA-Z0-9-]{2,}$": { + "type": [ + "string", + "array" + ], + "items": { + "type": "string" + } + } + } + }, + "skinScripts": { + "type": "object", + "description": "Scripts to include in specific skin contexts (mapping of skin name to script(s)", + "patternProperties": { + ".+": { + "type": [ + "string", + "array" + ], + "items": { + "type": "string" + } + } + } + }, + "debugScripts": { + "type": "array", + "description": "Scripts to include in debug contexts", + "items": { + "type": "string" + } + }, + "loaderScripts": { + "type": "array", + "description": "Scripts to include in the startup module", + "items": { + "type": "string" + } + }, + "dependencies": { + "type": "array", + "description": "Modules which must be loaded before this module", + "items": { + "type": "string" + } + }, + "styles": { + "type": "array", + "description": "Styles to always load", + "items": { + "type": "string" + } + }, + "skinStyles": { + "type": "object", + "description": "Styles to include in specific skin contexts (mapping of skin name to style(s))", + "patternProperties": { + ".+": { + "type": [ + "string", + "array" + ], + "items": { + "type": "string" + } + } + } + }, + "messages": { + "type": "array", + "description": "Messages to always load", + "items": { + "type": "string" + } + }, + "group": { + "type": "string", + "description": "Group which this module should be loaded together with" + }, + "position": { + "type": "string", + "description": "Position on the page to load this module at", + "enum": [ + "bottom", + "top" + ] + } + } + } + } + }, + "ResourceLoaderSources": { + "type": "object", + "description": "ResourceLoader sources to register" + }, + "ResourceLoaderLESSVars": { + "type": "object", + "description": "ResourceLoader LESS variables" + }, + "ResourceLoaderLESSFunctions": { + "type": "object", + "description": "ResourceLoader LESS functions" + }, + "ResourceLoaderLESSImportPaths": { + "type": "object", + "description": "ResourceLoader import paths" + }, + "namespaces": { + "type": "object", + "description": "Method to add extra namespaces", + "properties": { + "id": { + "type": "integer" + }, + "constant": { + "type": "string" + }, + "name": { + "type": "string" + }, + "gender": { + "type": "object", + "properties": { + "male": { + "type": "string" + }, + "female": { + "type": "string" + } + } + }, + "subpages": { + "type": "boolean", + "default": false + }, + "content": { + "type": "boolean", + "default": false + }, + "defaultcontentmodel": { + "type": "string" + } + } + }, + "TrackingCategories": { + "type": "array", + "description": "Tracking category message keys" + }, + "DefaultUserOptions": { + "type": "object", + "description": "Default values of user options" + }, + "HiddenPrefs": { + "type": "array", + "description": "Preferences users cannot set" + }, + "GroupPermissions": { + "type": "object", + "description": "Default permissions to give to user groups" + }, + "RevokePermissions": { + "type": "object", + "description": "Default permissions to revoke from user groups" + }, + "ImplicitGroups": { + "type": "array", + "description": "Implicit groups" + }, + "GroupsAddToSelf": { + "type": "object", + "description": "Groups a user can add to themselves" + }, + "GroupsRemoveFromSelf": { + "type": "object", + "description": "Groups a user can remove from themselves" + }, + "AddGroups": { + "type": "object", + "description": "Groups a user can add to users" + }, + "RemoveGroups": { + "type": "object", + "description": "Groups a user can remove from users" + }, + "AvailableRights": { + "type": "array", + "description": "User rights added by the extension" + }, + "ContentHandlers": { + "type": "object", + "description": "Mapping of model ID to class name" + }, + "RateLimits": { + "type": "object", + "description": "Rate limits" + }, + "ParserTestFiles": { + "type": "array", + "description": "Parser test files to run" + }, + "RecentChangesFlags": { + "type": "object", + "description": "Flags (letter symbols) shown on RecentChanges pages" + }, + "ExtensionFunctions": { + "type": [ + "array", + "string" + ], + "description": "Function to call after setup has finished" + }, + "ExtensionMessagesFiles": { + "type": "object", + "description": "File paths containing PHP internationalization data" + }, + "MessagesDirs": { + "type": "object", + "description": "Directory paths containing JSON internationalization data" + }, + "ExtensionEntryPointListFiles": { + "type": "object" + }, + "SpecialPages": { + "type": "object", + "description": "SpecialPages implemented in this extension (mapping of page name to class name)" + }, + "SpecialPageGroups": { + "type": "object", + "description": "Mapping of special page name to group it belongs to" + }, + "AutoloadClasses": { + "type": "object" + }, + "Hooks": { + "type": "object", + "description": "Hooks this extension uses (mapping of hook name to callback)" + }, + "JobClasses": { + "type": "object", + "description": "Job types this extension implements (mapping of job type to class name)" + }, + "LogTypes": { + "type": "array", + "description": "List of new log types this extension uses" + }, + "LogRestrictions": { + "type": "object" + }, + "FilterLogTypes": { + "type": "array" + }, + "LogNames": { + "type": "object" + }, + "LogHeaders": { + "type": "object" + }, + "LogActions": { + "type": "object" + }, + "LogActionsHandlers": { + "type": "object" + }, + "Actions": { + "type": "object" + }, + "APIModules": { + "type": "object" + }, + "APIFormatModules": { + "type": "object" + }, + "APIMetaModules": { + "type": "object" + }, + "APIPropModules": { + "type": "object" + }, + "APIListModules": { + "type": "object" + }, + "callback": { + "type": [ + "array", + "string" + ], + "description": "A function to be called right after MediaWiki processes this file" + }, + "config": { + "type": "object", + "description": "Configuration options for this extension" + } + } +} diff --git a/includes/DefaultSettings.php b/includes/DefaultSettings.php index 1884b5feb52..7e5c8579276 100644 --- a/includes/DefaultSettings.php +++ b/includes/DefaultSettings.php @@ -2348,6 +2348,23 @@ $wgClockSkewFudge = 5; */ $wgInvalidateCacheOnLocalSettingsChange = true; +/** + * When loading extensions through the extension registration system, this + * can be used to invalidate the cache. A good idea would be to set this to + * one file, you can just `touch` that one to invalidate the cache + * + * @par Example: + * @code + * $wgExtensionInfoMtime = filemtime( "$IP/LocalSettings.php" ); + * @endcode + * + * If set to false, the mtime for each individual JSON file will be checked, + * which can be slow if a large number of extensions are being loaded. + * + * @var int|bool + */ +$wgExtensionInfoMTime = false; + /** @} */ # end of cache settings /************************************************************************//** diff --git a/includes/GlobalFunctions.php b/includes/GlobalFunctions.php index a3f0a488ed9..f516759b59f 100644 --- a/includes/GlobalFunctions.php +++ b/includes/GlobalFunctions.php @@ -161,6 +161,80 @@ if ( !function_exists( 'hash_equals' ) ) { /// @endcond /** + * Load an extension + * + * This is the closest equivalent to: + * require_once "$IP/extensions/$name/$name.php"; + * as it will process and load the extension immediately. + * + * However, batch loading with wfLoadExtensions will + * be more performant. + * + * @param string $name Name of the extension to load + * @param string|null $path Absolute path of where to find the extension.json file + */ +function wfLoadExtension( $name, $path = null ) { + if ( !$path ) { + global $IP; + $path = "$IP/extensions/$name/extension.json"; + } + ExtensionRegistry::getInstance()->load( $path ); +} + +/** + * Load multiple extensions at once + * + * Same as wfLoadExtension, but more efficient if you + * are loading multiple extensions. + * + * If you want to specify custom paths, you should interact with + * ExtensionRegistry directly. + * + * @see wfLoadExtension + * @param string[] $exts Array of extension names to load + */ +function wfLoadExtensions( array $exts ) { + global $IP; + $registry = ExtensionRegistry::getInstance(); + foreach ( $exts as $ext ) { + $registry->queue( "$IP/extensions/$ext/extension.json" ); + } + + $registry->loadFromQueue(); +} + +/** + * Load a skin + * + * @see wfLoadExtension + * @param string $name Name of the extension to load + * @param string|null $path Absolute path of where to find the skin.json file + */ +function wfLoadSkin( $name, $path = null ) { + if ( !$path ) { + global $IP; + $path = "$IP/skins/$name/skin.json"; + } + ExtensionRegistry::getInstance()->load( $path ); +} + +/** + * Load multiple skins at once + * + * @see wfLoadExtensions + * @param string[] $skins Array of extension names to load + */ +function wfLoadSkins( array $skins ) { + global $IP; + $registry = ExtensionRegistry::getInstance(); + foreach ( $skins as $skin ) { + $registry->queue( "$IP/skins/$skin/skin.json" ); + } + + $registry->loadFromQueue(); +} + +/** * Like array_diff( $a, $b ) except that it works with two-dimensional arrays. * @param array $a * @param array $b diff --git a/includes/Setup.php b/includes/Setup.php index 535b13d6cf3..106267a16d7 100644 --- a/includes/Setup.php +++ b/includes/Setup.php @@ -34,6 +34,10 @@ if ( !defined( 'MEDIAWIKI' ) ) { $fname = 'Setup.php'; wfProfileIn( $fname ); + +// If any extensions are still queued, force load them +ExtensionRegistry::getInstance()->loadFromQueue(); + wfProfileIn( $fname . '-defaults' ); // Check to see if we are at the file scope @@ -479,7 +483,6 @@ wfProfileOut( $fname . '-exception' ); wfProfileIn( $fname . '-includes' ); require_once "$IP/includes/normal/UtfNormalUtil.php"; -require_once "$IP/includes/GlobalFunctions.php"; require_once "$IP/includes/normal/UtfNormalDefines.php"; wfProfileOut( $fname . '-includes' ); diff --git a/includes/WebStart.php b/includes/WebStart.php index 217ba3fbd81..bc1ca501f2c 100644 --- a/includes/WebStart.php +++ b/includes/WebStart.php @@ -79,6 +79,9 @@ wfProfileIn( 'WebStart.php-conf' ); # Load default settings require_once "$IP/includes/DefaultSettings.php"; +# Load global functions +require_once "$IP/includes/GlobalFunctions.php"; + # Load composer's autoloader if present if ( is_readable( "$IP/vendor/autoload.php" ) ) { require_once "$IP/vendor/autoload.php"; diff --git a/includes/registration/ExtensionProcessor.php b/includes/registration/ExtensionProcessor.php new file mode 100644 index 00000000000..f42e9f3ee53 --- /dev/null +++ b/includes/registration/ExtensionProcessor.php @@ -0,0 +1,259 @@ + array(), + 'wgMessagesDirs' => array(), + ); + + /** + * Things that should be define()'d + * + * @var array + */ + protected $defines = array(); + + /** + * Things to be called once registration of these extensions are done + * + * @var callable[] + */ + protected $callbacks = array(); + + /** + * @var array + */ + protected $credits = array(); + + /** + * Any thing else in the $info that hasn't + * already been processed + * + * @var array + */ + protected $attributes = array(); + + /** + * List of keys that have already been processed + * + * @var array + */ + protected $processed = array(); + + /** + * @param string $path + * @param array $info + * @return array + */ + public function extractInfo( $path, array $info ) { + $this->extractConfig( $info ); + $this->extractHooks( $info ); + $dir = dirname( $path ); + $this->extractMessageSettings( $dir, $info ); + $this->extractNamespaces( $info ); + $this->extractResourceLoaderModules( $dir, $info ); + if ( isset( $info['callback'] ) ) { + $this->callbacks[] = $info['callback']; + $this->processed[] = 'callback'; + } + + $this->extractCredits( $path, $info ); + foreach ( $info as $key => $val ) { + if ( in_array( $key, self::$globalSettings ) ) { + $this->storeToArray( "wg$key", $val, $this->globals ); + } elseif ( !in_array( $key, $this->processed ) ) { + $this->storeToArray( $key, $val, $this->attributes ); + } + } + + + + } + + public function getExtractedInfo() { + return array( + 'globals' => $this->globals, + 'defines' => $this->defines, + 'callbacks' => $this->callbacks, + 'credits' => $this->credits, + 'attributes' => $this->attributes, + ); + } + + protected function extractHooks( array $info ) { + if ( isset( $info['Hooks'] ) ) { + foreach ( $info['Hooks'] as $name => $callable ) { + $this->globals['wgHooks'][$name][] = $callable; + } + $this->processed[] = 'Hooks'; + } + } + + /** + * Register namespaces with the appropriate global settings + * + * @param array $info + */ + protected function extractNamespaces( array $info ) { + if ( isset( $info['namespaces'] ) ) { + foreach ( $info['namespaces'] as $ns ) { + $id = $ns['id']; + $this->defines[$ns['constant']] = $id; + $this->globals['wgExtraNamespaces'][$id] = $ns['name']; + if ( isset( $ns['gender'] ) ) { + $this->globals['wgExtraGenderNamespaces'][$id] = $ns['gender']; + } + if ( isset( $ns['subpages'] ) && $ns['subpages'] ) { + $this->globals['wgNamespacesWithSubpages'][$id] = true; + } + if ( isset( $ns['content'] ) && $ns['content'] ) { + $this->globals['wgContentNamespaces'][] = $id; + } + if ( isset( $ns['defaultcontentmodel'] ) ) { + $this->globals['wgNamespaceContentModels'][$id] = $ns['defaultcontentmodel']; + } + } + $this->processed[] = 'namespaces'; + } + } + + protected function extractResourceLoaderModules( $dir, array $info ) { + if ( isset( $info['ResourceModules'] ) ) { + foreach ( $info['ResourceModules'] as $name => $data ) { + if ( isset( $data['localBasePath'] ) ) { + $data['localBasePath'] = "$dir/{$data['localBasePath']}"; + } + $this->globals['wgResourceModules'][$name] = $data; + } + } + } + + /** + * Set message-related settings, which need to be expanded to use + * absolute paths + * + * @param string $dir + * @param array $info + */ + protected function extractMessageSettings( $dir, array $info ) { + foreach ( array( 'ExtensionMessagesFiles', 'MessagesDirs' ) as $key ) { + if ( isset( $info[$key] ) ) { + $this->globals["wg$key"] += array_map( function( $file ) use ( $dir ) { + return "$dir/$file"; + }, $info[$key] ); + $this->processed[] = $key; + } + } + } + + protected function extractCredits( $path, array $info ) { + $credits = array( + 'path' => $path, + 'type' => isset( $info['type'] ) ? $info['type'] : 'other', + ); + $this->processed[] = 'type'; + foreach ( self::$creditsAttributes as $attr ) { + if ( isset( $info[$attr] ) ) { + $credits[$attr] = $info[$attr]; + $this->processed[] = $attr; + } + } + + $this->credits[$credits['name']] = $credits; + } + + /** + * Set configuration settings + * @todo In the future, this should be done via Config interfaces + * + * @param array $info + */ + protected function extractConfig( array $info ) { + if ( isset( $info['config'] ) ) { + foreach ( $info['config'] as $key => $val ) { + $this->globals["wg$key"] = $val; + } + $this->processed[] = 'config'; + } + } + + /** + * @param string $name + * @param mixed $value + * @param array &$array + */ + protected function storeToArray( $name, $value, &$array ) { + if ( isset( $array[$name] ) ) { + $array[$name] = array_merge_recursive( $array[$name], $value ); + } else { + $array[$name] = $value; + } + } +} diff --git a/includes/registration/ExtensionRegistry.php b/includes/registration/ExtensionRegistry.php new file mode 100644 index 00000000000..44855d8ef47 --- /dev/null +++ b/includes/registration/ExtensionRegistry.php @@ -0,0 +1,260 @@ +cache = ObjectCache::newAccelerator( array(), CACHE_NONE ); + } + + /** + * @param string $path Absolute path to the JSON file + */ + public function queue( $path ) { + global $wgExtensionInfoMTime; + if ( $wgExtensionInfoMTime !== false ) { + $mtime = $wgExtensionInfoMTime; + } else { + $mtime = filemtime( $path ); + } + $this->queued[$path] = $mtime; + } + + public function loadFromQueue() { + if ( !$this->queued ) { + return; + } + + $this->queued = array_unique( $this->queued ); + + // See if this queue is in APC + $key = wfMemcKey( 'registration', md5( json_encode( $this->queued ) ) ); + $data = $this->cache->get( $key ); + if ( $data ) { + $this->exportExtractedData( $data ); + } else { + $data = array( 'globals' => array( 'wgAutoloadClasses' => array() ) ); + $autoloadClasses = array(); + foreach ( $this->queued as $path => $mtime ) { + $json = file_get_contents( $path ); + $info = json_decode( $json, /* $assoc = */ true ); + $autoload = $this->processAutoLoader( dirname( $path ), $info ); + // Set up the autoloader now so custom processors will work + $GLOBALS['wgAutoloadClasses'] += $autoload; + $autoloadClasses += $autoload; + if ( isset( $info['processor'] ) ) { + $processor = $this->getProcessor( $info['processor'] ); + } else { + $processor = $this->getProcessor( 'default' ); + } + $processor->extractInfo( $path, $info ); + } + foreach ( $this->processors as $processor ) { + $data = array_merge_recursive( $data, $processor->getExtractedInfo() ); + } + foreach ( $data['credits'] as $credit ) { + $data['globals']['wgExtensionCredits'][$credit['type']][] = $credit; + } + $this->processors = array(); // Reset + $this->exportExtractedData( $data ); + // Do this late since we don't want to extract it since we already + // did that, but it should be cached + $data['globals']['wgAutoloadClasses'] += $autoloadClasses; + $this->cache->set( $key, $data ); + } + $this->queued = array(); + } + + protected function getProcessor( $type ) { + if ( !isset( $this->processors[$type] ) ) { + $processor = $type === 'default' ? new ExtensionProcessor() : new $type(); + if ( !$processor instanceof Processor ) { + throw new Exception( "$type is not a Processor" ); + } + $this->processors[$type] = $processor; + } + + return $this->processors[$type]; + } + + protected function exportExtractedData( array $info ) { + foreach ( $info['globals'] as $key => $val ) { + if ( !isset( $GLOBALS[$key] ) || !$GLOBALS[$key] ) { + $GLOBALS[$key] = $val; + } elseif ( is_array( $GLOBALS[$key] ) && is_array( $val ) ) { + $GLOBALS[$key] = array_merge_recursive( $GLOBALS[$key], $val ); + } // else case is a config setting where it has already been overriden, so don't set it + } + foreach ( $info['defines'] as $name => $val ) { + define( $name, $val ); + } + foreach ( $info['callbacks'] as $cb ) { + call_user_func( $cb ); + } + + $this->loaded += $info['credits']; + + if ( $info['attributes'] ) { + if ( !$this->attributes ) { + $this->attributes = $info['attributes']; + } else { + $this->attributes = array_merge_recursive( $this->attributes, $info['attributes'] ); + } + } + } + + /** + * Loads and processes the given JSON file without delay + * + * If some extensions are already queued, this will load + * those as well. + * + * @param string $path Absolute path to the JSON file + */ + public function load( $path ) { + $this->loadFromQueue(); // First clear the queue + $this->queue( $path ); + $this->loadFromQueue(); + } + + /** + * Whether a thing has been loaded + * @param string $name + * @return bool + */ + public function isLoaded( $name ) { + return isset( $this->loaded[$name] ); + } + + /** + * @param string $name + * @return array + */ + public function getAttribute( $name ) { + if ( isset( $this->attributes[$name] ) ) { + return $this->attributes[$name]; + } else { + return array(); + } + } + + /** + * Get information about all things + * + * @return array + */ + public function getAllThings() { + return $this->loaded; + } + + /** + * Mark a thing as loaded + * + * @param string $name + * @param array $credits + */ + protected function markLoaded( $name, array $credits ) { + $this->loaded[$name] = $credits; + } + + /** + * Register classes with the autoloader + * + * @param string $dir + * @param array $info + * @return array + */ + protected function processAutoLoader( $dir, array $info ) { + if ( isset( $info['AutoloadClasses'] ) ) { + // Make paths absolute, relative to the JSON file + return array_map( function( $file ) use ( $dir ) { + return "$dir/$file"; + }, $info['AutoloadClasses'] ); + } else { + return array(); + } + } + + /** + * @param string $filename absolute path to the JSON file + * @param int $mtime modified time of the file + * @return array + */ + protected function loadInfoFromFile( $filename, $mtime ) { + $key = wfMemcKey( 'registry', md5( $filename ) ); + $cached = $this->cache->get( $key ); + if ( isset( $cached['mtime'] ) && $cached['mtime'] === $mtime ) { + return $cached['info']; + } + + $contents = file_get_contents( $filename ); + $json = json_decode( $contents, /* $assoc = */ true ); + if ( is_array( $json ) ) { + $this->cache->set( $key, array( 'mtime' => $mtime, 'info' => $json ) ); + } else { + // Don't throw an error here, but don't cache it either. + // @todo log somewhere? + $json = array(); + } + + return $json; + } +} diff --git a/includes/registration/Processor.php b/includes/registration/Processor.php new file mode 100644 index 00000000000..e930fd3e514 --- /dev/null +++ b/includes/registration/Processor.php @@ -0,0 +1,27 @@ + 'removeAbsolutePath', + 'ExtensionMessagesFiles' => 'removeAbsolutePath', + 'AutoloadClasses' => 'removeAbsolutePath', + 'ExtensionCredits' => 'handleCredits', + 'ResourceModules' => 'handleResourceModules', + 'Hooks' => 'handleHooks', + 'ExtensionFunctions' => 'handleExtensionFunctions', + ); + + private $json, $dir; + + public function __construct() { + parent::__construct(); + $this->mDescription = 'Converts extension entry points to the new JSON registration format'; + } + + public function execute() { + require $this->getArg( 0 ); + // Try not to create any local variables before this line + $vars = get_defined_vars(); + unset( $vars['this'] ); + $this->dir = dirname( realpath( $this->getArg( 0 ) ) ); + $this->json = array(); + $processor = new ReflectionClass( 'ExtensionProcessor' ); + $settings = $processor->getProperty( 'globalSettings' ); + $settings->setAccessible( true ); + $globalSettings = $settings->getValue(); + foreach ( $vars as $name => $value ) { + $realName = substr( $name, 2 ); // Strip 'wg' + if ( isset( $this->custom[$realName] ) ) { + call_user_func_array( array( $this, $this->custom[$realName] ), array( $realName, $value ) ); + } elseif ( in_array( $realName, $globalSettings ) ) { + $this->json[$realName] = $value; + } elseif ( strpos( $name, 'wg' ) === 0 ) { + // Most likely a config setting + $this->json['config'][$realName] = $value; + } + } + + $fname = "{$this->dir}/extension.json"; + $prettyJSON = FormatJson::encode( $this->json, "\t" ); + file_put_contents( $fname, $prettyJSON . "\n" ); + $this->output( "Wrote output to $fname.\n" ); + } + + protected function handleExtensionFunctions( $realName, $value ) { + foreach ( $value as $func ) { + if ( $func instanceof Closure ) { + $this->error( "Error: Closures cannot be converted to JSON. Please move your extension function somewhere else.", 1 ); + } + } + + $this->json[$realName] = $value; + } + + private function stripPath( $val, $dir ) { + if ( strpos( $val, $dir ) === 0 ) { + // +1 is for the trailing / that won't be in $this->dir + $val = substr( $val, strlen( $dir ) + 1 ); + } + + return $val; + } + + protected function removeAbsolutePath( $realName, $value ) { + $out = array(); + foreach ( $value as $key => $val ) { + $out[$key] = $this->stripPath( $val, $this->dir ); + } + $this->json[$realName] = $out; + } + + protected function handleCredits( $realName, $value) { + $keys = array_keys( $value ); + $this->json['type'] = $keys[0]; + $values = array_values( $value ); + foreach ( $values[0][0] as $name => $val ) { + if ( $name !== 'path' ) { + $this->json[$name] = $val; + } + } + } + + public function handleHooks( $realName, $value ) { + foreach ( $value as $hookName => $handlers ) { + foreach ( $handlers as $func ) { + if ( $func instanceof Closure ) { + $this->error( "Error: Closures cannot be converted to JSON. Please move the handler for $hookName somewhere else.", 1 ); + } + } + } + $this->json[$realName] = $value; + } + + protected function handleResourceModules( $realName, $value ) { + foreach ( $value as $name => $data ) { + if ( isset( $data['localBasePath'] ) ) { + $data['localBasePath'] = $this->stripPath( $data['localBasePath'], $this->dir ); + } + $this->json[$realName][$name] = $data; + } + } +} + +$maintClass = 'ConvertExtensionToRegistration'; +require_once RUN_MAINTENANCE_IF_MAIN; diff --git a/maintenance/doMaintenance.php b/maintenance/doMaintenance.php index e4380a7c475..66160576614 100644 --- a/maintenance/doMaintenance.php +++ b/maintenance/doMaintenance.php @@ -68,6 +68,7 @@ if ( file_exists( "$IP/StartProfiler.php" ) ) { // Some other requires require_once "$IP/includes/Defines.php"; require_once "$IP/includes/DefaultSettings.php"; +require_once "$IP/includes/GlobalFunctions.php"; # Load composer's autoloader if present if ( is_readable( "$IP/vendor/autoload.php" ) ) { diff --git a/maintenance/validateRegistrationFile.php b/maintenance/validateRegistrationFile.php new file mode 100644 index 00000000000..e7646610f4a --- /dev/null +++ b/maintenance/validateRegistrationFile.php @@ -0,0 +1,37 @@ +addArg( 'path', 'Path to extension.json/skin.json file.', true ); + } + public function execute() { + if ( !class_exists( 'JsonSchema\Uri\UriRetriever' ) ) { + $this->error( 'The JsonSchema library cannot be found, please install it through composer.', 1 ); + } + + $retriever = new JsonSchema\Uri\UriRetriever(); + $schema = $retriever->retrieve('file://' . dirname( __DIR__ ) . '/docs/extension.schema.json' ); + $path = $this->getArg( 0 ); + $data = json_decode( file_get_contents( $path ) ); + if ( !is_object( $data ) ) { + $this->error( "$path is not a valid JSON file.", 1 ); + } + + $validator = new JsonSchema\Validator(); + $validator->check( $data, $schema ); + if ( $validator->isValid() ) { + $this->output( "$path validates against the schema!\n" ); + } else { + foreach ( $validator->getErrors() as $error ) { + $this->output( "[{$error['property']}] {$error['message']}\n" ); + } + $this->error( "$path does not validate.", 1 ); + } + } +} + +$maintClass = 'ValidateRegistrationFile'; +require_once RUN_MAINTENANCE_IF_MAIN; diff --git a/tests/phpunit/includes/registration/ExtensionProcessorTest.php b/tests/phpunit/includes/registration/ExtensionProcessorTest.php new file mode 100644 index 00000000000..221c2580c5f --- /dev/null +++ b/tests/phpunit/includes/registration/ExtensionProcessorTest.php @@ -0,0 +1,135 @@ +dir = __DIR__ . '/FooBar/extension.json'; + } + + /** + * 'name' is absolutely required + * + * @var array + */ + static $default = array( + 'name' => 'FooBar', + ); + + public static function provideRegisterHooks() { + return array( + // No hooks + array( + array(), + self::$default, + array(), + ), + // No current hooks, adding one for "FooBaz" + array( + array(), + array( 'Hooks' => array( 'FooBaz' => 'FooBazCallback' ) ) + self::$default, + array( 'FooBaz' => array( 'FooBazCallback' ) ), + ), + // Hook for "FooBaz", adding another one + array( + array( 'FooBaz' => array( 'PriorCallback' ) ), + array( 'Hooks' => array( 'FooBaz' => 'FooBazCallback' ) ) + self::$default, + array( 'FooBaz' => array( 'PriorCallback', 'FooBazCallback' ) ), + ), + // Hook for "BarBaz", adding one for "FooBaz" + array( + array( 'BarBaz' => array( 'BarBazCallback' ) ), + array( 'Hooks' => array( 'FooBaz' => 'FooBazCallback' ) ) + self::$default, + array( + 'BarBaz' => array( 'BarBazCallback' ), + 'FooBaz' => array( 'FooBazCallback' ), + ), + ), + ); + } + + /** + * @covers ExtensionProcessor::extractHooks + * @dataProvider provideRegisterHooks + */ + public function testRegisterHooks( $pre, $info, $expected ) { + $processor = new MockExtensionProcessor( array( 'wgHooks' => $pre ) ); + $processor->extractInfo( $this->dir, $info ); + $extracted = $processor->getExtractedInfo(); + $this->assertEquals( $expected, $extracted['globals']['wgHooks'] ); + } + + /** + * @covers ExtensionProcessor::extractConfig + */ + public function testExtractConfig() { + $processor = new ExtensionProcessor; + $info = array( + 'config' => array( + 'Bar' => 'somevalue', + 'Foo' => 10, + ), + ) + self::$default; + $processor->extractInfo( $this->dir, $info ); + $extracted = $processor->getExtractedInfo(); + $this->assertEquals( 'somevalue', $extracted['globals']['wgBar'] ); + $this->assertEquals( 10, $extracted['globals']['wgFoo'] ); + } + + public static function provideSetToGlobal() { + return array( + array( + array( 'wgAPIModules', 'wgAvailableRights' ), + array(), + array( + 'APIModules' => array( 'foobar' => 'ApiFooBar' ), + 'AvailableRights' => array( 'foobar', 'unfoobar' ), + ), + array( + 'wgAPIModules' => array( 'foobar' => 'ApiFooBar' ), + 'wgAvailableRights' => array( 'foobar', 'unfoobar' ), + ), + ), + array( + array( 'wgAPIModules', 'wgAvailableRights' ), + array( + 'wgAPIModules' => array( 'barbaz' => 'ApiBarBaz' ), + 'wgAvailableRights' => array( 'barbaz' ) + ), + array( + 'APIModules' => array( 'foobar' => 'ApiFooBar' ), + 'AvailableRights' => array( 'foobar', 'unfoobar' ), + ), + array( + 'wgAPIModules' => array( 'barbaz' => 'ApiBarBaz', 'foobar' => 'ApiFooBar' ), + 'wgAvailableRights' => array( 'barbaz', 'foobar', 'unfoobar' ), + ), + ), + array( + array( 'wgGroupPermissions' ), + array( + 'wgGroupPermissions' => array( 'sysop' => array( 'delete' ) ), + ), + array( + 'GroupPermissions' => array( 'sysop' => array( 'undelete' ), 'user' => array( 'edit' ) ), + ), + array( + 'wgGroupPermissions' => array( 'sysop' => array( 'delete', 'undelete' ), 'user' => array( 'edit' ) ), + ) + ) + ); + } +} + + +/** + * Allow overriding the default value of $this->globals + * so we can test merging + */ +class MockExtensionProcessor extends ExtensionProcessor { + public function __construct( $globals = array() ) { + $this->globals = $globals + $this->globals; + } +} -- 2.11.4.GIT