@mapbox/mapbox-gl-style-spec
Version: 
a specification for mapbox gl styles
238 lines (199 loc) • 10.1 kB
text/typescript
import ValidationError from '../error/validation_error';
import {getType, isObject} from '../util/get_type';
import validate from './validate';
import validateObject from './validate_object';
import validateArray from './validate_array';
import validateNumber from './validate_number';
import {isExpression} from '../expression/index';
import {unbundle, deepUnbundle} from '../util/unbundle_jsonlint';
import {
    supportsPropertyExpression,
    supportsZoomExpression,
    supportsInterpolation
} from '../util/properties';
import type {StyleReference} from '../reference/latest';
import type {StyleSpecification} from '../types';
import type {StylePropertySpecification} from '../style-spec';
function hasObjectStops(value: object): value is {stops: Array<Record<PropertyKey, unknown>>} {
    // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
    const stops = value['stops'];
    return Array.isArray(stops) && Array.isArray(stops[0]) && isObject(stops[0][0]);
}
export type FunctionValidatorOptions = {
    key: string;
    value: unknown;
    valueSpec: unknown;
    style: Partial<StyleSpecification>;
    styleSpec: StyleReference;
};
export default function validateFunction(options: FunctionValidatorOptions): ValidationError[] {
    const key = options.key;
    const value = options.value;
    if (!isObject(value)) {
        return [new ValidationError(key, value, `object expected, ${getType(value)} found`)];
    }
    const functionValueSpec = options.valueSpec;
    const functionType = unbundle(value.type);
    let stopKeyType;
    let stopDomainValues: Partial<Record<string | number, boolean>> = {};
    let previousStopDomainValue: unknown;
    let previousStopDomainZoom;
    const isZoomFunction = functionType !== 'categorical' && value.property === undefined;
    const isPropertyFunction = !isZoomFunction;
    const isZoomAndPropertyFunction = hasObjectStops(value);
    const errors = validateObject({
        key: options.key,
        value: options.value,
        // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
        valueSpec: options.styleSpec.function,
        style: options.style,
        styleSpec: options.styleSpec,
        objectElementValidators: {
            stops: validateFunctionStops,
            default: validateFunctionDefault
        }
    });
    if (functionType === 'identity' && isZoomFunction) {
        errors.push(new ValidationError(options.key, options.value, 'missing required property "property"'));
    }
    if (functionType !== 'identity' && !value.stops) {
        errors.push(new ValidationError(options.key, options.value, 'missing required property "stops"'));
    }
    if (functionType === 'exponential' && (functionValueSpec as {expression?: unknown}).expression && !supportsInterpolation(functionValueSpec as StylePropertySpecification)) {
        errors.push(new ValidationError(options.key, options.value, 'exponential functions not supported'));
    }
    if (options.styleSpec.$version >= 8) {
        if (isPropertyFunction && !supportsPropertyExpression(functionValueSpec as StylePropertySpecification)) {
            errors.push(new ValidationError(options.key, options.value, 'property functions not supported'));
        } else if (isZoomFunction && !supportsZoomExpression(functionValueSpec as StylePropertySpecification)) {
            errors.push(new ValidationError(options.key, options.value, 'zoom functions not supported'));
        }
    }
    if ((functionType === 'categorical' || isZoomAndPropertyFunction) && (value as {property?: string}).property === undefined) {
        errors.push(new ValidationError(options.key, options.value, '"property" property is required'));
    }
    return errors;
    function validateFunctionStops(options: FunctionValidatorOptions): ValidationError[] {
        if (functionType === 'identity') {
            return [new ValidationError(options.key, options.value, 'identity function may not have a "stops" property')];
        }
        let errors: ValidationError[] = [];
        const value = options.value;
        errors = errors.concat(validateArray({
            key: options.key,
            value,
            valueSpec: options.valueSpec as Extract<StylePropertySpecification, {type: 'array'}>,
            style: options.style,
            styleSpec: options.styleSpec,
            arrayElementValidator: validateFunctionStop
        }));
        if (Array.isArray(value) && value.length === 0) {
            errors.push(new ValidationError(options.key, value, 'array must have at least one stop'));
        }
        return errors;
    }
    function validateFunctionStop(options: FunctionValidatorOptions): ValidationError[] {
        let errors: ValidationError[] = [];
        const value = options.value;
        const key = options.key;
        if (!Array.isArray(value)) {
            return [new ValidationError(key, value, `array expected, ${getType(value)} found`)];
        }
        if (value.length !== 2) {
            return [new ValidationError(key, value, `array length 2 expected, length ${value.length} found`)];
        }
        if (isZoomAndPropertyFunction) {
            if (!isObject(value[0])) {
                return [new ValidationError(key, value, `object expected, ${getType(value[0])} found`)];
            }
            const stopKey = value[0];
            if (stopKey.zoom === undefined) {
                return [new ValidationError(key, value, 'object stop key must have zoom')];
            }
            if (stopKey.value === undefined) {
                return [new ValidationError(key, value, 'object stop key must have value')];
            }
            const nextStopDomainZoom = unbundle(stopKey.zoom);
            if (typeof nextStopDomainZoom !== 'number') {
                return [new ValidationError(key, stopKey.zoom, 'stop zoom values must be numbers')];
            }
            if (previousStopDomainZoom && previousStopDomainZoom > nextStopDomainZoom) {
                return [new ValidationError(key, stopKey.zoom, 'stop zoom values must appear in ascending order')];
            }
            if (nextStopDomainZoom !== previousStopDomainZoom) {
                previousStopDomainZoom = nextStopDomainZoom;
                previousStopDomainValue = undefined;
                stopDomainValues = {};
            }
            errors = errors.concat(validateObject({
                key: `${key}[0]`,
                value: value[0],
                valueSpec: {zoom: {}},
                style: options.style,
                styleSpec: options.styleSpec,
                objectElementValidators: {zoom: validateNumber, value: validateStopDomainValue}
            }));
        } else {
            errors = errors.concat(validateStopDomainValue({
                key: `${key}[0]`,
                value: value[0],
                valueSpec: {},
                style: options.style,
                styleSpec: options.styleSpec
            }, value));
        }
        if (isExpression(deepUnbundle(value[1]))) {
            return errors.concat([new ValidationError(`${key}[1]`, value[1], 'expressions are not allowed in function stops.')]);
        }
        return errors.concat(validate({
            key: `${key}[1]`,
            value: value[1],
            valueSpec: functionValueSpec,
            style: options.style,
            styleSpec: options.styleSpec
        }));
    }
    function validateStopDomainValue(options: FunctionValidatorOptions, stop?: unknown[]): ValidationError[] {
        const type = getType(options.value);
        const value = unbundle(options.value);
        const reportValue = options.value !== null ? options.value : stop;
        if (!stopKeyType) {
            stopKeyType = type;
        } else if (type !== stopKeyType) {
            return [new ValidationError(options.key, reportValue, `${type} stop domain type must match previous stop domain type ${stopKeyType}`)];
        }
        if (type !== 'number' && type !== 'string' && type !== 'boolean' && typeof value !== 'number' && typeof value !== 'string' && typeof value !== 'boolean') {
            return [new ValidationError(options.key, reportValue, 'stop domain value must be a number, string, or boolean')];
        }
        if (type !== 'number' && functionType !== 'categorical') {
            let message = `number expected, ${type} found`;
            if (supportsPropertyExpression(functionValueSpec as StylePropertySpecification) && functionType === undefined) {
                message += '\nIf you intended to use a categorical function, specify `"type": "categorical"`.';
            }
            return [new ValidationError(options.key, reportValue, message)];
        }
        if (functionType === 'categorical' && type === 'number' && (typeof value !== 'number' || !isFinite(value) || Math.floor(value) !== value)) {
            return [new ValidationError(options.key, reportValue, `integer expected, found ${String(value as number)}`)];
        }
        if (functionType !== 'categorical' && type === 'number' && typeof value === 'number' && typeof previousStopDomainValue === 'number' && previousStopDomainValue !== undefined && value < previousStopDomainValue) {
            return [new ValidationError(options.key, reportValue, 'stop domain values must appear in ascending order')];
        } else {
            previousStopDomainValue = value;
        }
        if (functionType === 'categorical' && (value as string) in stopDomainValues) {
            return [new ValidationError(options.key, reportValue, 'stop domain values must be unique')];
        } else {
            stopDomainValues[(value as string)] = true;
        }
        return [];
    }
    function validateFunctionDefault(options: FunctionValidatorOptions) {
        return validate({
            key: options.key,
            value: options.value,
            valueSpec: functionValueSpec,
            style: options.style,
            styleSpec: options.styleSpec
        });
    }
}