492 lines
18 KiB
TypeScript
492 lines
18 KiB
TypeScript
/*
|
|
* 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> | 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> | 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<string> | 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<string>): 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<string> | 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<ruleParts.length; i++) {
|
|
if (!matchPart(vendorParts[i], ruleParts[i]))
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export function matchPart(rule: string, schema: string): boolean {
|
|
// parts should be the string nested between slashes in the URI: /example/
|
|
return (rule && schema && rule === '*' || rule === schema);
|
|
}
|
|
|
|
export function matchSchemaAgainstRuleSet(ruleSet: RuleSet, schema: string) : boolean {
|
|
let rejectCount = 0;
|
|
let acceptCount = 0;
|
|
let acceptRules = get(ruleSet, 'accept');
|
|
if (Array.isArray(acceptRules)) {
|
|
if ((ruleSet.accept as Array<string>).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<string>).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<SelfDescribingJson> | undefined {
|
|
let contextGeneratorResult : SelfDescribingJson | Array<SelfDescribingJson> | 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<any> {
|
|
if (Array.isArray(input)) {
|
|
return input;
|
|
}
|
|
return Array.of(input);
|
|
}
|
|
|
|
export function generatePrimitives(contextPrimitives: Array<ContextPrimitive> | ContextPrimitive,
|
|
event: SelfDescribingJson,
|
|
eventType: string,
|
|
eventSchema: string) : Array<SelfDescribingJson> {
|
|
let normalizedInputs : Array<ContextPrimitive> = 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<SelfDescribingJson> | 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<SelfDescribingJson> {
|
|
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> | ConditionalContextProvider,
|
|
event: SelfDescribingJson,
|
|
eventType: string,
|
|
eventSchema: string) : Array<SelfDescribingJson> {
|
|
let normalizedInput : Array<ConditionalContextProvider> = 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<ContextPrimitive> = [];
|
|
let conditionalProviders : Array<ConditionalContextProvider> = [];
|
|
|
|
function assembleAllContexts(event: SelfDescribingJson) : Array<SelfDescribingJson> {
|
|
let eventSchema = getUsefulSchema(event);
|
|
let eventType = getEventType(event);
|
|
let contexts : Array<SelfDescribingJson> = [];
|
|
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<any>) {
|
|
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<any>) {
|
|
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<SelfDescribingJson> {
|
|
const builtEvent = event.build();
|
|
if (isEventJson(builtEvent)) {
|
|
const decodedEvent = getDecodedEvent(builtEvent as SelfDescribingJson);
|
|
return assembleAllContexts(decodedEvent);
|
|
} else {
|
|
return [];
|
|
}
|
|
}
|
|
};
|
|
}
|