API: Introduce "templated parameters"
authorBrad Jorsch <bjorsch@wikimedia.org>
Wed, 4 Apr 2018 20:22:01 +0000 (16:22 -0400)
committerBrad Jorsch <bjorsch@wikimedia.org>
Wed, 16 May 2018 20:19:31 +0000 (16:19 -0400)
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

13 files changed:
RELEASE-NOTES-1.32
includes/api/ApiBase.php
includes/api/ApiHelp.php
includes/api/ApiMain.php
includes/api/ApiParamInfo.php
includes/api/i18n/en.json
includes/api/i18n/qqq.json
languages/i18n/en.json
languages/i18n/qqq.json
resources/Resources.php
resources/src/mediawiki.special.apisandbox/apisandbox.js
tests/phpunit/includes/api/ApiBaseTest.php
tests/phpunit/structure/ApiStructureTest.php

index 4c56eec..c06ba91 100644 (file)
@@ -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.
index 7fafa1f..0802e16 100644 (file)
@@ -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];
        }
 
        /**
index 8d24859..bccb338 100644 (file)
@@ -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] )
index b7b13c5..914d8e9 100644 (file)
@@ -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 = '<div id="main/templatedparams"></div>' . $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 );
index bfd3d61..b8a32ae 100644 (file)
@@ -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 ) {
index 6838e54..573d37c 100644 (file)
        "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": "<var>&#x7B;$1&#x7D;</var> in the parameter's name should be replaced with values of <var>$2</var>",
+       "api-help-param-templated-var": "<var>&#x7B;$1&#x7D;</var> with values of <var>$2</var>",
        "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, <kbd><var>2001</var>-<var>01</var>-<var>15</var>T<var>14</var>:<var>56</var>:<var>00</var>Z</kbd> (punctuation and <kbd>Z</kbd> are optional)\n:* ISO 8601 date and time with (ignored) fractional seconds, <kbd><var>2001</var>-<var>01</var>-<var>15</var>T<var>14</var>:<var>56</var>:<var>00</var>.<var>00001</var>Z</kbd> (dashes, colons, and <kbd>Z</kbd> are optional)\n:* MediaWiki format, <kbd><var>2001</var><var>01</var><var>15</var><var>14</var><var>56</var><var>00</var></kbd>\n:* Generic numeric format, <kbd><var>2001</var>-<var>01</var>-<var>15</var> <var>14</var>:<var>56</var>:<var>00</var></kbd> (optional timezone of <kbd>GMT</kbd>, <kbd>+<var>##</var></kbd>, or <kbd>-<var>##</var></kbd> is ignored)\n:* EXIF format, <kbd><var>2001</var>:<var>01</var>:<var>15</var> <var>14</var>:<var>56</var>:<var>00</var></kbd>\n:*RFC 2822 format (timezone may be omitted), <kbd><var>Mon</var>, <var>15</var> <var>Jan</var> <var>2001</var> <var>14</var>:<var>56</var>:<var>00</var></kbd>\n:* RFC 850 format (timezone may be omitted), <kbd><var>Monday</var>, <var>15</var>-<var>Jan</var>-<var>2001</var> <var>14</var>:<var>56</var>:<var>00</var></kbd>\n:* C ctime format, <kbd><var>Mon</var> <var>Jan</var> <var>15</var> <var>14</var>:<var>56</var>:<var>00</var> <var>2001</var></kbd>\n:* Seconds since 1970-01-01T00:00:00Z as a 1 to 13 digit integer (excluding <kbd>0</kbd>)\n:* The string <kbd>now</kbd>\n;alternative multiple-value separator\n:Parameters that take multiple values are normally submitted with the values separated using the pipe character, e.g. <kbd>param=value1|value2</kbd> or <kbd>param=value1%7Cvalue2</kbd>. 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. <kbd>param=%1Fvalue1%1Fvalue2</kbd>.",
+       "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 <var>fruits</var> to specify which fruits are being requested and a templated parameter <var>{fruit}-quantity</var> 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 <kbd>fruits=apples|bananas|strawberries&apples-quantity=1&bananas-quantity=5&strawberries-quantity=20</kbd>.",
        "api-help-param-type-limit": "Type: integer or <kbd>max</kbd>",
        "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]])",
        "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 <kbd>$2</kbd> in <var>$1</var> when processing templated parameters.",
        "apiwarn-invalidcategory": "\"$1\" is not a category.",
        "apiwarn-invalidtitle": "\"$1\" is not a valid title.",
        "apiwarn-invalidxmlstylesheetext": "Stylesheet should have <code>.xsl</code> extension.",
index 594bf8e..086e74b 100644 (file)
        "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 <nowiki><kbd></kbd></nowiki> or <nowiki><var></var></nowiki> 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 <nowiki><kbd></kbd></nowiki> or <nowiki><var></var></nowiki> 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 &lt;kbd&gt; 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 <code>Special:ApiHelp</code> 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]]",
        "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}}",
index 215b356..236d6e5 100644 (file)
        "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",
index 97ac807..0947db2 100644 (file)
        "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}}",
index d0bc1ba..e187ef2 100644 (file)
@@ -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' => [
index 523a62e..f936658 100644 (file)
                        }
                }
 
+               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;
        };
 
                },
 
                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 ) {
                                }
                        },
                        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,
                                        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 );
                        }
 
                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 );
                );
        };
 
+       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 = $( '<div>' );
+
+               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( $( '<div>' ).addClass( 'description' ).append( tmp ) );
+
+               if ( ppi.info && ppi.info.length ) {
+                       for ( j = 0; j < ppi.info.length; j++ ) {
+                               descriptionContainer.append( $( '<div>' )
+                                       .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( $( '<div>' )
+                                               .addClass( 'info' )
+                                               .append(
+                                                       Util.parseMsg(
+                                                               'api-help-param-limit2', ppi.max, ppi.highmax
+                                                       ),
+                                                       ' ',
+                                                       Util.parseMsg( 'apisandbox-param-limit' )
+                                               )
+                                       );
+                               } else {
+                                       descriptionContainer.append( $( '<div>' )
+                                               .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( $( '<div>' )
+                                               .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( $( '<div>' )
+                                       .addClass( 'info' )
+                                       .append( Util.parseHTML( tmp.join( ' ' ) ) )
+                               );
+                       }
+               }
+               if ( 'maxbytes' in ppi ) {
+                       descriptionContainer.append( $( '<div>' )
+                               .addClass( 'info' )
+                               .append( Util.parseMsg( 'api-help-param-maxbytes', ppi.maxbytes ) )
+                       );
+               }
+               if ( 'maxchars' in ppi ) {
+                       descriptionContainer.append( $( '<div>' )
+                               .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( $( '<var>' ).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( $( '<div>' )
+                               .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
         */
 
                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
                                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 = $( '<div>' );
