Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add object validation and apply it to themes #8902

Merged
2 changes: 2 additions & 0 deletions kolibri/core/assets/src/core-app/apiSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import commonSyncElements from '../mixins/commonSyncElements';
import CoreFullscreen from '../views/CoreFullscreen';
import * as exams from '../exams/utils';
import * as validators from '../validators';
import * as objectSpecs from '../objectSpecs';
import * as serverClock from '../serverClock';
import * as resources from '../api-resources';
import * as i18n from '../utils/i18n';
Expand Down Expand Up @@ -237,5 +238,6 @@ export default {
sortLanguages,
UserType,
validators,
objectSpecs,
},
};
193 changes: 193 additions & 0 deletions kolibri/core/assets/src/objectSpecs.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
/*
This module provides mechanisms for validating and initializing javascript
objects using specifications or "specs" analogous to Vue's prop validation
objects:

- validateObject: recursively validates objects based on a spec. It can be
useful for validation in situations such as using Objects as props, checking
API responses, parsing JSON-based config files, and other situations where
validation of objects is useful. It is not run in production.

- objectWithDefaults: recursively populates default values based on the spec,
in the same way that Vue prop `default` values work.

Each `options` object in a spec can have the following attributes:

- type (primitive data type)
Most commonly one of: Array, Boolean, Object, Number, or String

- required (boolean)
Whether an attribute in the object is required or not. If it is not
required, a `default` value must be provided.

- default (value or function)
Default value for non-required object attributes. Like Vue `data` values,
array and object types need to use a function to return default values.
Defaults are used by the `objectWithDefaults` function to populate objects.

- validator (function)
Like prop validator functions, this takes a value and returns true or false.

- spec (object)
In situations where objects are nested, this property can be used to define
a "sub-spec" for objects within objects recursively. Note that either a spec
or a validator function can be provided for nested objects, but not both.
*/

import logger from 'kolibri.lib.logging';

import clone from 'lodash/clone';
import isArray from 'lodash/isArray';
import isBoolean from 'lodash/isBoolean';
import isDate from 'lodash/isDate';
import isFunction from 'lodash/isFunction';
import isObject from 'lodash/isObject';
import isNumber from 'lodash/isNumber';
import isString from 'lodash/isString';
import isSymbol from 'lodash/isSymbol';
import isUndefined from 'lodash/isUndefined';

const logging = logger.getLogger(__filename);

function _fail(msg, dataKey, data) {
logging.error(`Problem with key '${dataKey}': ${msg}`);
logging.error('Data:', data);
return false;
}

function _validateObjectData(data, options, dataKey) {
const hasData = !isUndefined(data) && data !== null;

// data is not available but required
if (options.required && !hasData) {
return _fail('Required but undefined data', dataKey, data);
}

// should only have a validator or a spec, not both
if (options.validator && options.spec) {
return _fail('Should either have a validator or a sub-spec', dataKey, data);
}

// validation function
if (hasData && options.validator && !options.validator(data)) {
return _fail('Validator function failed', dataKey, data);
}

// object sub-spec
if (hasData && options.spec) {
if (!isObject(data)) {
return _fail('Only objects can have sub-specs', dataKey, data);
}
if (!validateObject(data, options.spec)) {
return _fail('Validator sub-spec failed', dataKey, data);
}
}

// Check types
const KNOWN_TYPES = [Array, Boolean, Date, Function, Object, Number, String, Symbol];
if (isUndefined(options.type)) {
return _fail('No type information provided', dataKey, data);
} else if (!KNOWN_TYPES.includes(options.type)) {
return _fail(`Type '${options.type}' not currently handled`, dataKey);
} else if (hasData) {
if (options.type === Array && !isArray(data)) {
return _fail('Expected Array', dataKey, data);
} else if (options.type === Boolean && !isBoolean(data)) {
return _fail('Expected Boolean', dataKey, data);
} else if (options.type === Date && !isDate(data)) {
return _fail('Expected Date', dataKey, data);
} else if (options.type === Function && !isFunction(data)) {
return _fail('Expected Function', dataKey, data);
} else if (options.type === Object && !isObject(data)) {
return _fail('Expected Object', dataKey, data);
} else if (options.type === Number && !isNumber(data)) {
return _fail('Expected Number', dataKey, data);
} else if (options.type === String && !isString(data)) {
return _fail('Expected String', dataKey, data);
} else if (options.type === Symbol && !isSymbol(data)) {
return _fail('Expected Symbol', dataKey, data);
}
}

// ensure spec has a default when not required and not vice-versa
if (!options.required && isUndefined(options.default)) {
return _fail('Must be either required or have a default', dataKey, data);
} else if (options.required && !isUndefined(options.default)) {
return _fail('Cannot be required and have a default', dataKey, data);
}

// objects and arrays must use a generator function for their default value
if (
options.default &&
(options.type === Array || options.type === Object) &&
!isFunction(options.default)
) {
return _fail('Need a function to return array and object default values', dataKey, data);
}

return true;
}

