From 78d1b8ebba6e992c59c7f7c02fd621bc6f12547c Mon Sep 17 00:00:00 2001 From: Brad Jorsch Date: Wed, 4 Apr 2018 16:22:01 -0400 Subject: [PATCH] API: Introduce "templated parameters" With MCR coming up, ApiEditPage is going to need to be able to take "text" and "contentmodel" parameters for each slot-role, and enumerating such parameters for every possible slot would probably get rather confusing as to what is required when, or at least long-winded in repeating the exact same thing for every possible role. So let's abstract it: we'll have an "editroles" parameter to specify which slots are being edited, and ApiEditPage will just declare that "text-{role}" and "contentmodel-{role}" parameters should exist for each value of "editroles" in the submission. Note this patch doesn't introduce anything that uses templated parameters, just the functionality itself. For testing purposes you might cherry pick I2d658e9a. Bug: T174032 Change-Id: Ia19a1617b73067bfb1f0f16ccc57d471778b7361 --- RELEASE-NOTES-1.32 | 12 +- includes/api/ApiBase.php | 95 ++- includes/api/ApiHelp.php | 14 + includes/api/ApiMain.php | 32 +- includes/api/ApiParamInfo.php | 15 +- includes/api/i18n/en.json | 6 + includes/api/i18n/qqq.json | 6 + languages/i18n/en.json | 1 + languages/i18n/qqq.json | 1 + resources/Resources.php | 4 + .../apisandbox.js | 599 ++++++++++++------ tests/phpunit/includes/api/ApiBaseTest.php | 95 +++ tests/phpunit/structure/ApiStructureTest.php | 40 ++ 13 files changed, 713 insertions(+), 207 deletions(-) diff --git a/RELEASE-NOTES-1.32 b/RELEASE-NOTES-1.32 index 4c56eecc50..c06ba9143a 100644 --- a/RELEASE-NOTES-1.32 +++ b/RELEASE-NOTES-1.32 @@ -43,10 +43,20 @@ production. * … === Action API changes in 1.32 === -* … +* Added templated parameters. + * A module can define a templated parameter like "{fruit}-quantity", where + the actual parameters recognized correspond to the values of a multi-valued + parameter. Then clients can make requests like + "fruits=apples|bananas&apples-quantity=1&bananas-quantity=5". + * action=paraminfo will return templated parameter definitions separately + from normal parameters. All parameter definitions now include an "index" + key to allow clients to maintain parameter ordering when merging normal and + templated parameters. === Action API internal changes in 1.32 === * Added 'ApiParseMakeOutputPage' hook. +* Parameter names may no longer contain '{' or '}', as these are now used for + templated parameters. === Languages updated in 1.32 === MediaWiki supports over 350 languages. Many localisations are updated regularly. diff --git a/includes/api/ApiBase.php b/includes/api/ApiBase.php index 7fafa1f1af..0802e160b1 100644 --- a/includes/api/ApiBase.php +++ b/includes/api/ApiBase.php @@ -226,6 +226,24 @@ abstract class ApiBase extends ContextSource { */ const PARAM_MAX_CHARS = 24; + /** + * (array) Indicate that this is a templated parameter, and specify replacements. Keys are the + * placeholders in the parameter name and values are the names of (unprefixed) parameters from + * which the replacement values are taken. + * + * For example, a parameter "foo-{ns}-{title}" could be defined with + * PARAM_TEMPLATE_VARS => [ 'ns' => 'namespaces', 'title' => 'titles' ]. Then a query for + * namespaces=0|1&titles=X|Y would support parameters foo-0-X, foo-0-Y, foo-1-X, and foo-1-Y. + * + * All placeholders must be present in the parameter's name. Each target parameter must have + * PARAM_ISMULTI true. If a target is itself a templated parameter, its PARAM_TEMPLATE_VARS must + * be a subset of the referring parameter's, mapping the same placeholders to the same targets. + * A parameter cannot target itself. + * + * @since 1.32 + */ + const PARAM_TEMPLATE_VARS = 25; + /**@}*/ const ALL_DEFAULT_STRING = '*'; @@ -749,15 +767,78 @@ abstract class ApiBase extends ContextSource { public function extractRequestParams( $parseLimit = true ) { // Cache parameters, for performance and to avoid T26564. if ( !isset( $this->mParamCache[$parseLimit] ) ) { - $params = $this->getFinalParams(); + $params = $this->getFinalParams() ?: []; $results = []; - - if ( $params ) { // getFinalParams() can return false - foreach ( $params as $paramName => $paramSettings ) { + $warned = []; + + // Process all non-templates and save templates for secondary + // processing. + $toProcess = []; + foreach ( $params as $paramName => $paramSettings ) { + if ( isset( $paramSettings[self::PARAM_TEMPLATE_VARS] ) ) { + $toProcess[] = [ $paramName, $paramSettings[self::PARAM_TEMPLATE_VARS], $paramSettings ]; + } else { $results[$paramName] = $this->getParameterFromSettings( - $paramName, $paramSettings, $parseLimit ); + $paramName, $paramSettings, $parseLimit + ); + } + } + + // Now process all the templates by successively replacing the + // placeholders with all client-supplied values. + // This bit duplicates JavaScript logic in + // ApiSandbox.PageLayout.prototype.updateTemplatedParams(). + // If you update this, see if that needs updating too. + while ( $toProcess ) { + list( $name, $targets, $settings ) = array_shift( $toProcess ); + + foreach ( $targets as $placeholder => $target ) { + if ( !array_key_exists( $target, $results ) ) { + // The target wasn't processed yet, try the next one. + // If all hit this case, the parameter has no expansions. + continue; + } + if ( !is_array( $results[$target] ) || !$results[$target] ) { + // The target was processed but has no (valid) values. + // That means it has no expansions. + break; + } + + // Expand this target in the name and all other targets, + // then requeue if there are more targets left or put in + // $results if all are done. + unset( $targets[$placeholder] ); + $placeholder = '{' . $placeholder . '}'; + foreach ( $results[$target] as $value ) { + if ( !preg_match( '/^[^{}]*$/', $value ) ) { + // Skip values that make invalid parameter names. + $encTargetName = $this->encodeParamName( $target ); + if ( !isset( $warned[$encTargetName][$value] ) ) { + $warned[$encTargetName][$value] = true; + $this->addWarning( [ + 'apiwarn-ignoring-invalid-templated-value', + wfEscapeWikiText( $encTargetName ), + wfEscapeWikiText( $value ), + ] ); + } + continue; + } + + $newName = str_replace( $placeholder, $value, $name ); + if ( !$targets ) { + $results[$newName] = $this->getParameterFromSettings( $newName, $settings, $parseLimit ); + } else { + $newTargets = []; + foreach ( $targets as $k => $v ) { + $newTargets[$k] = str_replace( $placeholder, $value, $v ); + } + $toProcess[] = [ $newName, $newTargets, $settings ]; + } + } + break; } } + $this->mParamCache[$parseLimit] = $results; } @@ -771,9 +852,7 @@ abstract class ApiBase extends ContextSource { * @return mixed Parameter value */ protected function getParameter( $paramName, $parseLimit = true ) { - $paramSettings = $this->getFinalParams()[$paramName]; - - return $this->getParameterFromSettings( $paramName, $paramSettings, $parseLimit ); + return $this->extractRequestParams( $parseLimit )[$paramName]; } /** diff --git a/includes/api/ApiHelp.php b/includes/api/ApiHelp.php index 8d24859078..bccb3385c2 100644 --- a/includes/api/ApiHelp.php +++ b/includes/api/ApiHelp.php @@ -466,6 +466,20 @@ class ApiHelp extends ApiBase { } } + // Templated? + if ( !empty( $settings[ApiBase::PARAM_TEMPLATE_VARS] ) ) { + $vars = []; + $msg = 'api-help-param-templated-var-first'; + foreach ( $settings[ApiBase::PARAM_TEMPLATE_VARS] as $k => $v ) { + $vars[] = $context->msg( $msg, $k, $module->encodeParamName( $v ) ); + $msg = 'api-help-param-templated-var'; + } + $info[] = $context->msg( 'api-help-param-templated' ) + ->numParams( count( $vars ) ) + ->params( Message::listParam( $vars ) ) + ->parse(); + } + // Type documentation if ( !isset( $settings[ApiBase::PARAM_TYPE] ) ) { $dflt = isset( $settings[ApiBase::PARAM_DFLT] ) diff --git a/includes/api/ApiMain.php b/includes/api/ApiMain.php index b7b13c5dfc..914d8e9786 100644 --- a/includes/api/ApiMain.php +++ b/includes/api/ApiMain.php @@ -1888,6 +1888,7 @@ class ApiMain extends ApiBase { $help[$k] = $v; } $help['datatypes'] = ''; + $help['templatedparams'] = ''; $help['credits'] = ''; // Fill 'permissions' @@ -1920,7 +1921,7 @@ class ApiMain extends ApiBase { $help['permissions'] .= Html::closeElement( 'dl' ); $help['permissions'] .= Html::closeElement( 'div' ); - // Fill 'datatypes' and 'credits', if applicable + // Fill 'datatypes', 'templatedparams', and 'credits', if applicable if ( empty( $options['nolead'] ) ) { $level = $options['headerlevel']; $tocnumber = &$options['tocnumber']; @@ -1954,6 +1955,35 @@ class ApiMain extends ApiBase { ]; } + $header = $this->msg( 'api-help-templatedparams-header' )->parse(); + + $id = Sanitizer::escapeIdForAttribute( 'main/templatedparams', Sanitizer::ID_PRIMARY ); + $idFallback = Sanitizer::escapeIdForAttribute( 'main/templatedparams', Sanitizer::ID_FALLBACK ); + $headline = Linker::makeHeadline( min( 6, $level ), + ' class="apihelp-header">', + $id, + $header, + '', + $idFallback + ); + // Ensure we have a sane anchor + if ( $id !== 'main/templatedparams' && $idFallback !== 'main/templatedparams' ) { + $headline = '
' . $headline; + } + $help['templatedparams'] .= $headline; + $help['templatedparams'] .= $this->msg( 'api-help-templatedparams' )->parseAsBlock(); + if ( !isset( $tocData['main/templatedparams'] ) ) { + $tocnumber[$level]++; + $tocData['main/templatedparams'] = [ + 'toclevel' => count( $tocnumber ), + 'level' => $level, + 'anchor' => 'main/templatedparams', + 'line' => $header, + 'number' => implode( '.', $tocnumber ), + 'index' => false, + ]; + } + $header = $this->msg( 'api-credits-header' )->parse(); $id = Sanitizer::escapeIdForAttribute( 'main/credits', Sanitizer::ID_PRIMARY ); $idFallback = Sanitizer::escapeIdForAttribute( 'main/credits', Sanitizer::ID_FALLBACK ); diff --git a/includes/api/ApiParamInfo.php b/includes/api/ApiParamInfo.php index bfd3d614b3..b8a32ae430 100644 --- a/includes/api/ApiParamInfo.php +++ b/includes/api/ApiParamInfo.php @@ -305,16 +305,25 @@ class ApiParamInfo extends ApiBase { } $ret['parameters'] = []; + $ret['templatedparameters'] = []; $params = $module->getFinalParams( ApiBase::GET_VALUES_FOR_HELP ); $paramDesc = $module->getFinalParamDescription(); + $index = 0; foreach ( $params as $name => $settings ) { if ( !is_array( $settings ) ) { $settings = [ ApiBase::PARAM_DFLT => $settings ]; } $item = [ - 'name' => $name + 'index' => ++$index, + 'name' => $name, ]; + + if ( !empty( $settings[ApiBase::PARAM_TEMPLATE_VARS] ) ) { + $item['templatevars'] = $settings[ApiBase::PARAM_TEMPLATE_VARS]; + ApiResult::setIndexedTagName( $item['templatevars'], 'var' ); + } + if ( isset( $paramDesc[$name] ) ) { $this->formatHelpMessages( $item, 'description', $paramDesc[$name], true ); } @@ -507,9 +516,11 @@ class ApiParamInfo extends ApiBase { ApiResult::setIndexedTagName( $item['info'], 'i' ); } - $ret['parameters'][] = $item; + $key = empty( $settings[ApiBase::PARAM_TEMPLATE_VARS] ) ? 'parameters' : 'templatedparameters'; + $ret[$key][] = $item; } ApiResult::setIndexedTagName( $ret['parameters'], 'param' ); + ApiResult::setIndexedTagName( $ret['templatedparameters'], 'param' ); $dynamicParams = $module->dynamicParameterDocumentation(); if ( $dynamicParams !== null ) { diff --git a/includes/api/i18n/en.json b/includes/api/i18n/en.json index 6838e545d6..573d37c664 100644 --- a/includes/api/i18n/en.json +++ b/includes/api/i18n/en.json @@ -1586,8 +1586,13 @@ "api-help-parameters": "{{PLURAL:$1|Parameter|Parameters}}:", "api-help-param-deprecated": "Deprecated.", "api-help-param-required": "This parameter is required.", + "api-help-param-templated": "This is a [[Special:ApiHelp/main#main/templatedparams|templated parameter]]. When making the request, $2.", + "api-help-param-templated-var-first": "{$1} in the parameter's name should be replaced with values of $2", + "api-help-param-templated-var": "{$1} with values of $2", "api-help-datatypes-header": "Data types", "api-help-datatypes": "Input to MediaWiki should be NFC-normalized UTF-8. MediaWiki may attempt to convert other input, but this may cause some operations (such as [[Special:ApiHelp/edit|edits]] with MD5 checks) to fail.\n\nSome parameter types in API requests need further explanation:\n;boolean\n:Boolean parameters work like HTML checkboxes: if the parameter is specified, regardless of value, it is considered true. For a false value, omit the parameter entirely.\n;timestamp\n:Timestamps may be specified in several formats. ISO 8601 date and time is recommended. All times are in UTC, any included timezone is ignored.\n:* ISO 8601 date and time, 2001-01-15T14:56:00Z (punctuation and Z are optional)\n:* ISO 8601 date and time with (ignored) fractional seconds, 2001-01-15T14:56:00.00001Z (dashes, colons, and Z are optional)\n:* MediaWiki format, 20010115145600\n:* Generic numeric format, 2001-01-15 14:56:00 (optional timezone of GMT, +##, or -## is ignored)\n:* EXIF format, 2001:01:15 14:56:00\n:*RFC 2822 format (timezone may be omitted), Mon, 15 Jan 2001 14:56:00\n:* RFC 850 format (timezone may be omitted), Monday, 15-Jan-2001 14:56:00\n:* C ctime format, Mon Jan 15 14:56:00 2001\n:* Seconds since 1970-01-01T00:00:00Z as a 1 to 13 digit integer (excluding 0)\n:* The string now\n;alternative multiple-value separator\n:Parameters that take multiple values are normally submitted with the values separated using the pipe character, e.g. param=value1|value2 or param=value1%7Cvalue2. If a value must contain the pipe character, use U+001F (Unit Separator) as the separator ''and'' prefix the value with U+001F, e.g. param=%1Fvalue1%1Fvalue2.", + "api-help-templatedparams-header": "Templated parameters", + "api-help-templatedparams": "Templated parameters support cases where an API module needs a value for each value of some other parameter. For example, if there were an API module to request fruit, it might have a parameter fruits to specify which fruits are being requested and a templated parameter {fruit}-quantity to specify how many of each fruit to request. An API client that wants 1 apple, 5 bananas, and 20 strawberries could then make a request like fruits=apples|bananas|strawberries&apples-quantity=1&bananas-quantity=5&strawberries-quantity=20.", "api-help-param-type-limit": "Type: integer or max", "api-help-param-type-integer": "Type: {{PLURAL:$1|1=integer|2=list of integers}}", "api-help-param-type-boolean": "Type: boolean ([[Special:ApiHelp/main#main/datatypes|details]])", @@ -1854,6 +1859,7 @@ "apiwarn-difftohidden": "Couldn't diff to r$1: content is hidden.", "apiwarn-errorprinterfailed": "Error printer failed. Will retry without params.", "apiwarn-errorprinterfailed-ex": "Error printer failed (will retry without params): $1", + "apiwarn-ignoring-invalid-templated-value": "Ignoring value $2 in $1 when processing templated parameters.", "apiwarn-invalidcategory": "\"$1\" is not a category.", "apiwarn-invalidtitle": "\"$1\" is not a valid title.", "apiwarn-invalidxmlstylesheetext": "Stylesheet should have .xsl extension.", diff --git a/includes/api/i18n/qqq.json b/includes/api/i18n/qqq.json index 594bf8e685..086e74bf2d 100644 --- a/includes/api/i18n/qqq.json +++ b/includes/api/i18n/qqq.json @@ -1476,8 +1476,13 @@ "api-help-parameters": "Label for the API help parameters section\n\nParameters:\n* $1 - Number of parameters to be displayed\n{{Identical|Parameter}}", "api-help-param-deprecated": "Displayed in the API help for any deprecated parameter\n{{Identical|Deprecated}}", "api-help-param-required": "Displayed in the API help for any required parameter", + "api-help-param-templated": "Displayed in the API help for any templated parameter.\n\nParameters:\n* $1 - Count of template variables in the parameter name.\n* $2 - A list, composed using {{msg-mw|comma-separator}} and {{msg-mw|and}}, of the template variables in the parameter name. The first is formatted using {{msg-mw|api-help-param-templated-var-first|notext=1}} and the rest use {{msg-mw|api-help-param-templated-var|notext=1}}.\n\nSee also:\n* {{msg-mw|api-help-param-templated-var-first}}\n* {{msg-mw|api-help-param-templated-var}}", + "api-help-param-templated-var-first": "Used with {{msg-mw|api-help-param-templated|notext=1}} to display templated parameter replacement variables. See that message for context.\n\nParameters:\n* $1 - Variable.\n* $2 - Parameter from which values are taken.\n\nSee also:\n* {{msg-mw|api-help-param-templated}}\n* {{msg-mw|api-help-param-templated-var}}", + "api-help-param-templated-var": "Used with {{msg-mw|api-help-param-templated|notext=1}} to display templated parameter replacement variables. See that message for context.\n\nParameters:\n* $1 - Variable.\n* $2 - Parameter from which values are taken.\n\nSee also:\n* {{msg-mw|api-help-param-templated}}\n* {{msg-mw|api-help-param-templated-var-first}}", "api-help-datatypes-header": "Header for the data type section in the API help output", "api-help-datatypes": "{{technical}} {{doc-important|Do not translate or reformat dates inside or tags}} Documentation of certain API data types\nSee also:\n* [[Special:PrefixIndex/MediaWiki:api-help-param-type]]", + "api-help-templatedparams-header": "Header for the \"templated parameters\" section in the API help output.", + "api-help-templatedparams": "{{technical}} {{doc-important|Unlike in other API messages, feel free to localize the words \"fruit\", \"fruits\", \"quantity\", \"apples\", \"bananas\", and \"strawberries\" in this message even when inside or tags. Do not change the punctuation, only the words.}} Documentation for the \"templated parameters\" feature.", "api-help-param-type-limit": "{{technical}} {{doc-important|Do not translate text inside <kbd> tags}} Used to indicate that a parameter is a \"limit\" type. Parameters:\n* $1 - Always 1.\nSee also:\n* {{msg-mw|api-help-datatypes}}\n* [[Special:PrefixIndex/MediaWiki:api-help-param-type]]", "api-help-param-type-integer": "{{technical}} Used to indicate that a parameter is an integer or list of integers. Parameters:\n* $1 - 1 if the parameter takes one value, 2 if the parameter takes a list of values.\nSee also:\n* {{msg-mw|api-help-datatypes}}\n* [[Special:PrefixIndex/MediaWiki:api-help-param-type]]", "api-help-param-type-boolean": "{{technical}} {{doc-important|Do not translate Special:ApiHelp in this message.}} Used to indicate that a parameter is a boolean. Parameters:\n* $1 - Always 1.\nSee also:\n* {{msg-mw|api-help-datatypes}}\n* [[Special:PrefixIndex/MediaWiki:api-help-param-type]]", @@ -1741,6 +1746,7 @@ "apiwarn-difftohidden": "{{doc-apierror}}\n\nParameters:\n* $1 - Revision ID number.\n\n\"r\" is short for \"revision\". You may translate it.", "apiwarn-errorprinterfailed": "{{doc-apierror}}", "apiwarn-errorprinterfailed-ex": "{{doc-apierror}}\n\nParameters:\n* $1 - Exception message, which may already end in punctuation. Probably in English.", + "apiwarn-ignoring-invalid-templated-value": "{{doc-apierror}}\n\nParameters:\n* $1 - Target parameter having a bad value.\n* $2 - The bad value being ignored.", "apiwarn-invalidcategory": "{{doc-apierror}}\n\nParameters:\n* $1 - Supplied category name.", "apiwarn-invalidtitle": "{{doc-apierror}}\n\nParameters:\n* $1 - Supplied title.", "apiwarn-invalidxmlstylesheetext": "{{doc-apierror}}", diff --git a/languages/i18n/en.json b/languages/i18n/en.json index 215b356eab..236d6e598d 100644 --- a/languages/i18n/en.json +++ b/languages/i18n/en.json @@ -2082,6 +2082,7 @@ "apisandbox-dynamic-parameters-add-label": "Add parameter:", "apisandbox-dynamic-parameters-add-placeholder": "Parameter name", "apisandbox-dynamic-error-exists": "A parameter named \"$1\" already exists.", + "apisandbox-templated-parameter-reason": "This [[Special:ApiHelp/main#main/templatedparams|templated parameter]] is offered based on the {{PLURAL:$1|value|values}} of $2.", "apisandbox-deprecated-parameters": "Deprecated parameters", "apisandbox-fetch-token": "Auto-fill the token", "apisandbox-add-multi": "Add", diff --git a/languages/i18n/qqq.json b/languages/i18n/qqq.json index 97ac8079ac..0947db2173 100644 --- a/languages/i18n/qqq.json +++ b/languages/i18n/qqq.json @@ -2280,6 +2280,7 @@ "apisandbox-dynamic-parameters-add-label": "JavaScript label for the widget to add a new arbitrary parameter.", "apisandbox-dynamic-parameters-add-placeholder": "JavaScript text field placeholder for the widget to add a new arbitrary parameter.", "apisandbox-dynamic-error-exists": "Displayed as an error message from JavaScript when trying to add a new arbitrary parameter with a name that already exists. Parameters:\n* $1 - Parameter name that failed.", + "apisandbox-templated-parameter-reason": "Displayed (from JavaScript) on each instance of a templated parameter.\n\nParameters:\n* $1 - Number of fields in $2.\n* $2 - List of targeted fields, combined using {{msg-mw|comma-separator}} and {{msg-mw|and}}.", "apisandbox-deprecated-parameters": "JavaScript button label and fieldset legend for separating deprecated parameters in the UI.", "apisandbox-fetch-token": "Label for the button that fetches a CSRF token.", "apisandbox-add-multi": "Label for the button to add another value to a field that accepts multiple values\n{{Identical|Add}}", diff --git a/resources/Resources.php b/resources/Resources.php index d0bc1ba623..e187ef24f2 100644 --- a/resources/Resources.php +++ b/resources/Resources.php @@ -2034,6 +2034,7 @@ return [ 'apisandbox-dynamic-parameters-add-label', 'apisandbox-dynamic-parameters-add-placeholder', 'apisandbox-dynamic-error-exists', + 'apisandbox-templated-parameter-reason', 'apisandbox-deprecated-parameters', 'apisandbox-no-parameters', 'api-help-param-limit', @@ -2070,6 +2071,9 @@ return [ 'apisandbox-multivalue-all-values', 'api-format-prettyprint-status', 'blanknamespace', + 'comma-separator', + 'word-separator', + 'and' ], ], 'mediawiki.special.block' => [ diff --git a/resources/src/mediawiki.special.apisandbox/apisandbox.js b/resources/src/mediawiki.special.apisandbox/apisandbox.js index 523a62e75b..f936658ff3 100644 --- a/resources/src/mediawiki.special.apisandbox/apisandbox.js +++ b/resources/src/mediawiki.special.apisandbox/apisandbox.js @@ -42,6 +42,10 @@ } } + widget.connect( this, { + change: [ this.emit, 'change' ] + } ); + this.$cover.on( 'click', this.onOverlayClick.bind( this ) ); this.$element @@ -75,6 +79,7 @@ this.widget.setDisabled( this.isDisabled() ); this.checkbox.setSelected( !this.isDisabled() ); this.$cover.toggle( this.isDisabled() ); + this.emit( 'change' ); return this; }; @@ -186,6 +191,21 @@ }, tagWidget: { + parseApiValue: function ( v ) { + if ( v === undefined || v === '' || v === '\x1f' ) { + return []; + } else { + v = String( v ); + if ( v[ 0 ] !== '\x1f' ) { + return v.split( '|' ); + } else { + return v.substr( 1 ).split( '\x1f' ); + } + } + }, + getApiValueForTemplates: function () { + return this.isDisabled() ? this.parseApiValue( this.paramInfo[ 'default' ] ) : this.getValue(); + }, getApiValue: function () { var items = this.getValue(); if ( items.join( '' ).indexOf( '|' ) === -1 ) { @@ -195,16 +215,10 @@ } }, setApiValue: function ( v ) { - if ( v === undefined || v === '' || v === '\x1f' ) { - this.setValue( [] ); - } else { - v = String( v ); - if ( v.indexOf( '\x1f' ) !== 0 ) { - this.setValue( v.split( '|' ) ); - } else { - this.setValue( v.substr( 1 ).split( '\x1f' ) ); - } + if ( v === undefined ) { + v = this.paramInfo[ 'default' ]; } + this.setValue( this.parseApiValue( v ) ); }, apiCheckValid: function () { var ok = true, @@ -649,6 +663,9 @@ finalWidget.getSubmodules = widget.getSubmodules.bind( widget ); finalWidget.on( 'disable', function () { setTimeout( ApiSandbox.updateUI ); } ); } + if ( widget.getApiValueForTemplates ) { + finalWidget.getApiValueForTemplates = widget.getApiValueForTemplates.bind( widget ); + } finalWidget.setDisabled( true ); } @@ -1330,6 +1347,9 @@ this.apiIsValid = true; this.loadFromQueryParams = null; this.widgets = {}; + this.itemsFieldset = null; + this.deprecatedItemsFieldset = null; + this.templatedItemsCache = {}; this.tokenWidget = null; this.indentLevel = config.indentLevel ? config.indentLevel : 0; ApiSandbox.PageLayout[ 'super' ].call( this, config.key, config ); @@ -1345,6 +1365,351 @@ ); }; + function widgetLabelOnClick() { + var f = this.getField(); + if ( $.isFunction( f.setDisabled ) ) { + f.setDisabled( false ); + } + if ( $.isFunction( f.focus ) ) { + f.focus(); + } + } + + /** + * Create a widget and the FieldLayouts it needs + * @private + * @param {Object} ppi API paraminfo data for the parameter + * @param {string} name API parameter name + * @return {Object} + * @return {OO.ui.Widget} return.widget + * @return {OO.ui.FieldLayout} return.widgetField + * @return {OO.ui.FieldLayout} return.helpField + */ + ApiSandbox.PageLayout.prototype.makeWidgetFieldLayouts = function ( ppi, name ) { + var j, l, widget, descriptionContainer, tmp, flag, count, button, widgetField, helpField, layoutConfig; + + widget = Util.createWidgetForParameter( ppi ); + if ( ppi.tokentype ) { + this.tokenWidget = widget; + } + if ( this.paramInfo.templatedparameters.length ) { + widget.on( 'change', this.updateTemplatedParameters, [ null ], this ); + } + + descriptionContainer = $( '
' ); + + tmp = Util.parseHTML( ppi.description ); + tmp.filter( 'dl' ).makeCollapsible( { + collapsed: true + } ).children( '.mw-collapsible-toggle' ).each( function () { + var $this = $( this ); + $this.parent().prev( 'p' ).append( $this ); + } ); + descriptionContainer.append( $( '
' ).addClass( 'description' ).append( tmp ) ); + + if ( ppi.info && ppi.info.length ) { + for ( j = 0; j < ppi.info.length; j++ ) { + descriptionContainer.append( $( '
' ) + .addClass( 'info' ) + .append( Util.parseHTML( ppi.info[ j ] ) ) + ); + } + } + flag = true; + count = Infinity; + switch ( ppi.type ) { + case 'namespace': + flag = false; + count = mw.config.get( 'wgFormattedNamespaces' ).length; + break; + + case 'limit': + if ( ppi.highmax !== undefined ) { + descriptionContainer.append( $( '
' ) + .addClass( 'info' ) + .append( + Util.parseMsg( + 'api-help-param-limit2', ppi.max, ppi.highmax + ), + ' ', + Util.parseMsg( 'apisandbox-param-limit' ) + ) + ); + } else { + descriptionContainer.append( $( '
' ) + .addClass( 'info' ) + .append( + Util.parseMsg( 'api-help-param-limit', ppi.max ), + ' ', + Util.parseMsg( 'apisandbox-param-limit' ) + ) + ); + } + break; + + case 'integer': + tmp = ''; + if ( ppi.min !== undefined ) { + tmp += 'min'; + } + if ( ppi.max !== undefined ) { + tmp += 'max'; + } + if ( tmp !== '' ) { + descriptionContainer.append( $( '
' ) + .addClass( 'info' ) + .append( Util.parseMsg( + 'api-help-param-integer-' + tmp, + Util.apiBool( ppi.multi ) ? 2 : 1, + ppi.min, ppi.max + ) ) + ); + } + break; + + default: + if ( Array.isArray( ppi.type ) ) { + flag = false; + count = ppi.type.length; + } + break; + } + if ( Util.apiBool( ppi.multi ) ) { + tmp = []; + if ( flag && !( widget instanceof OO.ui.TagMultiselectWidget ) && + !( + widget instanceof OptionalWidget && + widget.widget instanceof OO.ui.TagMultiselectWidget + ) + ) { + tmp.push( mw.message( 'api-help-param-multi-separate' ).parse() ); + } + if ( count > ppi.lowlimit ) { + tmp.push( + mw.message( 'api-help-param-multi-max', ppi.lowlimit, ppi.highlimit ).parse() + ); + } + if ( tmp.length ) { + descriptionContainer.append( $( '
' ) + .addClass( 'info' ) + .append( Util.parseHTML( tmp.join( ' ' ) ) ) + ); + } + } + if ( 'maxbytes' in ppi ) { + descriptionContainer.append( $( '
' ) + .addClass( 'info' ) + .append( Util.parseMsg( 'api-help-param-maxbytes', ppi.maxbytes ) ) + ); + } + if ( 'maxchars' in ppi ) { + descriptionContainer.append( $( '
' ) + .addClass( 'info' ) + .append( Util.parseMsg( 'api-help-param-maxchars', ppi.maxchars ) ) + ); + } + if ( ppi.usedTemplateVars && ppi.usedTemplateVars.length ) { + tmp = $(); + for ( j = 0, l = ppi.usedTemplateVars.length; j < l; j++ ) { + tmp = tmp.add( $( '' ).text( ppi.usedTemplateVars[ j ] ) ); + if ( j === l - 2 ) { + tmp = tmp.add( mw.message( 'and' ).parseDom() ); + tmp = tmp.add( mw.message( 'word-separator' ).parseDom() ); + } else if ( j !== l - 1 ) { + tmp = tmp.add( mw.message( 'comma-separator' ).parseDom() ); + } + } + descriptionContainer.append( $( '
' ) + .addClass( 'info' ) + .append( Util.parseMsg( + 'apisandbox-templated-parameter-reason', + ppi.usedTemplateVars.length, + tmp + ) ) + ); + } + + helpField = new OO.ui.FieldLayout( + new OO.ui.Widget( { + $content: '\xa0', + classes: [ 'mw-apisandbox-spacer' ] + } ), { + align: 'inline', + classes: [ 'mw-apisandbox-help-field' ], + label: descriptionContainer + } + ); + + layoutConfig = { + align: 'left', + classes: [ 'mw-apisandbox-widget-field' ], + label: name + }; + + if ( ppi.tokentype ) { + button = new OO.ui.ButtonWidget( { + label: mw.message( 'apisandbox-fetch-token' ).text() + } ); + button.on( 'click', widget.fetchToken, [], widget ); + + widgetField = new OO.ui.ActionFieldLayout( widget, button, layoutConfig ); + } else { + widgetField = new OO.ui.FieldLayout( widget, layoutConfig ); + } + + // We need our own click handler on the widget label to + // turn off the disablement. + widgetField.$label.on( 'click', widgetLabelOnClick.bind( widgetField ) ); + + // Don't grey out the label when the field is disabled, + // it makes it too hard to read and our "disabled" + // isn't really disabled. + widgetField.onFieldDisable( false ); + widgetField.onFieldDisable = $.noop; + + widgetField.apiParamIndex = ppi.index; + + return { + widget: widget, + widgetField: widgetField, + helpField: helpField + }; + }; + + /** + * Update templated parameters in the page + * @private + * @param {Object} [params] Query parameters for initializing the widgets + */ + ApiSandbox.PageLayout.prototype.updateTemplatedParameters = function ( params ) { + var p, toProcess, doProcess, tmp, toRemove, + that = this, + pi = this.paramInfo, + prefix = that.prefix + pi.prefix; + + if ( !pi || !pi.templatedparameters.length ) { + return; + } + + if ( !$.isPlainObject( params ) ) { + params = null; + } + + toRemove = {}; + $.each( this.templatedItemsCache, function ( k, el ) { + if ( el.widget.isElementAttached() ) { + toRemove[ k ] = el; + } + } ); + + // This bit duplicates the PHP logic in ApiBase::extractRequestParams(). + // If you update this, see if that needs updating too. + toProcess = pi.templatedparameters.map( function ( p ) { + return { + name: prefix + p.name, + info: p, + vars: $.extend( {}, p.templatevars ), + usedVars: [] + }; + } ); + doProcess = function ( placeholder, target ) { + var values, container, index, usedVars, done; + + target = prefix + target; + + if ( !that.widgets[ target ] ) { + // The target wasn't processed yet, try the next one. + // If all hit this case, the parameter has no expansions. + return true; + } + + if ( !that.widgets[ target ].getApiValueForTemplates ) { + // Not a multi-valued widget, so it can't have expansions. + return false; + } + + values = that.widgets[ target ].getApiValueForTemplates(); + if ( !Array.isArray( values ) || !values.length ) { + // The target was processed but has no (valid) values. + // That means it has no expansions. + return false; + } + + // Expand this target in the name and all other targets, + // then requeue if there are more targets left or create the widget + // and add it to the form if all are done. + delete p.vars[ placeholder ]; + usedVars = p.usedVars.concat( [ target ] ); + placeholder = '{' + placeholder + '}'; + done = $.isEmptyObject( p.vars ); + if ( done ) { + container = Util.apiBool( p.info.deprecated ) ? that.deprecatedItemsFieldset : that.itemsFieldset; + index = container.getItems().findIndex( function ( el ) { + return el.apiParamIndex !== undefined && el.apiParamIndex > p.info.index; + } ); + if ( index < 0 ) { + index = undefined; + } + } + values.forEach( function ( value ) { + var name, newVars; + + if ( !/^[^{}]*$/.exec( value ) ) { + // Skip values that make invalid parameter names + return; + } + + name = p.name.replace( placeholder, value ); + if ( done ) { + if ( that.templatedItemsCache[ name ] ) { + tmp = that.templatedItemsCache[ name ]; + } else { + tmp = that.makeWidgetFieldLayouts( + $.extend( {}, p.info, { usedTemplateVars: usedVars } ), name + ); + that.templatedItemsCache[ name ] = tmp; + } + delete toRemove[ name ]; + if ( !tmp.widget.isElementAttached() ) { + that.widgets[ name ] = tmp.widget; + container.addItems( [ tmp.widgetField, tmp.helpField ], index ); + if ( index !== undefined ) { + index += 2; + } + } + if ( params ) { + tmp.widget.setApiValue( params.hasOwnProperty( name ) ? params[ name ] : undefined ); + } + } else { + newVars = {}; + $.each( p.vars, function ( k, v ) { + newVars[ k ] = v.replace( placeholder, value ); + } ); + toProcess.push( { + name: name, + info: p.info, + vars: newVars, + usedVars: usedVars + } ); + } + } ); + return false; + }; + while ( toProcess.length ) { + p = toProcess.shift(); + $.each( p.vars, doProcess ); + } + + toRemove = $.map( toRemove, function ( el, name ) { + delete that.widgets[ name ]; + return [ el.widgetField, el.helpField ]; + } ); + if ( toRemove.length ) { + this.itemsFieldset.removeItems( toRemove ); + this.deprecatedItemsFieldset.removeItems( toRemove ); + } + }; + /** * Fetch module information for this page's module, then create UI */ @@ -1414,22 +1779,13 @@ Util.fetchModuleInfo( this.apiModule ) .done( function ( pi ) { - var prefix, i, j, descriptionContainer, widget, layoutConfig, button, widgetField, helpField, tmp, flag, count, + var prefix, i, j, tmp, items = [], deprecatedItems = [], buttons = [], filterFmModules = function ( v ) { return v.substr( -2 ) !== 'fm' || !availableFormats.hasOwnProperty( v.substr( 0, v.length - 2 ) ); - }, - widgetLabelOnClick = function () { - var f = this.getField(); - if ( $.isFunction( f.setDisabled ) ) { - f.setDisabled( false ); - } - if ( $.isFunction( f.focus ) ) { - f.focus(); - } }; // This is something of a hack. We always want the 'format' and @@ -1518,168 +1874,12 @@ if ( pi.parameters.length ) { prefix = that.prefix + pi.prefix; for ( i = 0; i < pi.parameters.length; i++ ) { - widget = Util.createWidgetForParameter( pi.parameters[ i ] ); - that.widgets[ prefix + pi.parameters[ i ].name ] = widget; - if ( pi.parameters[ i ].tokentype ) { - that.tokenWidget = widget; - } - - descriptionContainer = $( '
' ); - - tmp = Util.parseHTML( pi.parameters[ i ].description ); - tmp.filter( 'dl' ).makeCollapsible( { - collapsed: true - } ).children( '.mw-collapsible-toggle' ).each( function () { - var $this = $( this ); - $this.parent().prev( 'p' ).append( $this ); - } ); - descriptionContainer.append( $( '
' ).addClass( 'description' ).append( tmp ) ); - - if ( pi.parameters[ i ].info && pi.parameters[ i ].info.length ) { - for ( j = 0; j < pi.parameters[ i ].info.length; j++ ) { - descriptionContainer.append( $( '
' ) - .addClass( 'info' ) - .append( Util.parseHTML( pi.parameters[ i ].info[ j ] ) ) - ); - } - } - flag = true; - count = 1e100; - switch ( pi.parameters[ i ].type ) { - case 'namespace': - flag = false; - count = mw.config.get( 'wgFormattedNamespaces' ).length; - break; - - case 'limit': - if ( pi.parameters[ i ].highmax !== undefined ) { - descriptionContainer.append( $( '
' ) - .addClass( 'info' ) - .append( - Util.parseMsg( - 'api-help-param-limit2', pi.parameters[ i ].max, pi.parameters[ i ].highmax - ), - ' ', - Util.parseMsg( 'apisandbox-param-limit' ) - ) - ); - } else { - descriptionContainer.append( $( '
' ) - .addClass( 'info' ) - .append( - Util.parseMsg( 'api-help-param-limit', pi.parameters[ i ].max ), - ' ', - Util.parseMsg( 'apisandbox-param-limit' ) - ) - ); - } - break; - - case 'integer': - tmp = ''; - if ( pi.parameters[ i ].min !== undefined ) { - tmp += 'min'; - } - if ( pi.parameters[ i ].max !== undefined ) { - tmp += 'max'; - } - if ( tmp !== '' ) { - descriptionContainer.append( $( '
' ) - .addClass( 'info' ) - .append( Util.parseMsg( - 'api-help-param-integer-' + tmp, - Util.apiBool( pi.parameters[ i ].multi ) ? 2 : 1, - pi.parameters[ i ].min, pi.parameters[ i ].max - ) ) - ); - } - break; - - default: - if ( Array.isArray( pi.parameters[ i ].type ) ) { - flag = false; - count = pi.parameters[ i ].type.length; - } - break; - } - if ( Util.apiBool( pi.parameters[ i ].multi ) ) { - tmp = []; - if ( flag && !( widget instanceof OO.ui.TagMultiselectWidget ) && - !( - widget instanceof OptionalWidget && - widget.widget instanceof OO.ui.TagMultiselectWidget - ) - ) { - tmp.push( mw.message( 'api-help-param-multi-separate' ).parse() ); - } - if ( count > pi.parameters[ i ].lowlimit ) { - tmp.push( - mw.message( 'api-help-param-multi-max', - pi.parameters[ i ].lowlimit, pi.parameters[ i ].highlimit - ).parse() - ); - } - if ( tmp.length ) { - descriptionContainer.append( $( '
' ) - .addClass( 'info' ) - .append( Util.parseHTML( tmp.join( ' ' ) ) ) - ); - } - } - if ( 'maxbytes' in pi.parameters[ i ] ) { - descriptionContainer.append( $( '
' ) - .addClass( 'info' ) - .append( Util.parseMsg( 'api-help-param-maxbytes', pi.parameters[ i ].maxbytes ) ) - ); - } - if ( 'maxchars' in pi.parameters[ i ] ) { - descriptionContainer.append( $( '
' ) - .addClass( 'info' ) - .append( Util.parseMsg( 'api-help-param-maxchars', pi.parameters[ i ].maxchars ) ) - ); - } - helpField = new OO.ui.FieldLayout( - new OO.ui.Widget( { - $content: '\xa0', - classes: [ 'mw-apisandbox-spacer' ] - } ), { - align: 'inline', - classes: [ 'mw-apisandbox-help-field' ], - label: descriptionContainer - } - ); - - layoutConfig = { - align: 'left', - classes: [ 'mw-apisandbox-widget-field' ], - label: prefix + pi.parameters[ i ].name - }; - - if ( pi.parameters[ i ].tokentype ) { - button = new OO.ui.ButtonWidget( { - label: mw.message( 'apisandbox-fetch-token' ).text() - } ); - button.on( 'click', widget.fetchToken, [], widget ); - - widgetField = new OO.ui.ActionFieldLayout( widget, button, layoutConfig ); - } else { - widgetField = new OO.ui.FieldLayout( widget, layoutConfig ); - } - - // We need our own click handler on the widget label to - // turn off the disablement. - widgetField.$label.on( 'click', widgetLabelOnClick.bind( widgetField ) ); - - // Don't grey out the label when the field is disabled, - // it makes it too hard to read and our "disabled" - // isn't really disabled. - widgetField.onFieldDisable( false ); - widgetField.onFieldDisable = $.noop; - + tmp = that.makeWidgetFieldLayouts( pi.parameters[ i ], prefix + pi.parameters[ i ].name ); + that.widgets[ prefix + pi.parameters[ i ].name ] = tmp.widget; if ( Util.apiBool( pi.parameters[ i ].deprecated ) ) { - deprecatedItems.push( widgetField, helpField ); + deprecatedItems.push( tmp.widgetField, tmp.helpField ); } else { - items.push( widgetField, helpField ); + items.push( tmp.widgetField, tmp.helpField ); } } } @@ -1695,10 +1895,11 @@ that.$element.empty(); - new OO.ui.FieldsetLayout( { + that.itemsFieldset = new OO.ui.FieldsetLayout( { label: that.displayText - } ).addItems( items ) - .$element.appendTo( that.$element ); + } ); + that.itemsFieldset.addItems( items ); + that.itemsFieldset.$element.appendTo( that.$element ); if ( Util.apiBool( pi.dynamicparameters ) ) { dynamicFieldset = new OO.ui.FieldsetLayout(); @@ -1732,19 +1933,24 @@ .appendTo( that.$element ); } - if ( deprecatedItems.length ) { - tmp = new OO.ui.FieldsetLayout().addItems( deprecatedItems ).toggle( false ); - $( '
' ) - .append( - $( '' ).append( - new OO.ui.ToggleButtonWidget( { - label: mw.message( 'apisandbox-deprecated-parameters' ).text() - } ).on( 'change', tmp.toggle, [], tmp ).$element - ), - tmp.$element - ) - .appendTo( that.$element ); - } + that.deprecatedItemsFieldset = new OO.ui.FieldsetLayout().addItems( deprecatedItems ).toggle( false ); + tmp = $( '
' ) + .toggle( !that.deprecatedItemsFieldset.isEmpty() ) + .append( + $( '' ).append( + new OO.ui.ToggleButtonWidget( { + label: mw.message( 'apisandbox-deprecated-parameters' ).text() + } ).on( 'change', that.deprecatedItemsFieldset.toggle, [], that.deprecatedItemsFieldset ).$element + ), + that.deprecatedItemsFieldset.$element + ) + .appendTo( that.$element ); + that.deprecatedItemsFieldset.on( 'add', function () { + this.toggle( !that.deprecatedItemsFieldset.isEmpty() ); + }, [], tmp ); + that.deprecatedItemsFieldset.on( 'remove', function () { + this.toggle( !that.deprecatedItemsFieldset.isEmpty() ); + }, [], tmp ); // Load stored params, if any, then update the booklet if we // have subpages (or else just update our valid-indicator). @@ -1752,6 +1958,8 @@ that.loadFromQueryParams = null; if ( $.isPlainObject( tmp ) ) { that.loadQueryParams( tmp ); + } else { + that.updateTemplatedParameters(); } if ( that.getSubpages().length > 0 ) { ApiSandbox.updateUI( tmp ); @@ -1812,6 +2020,7 @@ var v = params.hasOwnProperty( name ) ? params[ name ] : undefined; widget.setApiValue( v ); } ); + this.updateTemplatedParameters( params ); } }; diff --git a/tests/phpunit/includes/api/ApiBaseTest.php b/tests/phpunit/includes/api/ApiBaseTest.php index 4bffc742f6..e7db68eee5 100644 --- a/tests/phpunit/includes/api/ApiBaseTest.php +++ b/tests/phpunit/includes/api/ApiBaseTest.php @@ -1272,4 +1272,99 @@ class ApiBaseTest extends ApiTestCase { } } + /** + * @covers ApiBase::extractRequestParams + */ + public function testExtractRequestParams() { + $request = new FauxRequest( [ + 'xxexists' => 'exists!', + 'xxmulti' => 'a|b|c|d|{bad}', + 'xxempty' => '', + 'xxtemplate-a' => 'A!', + 'xxtemplate-b' => 'B1|B2|B3', + 'xxtemplate-c' => '', + 'xxrecursivetemplate-b-B1' => 'X', + 'xxrecursivetemplate-b-B3' => 'Y', + 'xxrecursivetemplate-b-B4' => '?', + 'xxemptytemplate-' => 'nope', + 'foo' => 'a|b|c', + 'xxfoo' => 'a|b|c', + 'errorformat' => 'raw', + ] ); + $context = new DerivativeContext( RequestContext::getMain() ); + $context->setRequest( $request ); + $main = new ApiMain( $context ); + + $mock = $this->getMockBuilder( ApiBase::class ) + ->setConstructorArgs( [ $main, 'test', 'xx' ] ) + ->setMethods( [ 'getAllowedParams' ] ) + ->getMockForAbstractClass(); + $mock->method( 'getAllowedParams' )->willReturn( [ + 'notexists' => null, + 'exists' => null, + 'multi' => [ + ApiBase::PARAM_ISMULTI => true, + ], + 'empty' => [ + ApiBase::PARAM_ISMULTI => true, + ], + 'template-{m}' => [ + ApiBase::PARAM_ISMULTI => true, + ApiBase::PARAM_TEMPLATE_VARS => [ 'm' => 'multi' ], + ], + 'recursivetemplate-{m}-{t}' => [ + ApiBase::PARAM_TEMPLATE_VARS => [ 't' => 'template-{m}', 'm' => 'multi' ], + ], + 'emptytemplate-{m}' => [ + ApiBase::PARAM_ISMULTI => true, + ApiBase::PARAM_TEMPLATE_VARS => [ 'm' => 'empty' ], + ], + 'badtemplate-{e}' => [ + ApiBase::PARAM_TEMPLATE_VARS => [ 'e' => 'exists' ], + ], + 'badtemplate2-{e}' => [ + ApiBase::PARAM_TEMPLATE_VARS => [ 'e' => 'badtemplate2-{e}' ], + ], + 'badtemplate3-{x}' => [ + ApiBase::PARAM_TEMPLATE_VARS => [ 'x' => 'foo' ], + ], + ] ); + + $this->assertEquals( [ + 'notexists' => null, + 'exists' => 'exists!', + 'multi' => [ 'a', 'b', 'c', 'd', '{bad}' ], + 'empty' => [], + 'template-a' => [ 'A!' ], + 'template-b' => [ 'B1', 'B2', 'B3' ], + 'template-c' => [], + 'template-d' => null, + 'recursivetemplate-a-A!' => null, + 'recursivetemplate-b-B1' => 'X', + 'recursivetemplate-b-B2' => null, + 'recursivetemplate-b-B3' => 'Y', + ], $mock->extractRequestParams() ); + + $used = TestingAccessWrapper::newFromObject( $main )->getParamsUsed(); + sort( $used ); + $this->assertEquals( [ + 'xxempty', + 'xxexists', + 'xxmulti', + 'xxnotexists', + 'xxrecursivetemplate-a-A!', + 'xxrecursivetemplate-b-B1', + 'xxrecursivetemplate-b-B2', + 'xxrecursivetemplate-b-B3', + 'xxtemplate-a', + 'xxtemplate-b', + 'xxtemplate-c', + 'xxtemplate-d', + ], $used ); + + $warnings = $mock->getResult()->getResultData( 'warnings', [ 'Strip' => 'all' ] ); + $this->assertCount( 1, $warnings ); + $this->assertSame( 'ignoring-invalid-templated-value', $warnings[0]['code'] ); + } + } diff --git a/tests/phpunit/structure/ApiStructureTest.php b/tests/phpunit/structure/ApiStructureTest.php index 77d6e74174..692bd73a9f 100644 --- a/tests/phpunit/structure/ApiStructureTest.php +++ b/tests/phpunit/structure/ApiStructureTest.php @@ -60,6 +60,7 @@ class ApiStructureTest extends MediaWikiTestCase { ApiBase::PARAM_ISMULTI_LIMIT2 => [ 'integer' ], ApiBase::PARAM_MAX_BYTES => [ 'integer' ], ApiBase::PARAM_MAX_CHARS => [ 'integer' ], + ApiBase::PARAM_TEMPLATE_VARS => [ 'array' ], ]; // param => [ other param that must be present => required value or null ] @@ -422,6 +423,45 @@ class ApiStructureTest extends MediaWikiTestCase { "$param: PARAM_MAX_BYTES cannot be less than PARAM_MAX_CHARS" ); } + + if ( isset( $config[ApiBase::PARAM_TEMPLATE_VARS] ) ) { + $this->assertNotSame( [], $config[ApiBase::PARAM_TEMPLATE_VARS], + "$param: PARAM_TEMPLATE_VARS cannot be empty" ); + foreach ( $config[ApiBase::PARAM_TEMPLATE_VARS] as $key => $target ) { + $this->assertRegExp( '/^[^{}]+$/', $key, + "$param: PARAM_TEMPLATE_VARS key may not contain '{' or '}'" ); + + $this->assertContains( '{' . $key . '}', $param, + "$param: Name must contain PARAM_TEMPLATE_VARS key {" . $key . "}" ); + $this->assertArrayHasKey( $target, $params, + "$param: PARAM_TEMPLATE_VARS target parameter '$target' does not exist" ); + $config2 = $params[$target]; + $this->assertTrue( !empty( $config2[ApiBase::PARAM_ISMULTI] ), + "$param: PARAM_TEMPLATE_VARS target parameter '$target' must have PARAM_ISMULTI = true" ); + + if ( isset( $config2[ApiBase::PARAM_TEMPLATE_VARS] ) ) { + $this->assertNotSame( $param, $target, + "$param: PARAM_TEMPLATE_VARS cannot target itself" ); + + $this->assertArraySubset( + $config2[ApiBase::PARAM_TEMPLATE_VARS], + $config[ApiBase::PARAM_TEMPLATE_VARS], + true, + "$param: PARAM_TEMPLATE_VARS target parameter '$target': " + . "the target's PARAM_TEMPLATE_VARS must be a subset of the original." + ); + } + } + + $keys = implode( '|', + array_map( 'preg_quote', array_keys( $config[ApiBase::PARAM_TEMPLATE_VARS] ) ) + ); + $this->assertRegExp( '/^(?>[^{}]+|\{(?:' . $keys . ')\})+$/', $param, + "$param: Name may not contain '{' or '}' other than as defined by PARAM_TEMPLATE_VARS" ); + } else { + $this->assertRegExp( '/^[^{}]+$/', $param, + "$param: Name may not contain '{' or '}' without PARAM_TEMPLATE_VARS" ); + } } } } -- 2.20.1