luci-base: add tuple validator

There are a number of validation types which are useful
but inaccessible when a value field combines simple
data-types. Example <ipaddr><space><ipaddr>. At which point
one must write a custom validate function, and applying the
built-in factory methods is not trivial.

Introduce a tuple function which combines known types
to validate a string, with a single line definition.
E.g. an IP and a port space-separated:

opt.datatype = 'tuple(ipaddr,port)';

All validation methods must return true for valid data.

The tuple function splits on space by default, or any string
provided by sep(). Here, a comma:

opt.datatype = 'tuple(ipaddr,port,sep(","))';

After the string is separated, any error message displayed
corresponds to the first invalid part of the input string
encountered.

Signed-off-by: Paul Donald <newtwen+github@gmail.com>
This commit is contained in:
Paul Donald
2026-02-14 19:05:19 +01:00
parent 7ee6ba3dc2
commit b6fc02d281
@@ -309,6 +309,18 @@ const ValidatorFactory = baseclass.extend(/** @lends LuCI.validation.ValidatorFa
esc = true;
break;
// Skip over quoted strings so commas inside quotes don't split tokens
case 34: // "
case 39: { // '\''
const quote = code.charCodeAt(i);
let j = i + 1;
for (; j < code.length; j++) {
if (code.charCodeAt(j) === 92) { j++; continue; }
if (code.charCodeAt(j) === quote) { i = j; break; }
}
break;
}
case 40:
case 44:
if (depth <= 0) {
@@ -841,6 +853,95 @@ const ValidatorFactory = baseclass.extend(/** @lends LuCI.validation.ValidatorFa
_('valid address:port'));
},
/**
* Define a string separator `sep` for use in [tuple]{@link
* LuCI.validation.ValidatorFactory.types#tuple}.
* @function LuCI.validation.ValidatorFactory.types#sep
* @param {string} str define the separator string
* @returns {@link LuCI.validation.Validator#assert assert()} {boolean}
*/
sep(str) {
return this.apply('string', str);
},
/**
* Tuple validator: accepts 1-N tokens separated by a given separator
* {@link LuCI.validation.ValidatorFactory.types#sep sep}
* (whitespace by default if {@link LuCI.validation.ValidatorFactory.types#sep sep}
* is omitted) which will be validated against the 1-N types.
*
* This differs from {@link LuCI.validation.ValidatorFactory.types#and and}
* by first splitting the input and applying each validator function
* sequentially on the resulting array of the split string, whereby the
* first type applies to the first value element, the second to the
* second, and so on, to define a concrete order.
*
* {@link LuCI.validation.ValidatorFactory.types#sep sep}
* can appear at any position in the list.
*
* @example
*
* tuple(ipaddr,port) // "192.0.2.1 88"
*
* tuple(host,port,sep(',')) // "taurus,8000"
*
* tuple(port,port,port,sep('-')) // "33-45-78"
*
* @function LuCI.validation.ValidatorFactory.types#tuple
* @param {...function} types {@link LuCI.validation.ValidatorFactory.types
* types validation functions}
* @param {string} [sep()] function to define split separator string.
* @returns {@link LuCI.validation.Validator#assert assert()} {boolean}
*/
tuple() {
const argsraw = Array.prototype.slice.call(arguments);
let sep = null;
// Build list of (validator, validatorArgs) pairs
const types = [];
for (let i = 0; i < argsraw.length; i += 2)
types.push([ argsraw[i], argsraw[i+1] ]);
// Determine the separator, if provided
if (types.length) {
for (let t of types) {
if (t[0] === this.factory.types['sep']) {
const e = types.pop();
if (Array.isArray(e[1]) && e[1].length > 0)
sep = e[1][0];
}
}
}
const raw = (this.value || '');
let tokens = (sep == null) ? raw.split(/\s+/) : raw.split(sep).map(s => s.trim());
if (tokens.length != types.length) {
const getName = (t) => {
if (typeof t === 'function') {
for (const k in this.factory.types)
if (this.factory.types[k] === t)
return k;
return _('value');
}
return _('value');
};
const expectedTypes = types.map(t => getName(t[0])).join(sep == null ? ' ' : sep);
const sepDesc = sep == null ? _('whitespace') : `"${sep}"`;
const msg_multi = _('%s; %d tokens separated by %s').format(expectedTypes, types.length, sepDesc);
const msg_single = _('%s').format(expectedTypes, types.length, sepDesc);
return this.assert(false, (types.length > 1) ? msg_multi : msg_single);
}
for (let i = 0; i < tokens.length; i++) {
if (!this.apply(types[i][0], tokens[i], types[i][1]))
return this.assert(false, this.error);
}
return this.assert(true);
},
/**
* Assert a valid (hexadecimal) WPA key of `8 <= length <= 63`, or hex if `length == 64`.
* @function LuCI.validation.ValidatorFactory.types#wpakey