/*
* Given a JS object and a spec object as defined above, return `true` if the object
* conforms to the spec and `false` otherwise
*/
export function validateObject(object, spec) {
// skip validation in production
if (process.env.NODE_ENV === 'production') {
return true;
}

// Don't end early: provide as much validation messaging as possible
let isValid = true;
for (const dataKey in spec) {
const options = spec[dataKey];
if (!isObject(options)) {
logging.error(`Expected an Object for '${dataKey}' in spec. Got:`, options);
isValid = false;
continue;
}
isValid = _validateObjectData(object[dataKey], options, dataKey) && isValid;
}

// Output additional debug info
if (!isValid) {
logging.info('Spec:');
logging.info(spec);
logging.info('Value:');
logging.info(object);
}

return isValid;
}

/*
* Given a JS object and a spec object as defined above, return a new object
* with any necessary default values filled in as per the spec object.
*/
export function objectWithDefaults(object, spec) {
// create a shallow clone
const cloned = clone(object);
// iterate over spec options
for (const dataKey in spec) {
const options = spec[dataKey];
// set defaults if necessary
if (isUndefined(cloned[dataKey]) && !isUndefined(options.default)) {
// arrays and objects need to use a function to return defaults
const needsFunction = options.type === Array || options.type === Object;
if (needsFunction && options.default !== null) {
cloned[dataKey] = options.default();
}
// all other types can be assigned directly
else {
cloned[dataKey] = options.default;
}
}
// recurse down into sub-specs if necessary
else if (cloned[dataKey] && options.spec) {
cloned[dataKey] = objectWithDefaults(cloned[dataKey], options.spec);
}
}
// return copy of object with defaults filled in
return cloned;
}
12 changes: 9 additions & 3 deletions kolibri/core/assets/src/styles/initializeTheme.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import generateGlobalStyles from 'kolibri-design-system/lib/styles/generateGloba
import Vue from 'vue';
import trackInputModality from 'kolibri-design-system/lib/styles/trackInputModality';
import trackMediaType from 'kolibri-design-system/lib/styles/trackMediaType';
import { validateObject, objectWithDefaults } from 'kolibri.utils.objectSpecs';
import themeSpec from './themeSpec';
import themeConfig from './themeConfig';
import plugin_data from 'plugin_data';

Expand All @@ -14,9 +16,13 @@ export function setThemeConfig(theme) {
}

export default function initializeTheme() {
setBrandColors(plugin_data.kolibriTheme.brandColors);
setTokenMapping(plugin_data.kolibriTheme.tokenMapping);
setThemeConfig(plugin_data.kolibriTheme);
validateObject(plugin_data.kolibriTheme, themeSpec);
const theme = objectWithDefaults(plugin_data.kolibriTheme, themeSpec);
if (theme.brandColors) {
setBrandColors(theme.brandColors);
}
setTokenMapping(theme.tokenMapping);
setThemeConfig(theme);
generateGlobalStyles();
trackInputModality();
trackMediaType();
Expand Down
156 changes: 156 additions & 0 deletions kolibri/core/assets/src/styles/themeSpec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import logger from 'kolibri.lib.logging';
import isObject from 'lodash/isObject';

const logging = logger.getLogger(__filename);

function _colorScaleValidator(value) {
if (!isObject(value)) {
logging.error(`Expected object but got '${value}'`);
return false;
}
const COLOR_NAMES = [
'v_50',
'v_100',
'v_200',
'v_300',
'v_400',
'v_500',
'v_600',
'v_700',
'v_800',
'v_900',
];
for (const colorName of COLOR_NAMES) {
if (!value[colorName]) {
logging.error(`${colorName} '${name}' not defined by theme`);
return false;
}
}
return true;
}

const _imageSpec = {
src: {
type: String,
required: true,
},
style: {
type: String,
default: null,
},
alt: {
type: String,
default: null,
},
};

export default {
brandColors: {
type: Object,
default: null,
spec: {
primary: {
type: Object,
required: true,
validator: _colorScaleValidator,
},
secondary: {
type: Object,
required: true,
validator: _colorScaleValidator,
},
},
},
tokenMapping: {
type: Object,
default: null,
},
signIn: {
type: Object,
default: null,
spec: {
background: {
type: String,
default: null,
},
backgroundImgCredit: {
type: String,
default: null,
},
scrimOpacity: {
type: Number,
default: 0.7,
validator(opacity) {
if (opacity < 0 || opacity > 1) {
logging.error(`Scrim opacity '${opacity}' is not in range [0,1]`);
return false;
}
return true;
},
},
topLogo: {
type: Object,
default: null,
spec: _imageSpec,
},
poweredByStyle: {
type: String,
default: null,
},
title: {
type: String,
default: null,
},
showTitle: {
type: Boolean,
default: true,
},
showKolibriFooterLogo: {
type: Boolean,
default: true,
},
showPoweredBy: {
type: Boolean,
default: true,
},
},
},
sideNav: {
type: Object,
default: null,
spec: {
title: {
type: String,
default: null,
},
topLogo: {
type: Object,
default: null,
spec: _imageSpec,
},
showKolibriFooterLogo: {
type: Boolean,
default: true,
},
showPoweredBy: {
type: Boolean,
default: true,
},
brandedFooter: {
type: Object,
default: null,
spec: {
logo: {
type: Object,
default: null,
spec: _imageSpec,
},
paragraphArray: {
type: Array,
default: [],
},
},
},
},
},
};
Loading