-
-                                               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( $( '<div>' ).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( $( '<div>' )
-                                                                       .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( $( '<div>' )
-                                                                               .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( $( '<div>' )
-                                                                               .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( $( '<div>' )
-                                                                               .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( $( '<div>' )
-                                                                       .addClass( 'info' )
-                                                                       .append( Util.parseHTML( tmp.join( ' ' ) ) )
-                                                               );
-                                                       }
-                                               }
-                                               if ( 'maxbytes' in pi.parameters[ i ] ) {
-                                                       descriptionContainer.append( $( '<div>' )
-                                                               .addClass( 'info' )
-                                                               .append( Util.parseMsg( 'api-help-param-maxbytes', pi.parameters[ i ].maxbytes ) )
-                                                       );
-                                               }
-                                               if ( 'maxchars' in pi.parameters[ i ] ) {
-                                                       descriptionContainer.append( $( '<div>' )
-                                                               .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 );
                                                }
                                        }
                                }
 
                                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();
                                                .appendTo( that.$element );
                                }
 
-                               if ( deprecatedItems.length ) {
-                                       tmp = new OO.ui.FieldsetLayout().addItems( deprecatedItems ).toggle( false );
-                                       $( '<fieldset>' )
-                                               .append(
-                                                       $( '<legend>' ).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 = $( '<fieldset>' )
+                                       .toggle( !that.deprecatedItemsFieldset.isEmpty() )
+                                       .append(
+                                               $( '<legend>' ).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).
                                that.loadFromQueryParams = null;
                                if ( $.isPlainObject( tmp ) ) {
                                        that.loadQueryParams( tmp );
+                               } else {
+                                       that.updateTemplatedParameters();
                                }
                                if ( that.getSubpages().length > 0 ) {
                                        ApiSandbox.updateUI( tmp );
                                var v = params.hasOwnProperty( name ) ? params[ name ] : undefined;
                                widget.setApiValue( v );
                        } );
+                       this.updateTemplatedParameters( params );
                }
        };
 
index 4bffc74..e7db68e 100644 (file)
@@ -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'] );
+       }
+
 }
index 77d6e74..692bd73 100644 (file)
@@ -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" );
+                               }
                        }
                }
        }