/* * JavaScript tracker core for Snowplow: contexts.js * * Copyright (c) 2014-2018 Snowplow Analytics Ltd. All rights reserved. * * This program is licensed to you under the Apache License Version 2.0, * and you may not use this file except in compliance with the Apache License Version 2.0. * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. * * Unless required by applicable law or agreed to in writing, * software distributed under the Apache License Version 2.0 is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. */ import { PayloadData, isNonEmptyJson } from "./payload"; import { SelfDescribingJson } from "./core"; import { base64urldecode } from "./base64"; import isEqual = require('lodash/isEqual'); import has = require('lodash/has'); import get = require('lodash/get'); import isPlainObject = require('lodash/isPlainObject'); import every = require('lodash/every'); import compact = require('lodash/compact'); import map = require('lodash/map'); /** * Datatypes (some algebraic) for representing context types */ /** * A context generator is a callback that returns a self-describing JSON * @param {Object} args - Object that contains: event, eventType, eventSchema * @return {SelfDescribingJson} A self-describing JSON */ export type ContextGenerator = (args?: Object) => SelfDescribingJson; /** * A context primitive is either a self-describing JSON or a context generator */ export type ContextPrimitive = SelfDescribingJson | ContextGenerator; /** * A context filter is a user-supplied callback that is evaluated for each event * to determine if the context associated with the filter should be attached to the event */ export type ContextFilter = (args?: Object) => boolean; /** * A filter provider is an array that has two parts: a context filter and context primitives * If the context filter evaluates to true, the tracker will attach the context primitive(s) */ export type FilterProvider = [ContextFilter, Array | ContextPrimitive]; /** * A ruleset has accept or reject properties that contain rules for matching Iglu schema URIs */ export interface RuleSet { accept?: string[] | string; reject?: string[] | string; } /** * A ruleset provider is an array that has two parts: a ruleset and context primitives * If the ruleset allows the current event schema URI, the tracker will attach the context primitive(s) */ export type RuleSetProvider = [RuleSet, Array | ContextPrimitive]; /** * Conditional context providers are two element arrays used to decide when to attach contexts, where: * - the first element is some conditional criterion * - the second element is any number of context primitives */ export type ConditionalContextProvider = FilterProvider | RuleSetProvider; export function getSchemaParts(input: string): Array | undefined { let re = new RegExp('^iglu:([a-zA-Z0-9-_\.]+)\/([a-zA-Z0-9-_]+)\/jsonschema\/([1-9][0-9]*)\-(0|[1-9][0-9]*)\-(0|[1-9][0-9]*)$'); let matches = re.exec(input); if (matches !== null) return matches.slice(1, 6); return undefined; } export function validateVendorParts(parts: Array): boolean { if (parts[0] === '*' || parts[1] === '*') { return false; // no wildcard in first or second part } if (parts.slice(2).length > 0) { let asterisk = false; for (let part of parts.slice(2)) { if (part === '*') // mark when we've found a wildcard asterisk = true; else if (asterisk) // invalid if alpha parts come after wildcard return false; } return true; } else if (parts.length == 2) return true; return false; } export function validateVendor(input: string): boolean { let parts = input.split('.'); if (parts && parts.length > 1) return validateVendorParts(parts); return false; } export function getRuleParts(input: string): Array | undefined { const re = new RegExp('^iglu:((?:(?:[a-zA-Z0-9-_]+|\\*)\.)+(?:[a-zA-Z0-9-_]+|\\*))\/([a-zA-Z0-9-_.]+|\\*)\/jsonschema\/([1-9][0-9]*|\\*)-(0|[1-9][0-9]*|\\*)-(0|[1-9][0-9]*|\\*)$'); let matches = re.exec(input); if (matches !== null && validateVendor(matches[1])) return matches.slice(1,6); return undefined; } export function isValidRule(input: string): boolean { let ruleParts = getRuleParts(input); if (ruleParts) { let vendor = ruleParts[0]; return ruleParts.length === 5 && validateVendor(vendor); } return false; } export function isStringArray(input: any): boolean { return Array.isArray(input) && input.every((x) => { return typeof x === 'string' }); } export function isValidRuleSetArg(input: any): boolean { if (isStringArray(input)) return input.every((x) => { return isValidRule(x) }); else if (typeof input === 'string') return isValidRule(input); return false; } export function isSelfDescribingJson(input: any) : boolean { if (isNonEmptyJson(input)) if ('schema' in input && 'data' in input) return (typeof(input.schema) === 'string' && typeof(input.data) === 'object'); return false; } export function isEventJson(input: any) : boolean { if (isNonEmptyJson(input) && ('e' in input)) return (typeof(input.e) === 'string'); return false; } export function isRuleSet(input: any) : boolean { let ruleCount = 0; if (isPlainObject(input)) { if (has(input, 'accept')) { if (isValidRuleSetArg(input['accept'])) { ruleCount += 1; } else { return false; } } if (has(input, 'reject')) { if (isValidRuleSetArg(input['reject'])) { ruleCount += 1; } else { return false; } } // if either 'reject' or 'accept' or both exists, // we have a valid ruleset return ruleCount > 0 && ruleCount <= 2; } return false; } export function isContextGenerator(input: any) : boolean { return typeof(input) === 'function' && input.length <= 1; } export function isContextFilter(input: any) : boolean { return typeof(input) === 'function' && input.length <= 1; } export function isContextPrimitive(input: any) : boolean { return (isContextGenerator(input) || isSelfDescribingJson(input)); } export function isFilterProvider(input: any) : boolean { if (Array.isArray(input)) { if (input.length === 2) { if (Array.isArray(input[1])) { return isContextFilter(input[0]) && every(input[1], isContextPrimitive); } return isContextFilter(input[0]) && isContextPrimitive(input[1]); } } return false; } export function isRuleSetProvider(input: any) : boolean { if (Array.isArray(input) && input.length === 2) { if (!isRuleSet(input[0])) return false; if (Array.isArray(input[1])) return every(input[1], isContextPrimitive); return isContextPrimitive(input[1]); } return false; } export function isConditionalContextProvider(input: any) : boolean { return isFilterProvider(input) || isRuleSetProvider(input); } export function matchSchemaAgainstRule(rule: string, schema: string) : boolean { if (!isValidRule(rule)) return false; let ruleParts = getRuleParts(rule); let schemaParts = getSchemaParts(schema); if (ruleParts && schemaParts) { if (!matchVendor(ruleParts[0], schemaParts[0])) return false; for (let i=1; i<5; i++) { if (!matchPart(ruleParts[i], schemaParts[i])) return false; } return true; // if it hasn't failed, it passes } return false; } export function matchVendor(rule: string, vendor: string): boolean { // rule and vendor must have same number of elements let vendorParts = vendor.split('.'); let ruleParts = rule.split('.'); if (vendorParts && ruleParts) { if (vendorParts.length !== ruleParts.length) return false; for (let i=0; i).some((rule) => (matchSchemaAgainstRule(rule, schema)))) { acceptCount++; } } else if (typeof(acceptRules) === 'string') { if (matchSchemaAgainstRule(acceptRules, schema)) { acceptCount++; } } let rejectRules = get(ruleSet, 'reject'); if (Array.isArray(rejectRules)) { if ((ruleSet.reject as Array).some((rule) => (matchSchemaAgainstRule(rule, schema)))) { rejectCount++; } } else if (typeof(rejectRules) === 'string') { if (matchSchemaAgainstRule(rejectRules, schema)) { rejectCount++; } } if (acceptCount > 0 && rejectCount === 0) { return true; } else if (acceptCount === 0 && rejectCount > 0) { return false; } return false; } // Returns the "useful" schema, i.e. what would someone want to use to identify events. // The idea being that you can determine the event type from 'e', so getting the schema from // 'ue_px.schema'/'ue_pr.schema' would be redundant - it'll return the unstructured event schema. // Instead the schema nested inside the unstructured event is more useful! // This doesn't decode ue_px, it works because it's called by code that has already decoded it export function getUsefulSchema(sb: SelfDescribingJson): string { if (typeof get(sb, 'ue_px.data.schema') === 'string') return get(sb, 'ue_px.data.schema'); else if (typeof get(sb, 'ue_pr.data.schema') === 'string') return get(sb, 'ue_pr.data.schema'); else if (typeof get(sb, 'schema') === 'string') return get(sb, 'schema'); return ''; } export function getDecodedEvent(sb: SelfDescribingJson): SelfDescribingJson { let decodedEvent = {...sb}; // spread operator, instantiates new object try { if (has(decodedEvent, 'ue_px')) { decodedEvent['ue_px'] = JSON.parse(base64urldecode(get(decodedEvent, ['ue_px']))); } } catch(e) {} return decodedEvent; } export function getEventType(sb: {}): string { return get(sb, 'e', ''); } export function buildGenerator(generator: ContextGenerator, event: SelfDescribingJson, eventType: string, eventSchema: string) : SelfDescribingJson | Array | undefined { let contextGeneratorResult : SelfDescribingJson | Array | undefined = undefined; try { // try to evaluate context generator let args = { event: event, eventType: eventType, eventSchema: eventSchema }; contextGeneratorResult = generator(args); // determine if the produced result is a valid SDJ if (isSelfDescribingJson(contextGeneratorResult)) { return contextGeneratorResult; } else if (Array.isArray(contextGeneratorResult) && every(contextGeneratorResult, isSelfDescribingJson)) { return contextGeneratorResult; } else { return undefined; } } catch (error) { contextGeneratorResult = undefined; } return contextGeneratorResult; } export function normalizeToArray(input: any) : Array { if (Array.isArray(input)) { return input; } return Array.of(input); } export function generatePrimitives(contextPrimitives: Array | ContextPrimitive, event: SelfDescribingJson, eventType: string, eventSchema: string) : Array { let normalizedInputs : Array = normalizeToArray(contextPrimitives); let partialEvaluate = (primitive) => { let result = evaluatePrimitive(primitive, event, eventType, eventSchema); if (result && result.length !== 0) { return result; } }; let generatedContexts = map(normalizedInputs, partialEvaluate); return [].concat(...compact(generatedContexts)); } export function evaluatePrimitive(contextPrimitive: ContextPrimitive, event: SelfDescribingJson, eventType: string, eventSchema: string) : Array | undefined { if (isSelfDescribingJson(contextPrimitive)) { return [contextPrimitive as SelfDescribingJson]; } else if (isContextGenerator(contextPrimitive)) { let generatorOutput = buildGenerator(contextPrimitive as ContextGenerator, event, eventType, eventSchema); if (isSelfDescribingJson(generatorOutput)) { return [generatorOutput as SelfDescribingJson]; } else if (Array.isArray(generatorOutput)) { return generatorOutput; } } return undefined; } export function evaluateProvider(provider: ConditionalContextProvider, event: SelfDescribingJson, eventType: string, eventSchema: string): Array { if (isFilterProvider(provider)) { let filter : ContextFilter = (provider as FilterProvider)[0]; let filterResult = false; try { let args = { event: event, eventType: eventType, eventSchema: eventSchema }; filterResult = filter(args); } catch(error) { filterResult = false; } if (filterResult === true) { return generatePrimitives((provider as FilterProvider)[1], event, eventType, eventSchema); } } else if (isRuleSetProvider(provider)) { if (matchSchemaAgainstRuleSet((provider as RuleSetProvider)[0], eventSchema)) { return generatePrimitives((provider as RuleSetProvider)[1], event, eventType, eventSchema); } } return []; } export function generateConditionals(providers: Array | ConditionalContextProvider, event: SelfDescribingJson, eventType: string, eventSchema: string) : Array { let normalizedInput : Array = normalizeToArray(providers); let partialEvaluate = (provider) => { let result = evaluateProvider(provider, event, eventType, eventSchema); if (result && result.length !== 0) { return result; } }; let generatedContexts = map(normalizedInput, partialEvaluate); return [].concat(...compact(generatedContexts)); } export function contextModule() { let globalPrimitives : Array = []; let conditionalProviders : Array = []; function assembleAllContexts(event: SelfDescribingJson) : Array { let eventSchema = getUsefulSchema(event); let eventType = getEventType(event); let contexts : Array = []; let generatedPrimitives = generatePrimitives(globalPrimitives, event, eventType, eventSchema); contexts.push(...generatedPrimitives); let generatedConditionals = generateConditionals(conditionalProviders, event, eventType, eventSchema); contexts.push(...generatedConditionals); return contexts; } return { getGlobalPrimitives: function () { return globalPrimitives; }, getConditionalProviders: function () { return conditionalProviders; }, addGlobalContexts: function (contexts: Array) { let acceptedConditionalContexts : ConditionalContextProvider[] = []; let acceptedContextPrimitives : ContextPrimitive[] = []; for (let context of contexts) { if (isConditionalContextProvider(context)) { acceptedConditionalContexts.push(context); } else if (isContextPrimitive(context)) { acceptedContextPrimitives.push(context); } } globalPrimitives = globalPrimitives.concat(acceptedContextPrimitives); conditionalProviders = conditionalProviders.concat(acceptedConditionalContexts); }, clearGlobalContexts: function () { conditionalProviders = []; globalPrimitives = []; }, removeGlobalContexts: function (contexts: Array) { for (let context of contexts) { if (isConditionalContextProvider(context)) { conditionalProviders = conditionalProviders.filter(item => !isEqual(item, context)); } else if (isContextPrimitive(context)) { globalPrimitives = globalPrimitives.filter(item => !isEqual(item, context)); } else { // error message here? } } }, getApplicableContexts: function (event: PayloadData) : Array { const builtEvent = event.build(); if (isEventJson(builtEvent)) { const decodedEvent = getDecodedEvent(builtEvent as SelfDescribingJson); return assembleAllContexts(decodedEvent); } else { return []; } } }; }