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

Apply Validation Logic to gTag #1693

Merged
merged 5 commits into from
Aug 2, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -18,55 +18,72 @@ const RESERVED_USER_PROPERTY_NAMES = [

// formatCheckLib provides additional validations for payload not included in
// the schema validations. All checks are consistent with Firebase documentation.
export const formatCheckLib = (payload, firebaseAppId, api_secret) => {
export const formatCheckLib = (payload, instanceId, api_secret, useFirebase) => {
let errors: ValidationMessage[] = []

const appInstanceIdErrors = isValidAppInstanceId(payload)
const appOrClientErrors = isValidAppOrClientId(payload, useFirebase)
const eventNameErrors = isValidEventName(payload)
const userPropertyNameErrors = isValidUserPropertyName(payload)
const currencyErrors = isValidCurrencyType(payload)
const emptyItemsErrors = isItemsEmpty(payload)
const itemsRequiredKeyErrors = itemsHaveRequiredKey(payload)
const firebaseAppIdErrors = isfirebaseAppIdValid(firebaseAppId)
const instanceIdErrors = isInstanceIdValid(instanceId, useFirebase)
const apiSecretErrors = isApiSecretNotNull(api_secret)
const sizeErrors = isTooBig(payload)

return [
...errors,
...appInstanceIdErrors,
...appOrClientErrors,
...eventNameErrors,
...userPropertyNameErrors,
...currencyErrors,
...emptyItemsErrors,
...itemsRequiredKeyErrors,
...firebaseAppIdErrors,
...instanceIdErrors,
...apiSecretErrors,
...sizeErrors,
]
}

const isValidAppInstanceId = (payload) => {
const isValidAppOrClientId = (payload, useFirebase) => {
let errors: ValidationMessage[] = []
const appInstanceId = payload.app_instance_id
const clientId = payload.client_id

if (appInstanceId) {
if (appInstanceId?.length !== 32) {
if (useFirebase) {
if (appInstanceId) {
if (appInstanceId?.length !== 32) {
errors.push({
description: `Measurement app_instance_id is expected to be a 32 digit hexadecimal number but was [${appInstanceId.length}] digits.`,
validationCode: "value_invalid",
fieldPath: "app_instance_id"
})
}

if (!appInstanceId.match(/^[A-Fa-f0-9]+$/)) {
let nonChars = appInstanceId.split('').filter((letter: string)=> {
return (!/[0-9A-Fa-f]/.test(letter))
})

errors.push({
description: `Measurement app_instance_id contains non hexadecimal character [${nonChars[0]}].`,
validationCode: "value_invalid",
fieldPath: "app_instance_id"
})
}
} else {
errors.push({
description: `Measurement app_instance_id is expected to be a 32 digit hexadecimal number but was [${appInstanceId.length}] digits.`,
description: "Measurement requires an app_instance_id.",
validationCode: "value_invalid",
fieldPath: "app_instance_id"
})
}

if (!appInstanceId.match(/^[A-Fa-f0-9]+$/)) {
let nonChars = appInstanceId.split('').filter((letter: string)=> {
return (!/[0-9A-Fa-f]/.test(letter))
})

} else {
if (!clientId) {
errors.push({
description: `Measurement app_instance_id contains non hexadecimal character [${nonChars[0]}].`,
description: "Measurement requires a client_id.",
validationCode: "value_invalid",
fieldPath: "app_instance_id"
fieldPath: "client_id"
})
}
}
Expand Down Expand Up @@ -182,15 +199,27 @@ const requiredKeysEmpty = (itemsObj) => {
return !(itemsObj.item_id || itemsObj.item_name)
}

const isfirebaseAppIdValid = (firebaseAppId) => {
const isInstanceIdValid = (instanceId, useFirebase) => {
let errors: ValidationMessage[] = []
const firebaseAppId = instanceId?.firebase_app_id
const measurementId = instanceId?.measurement_id

if (firebaseAppId && !firebaseAppId.match(/[0-9]:[0-9]+:[a-zA-Z]+:[a-zA-Z0-9]+$/)) {
errors.push({
description: `${firebaseAppId} does not follow firebase_app_id pattern of X:XX:XX:XX at path`,
validationCode: "value_invalid",
fieldPath: "firebase_app_id"
})
if (useFirebase) {
if (firebaseAppId && !firebaseAppId.match(/[0-9]:[0-9]+:[a-zA-Z]+:[a-zA-Z0-9]+$/)) {
errors.push({
description: `${firebaseAppId} does not follow firebase_app_id pattern of X:XX:XX:XX at path`,
validationCode: "value_invalid",
fieldPath: "firebase_app_id"
})
}
} else {
if (!measurementId) {
errors.push({
description: "Unable to find non-empty parameter [measurement_id] value in request.",
validationCode: "value_invalid",
fieldPath: "measurement_id"
})
}
}

return errors
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@ const API_DOC_EVENT_URL = 'https://developers.google.com/analytics/devguides/col
const API_DOC_USER_PROPERTIES = 'https://developers.google.com/analytics/devguides/collection/protocol/ga4/user-properties?hl=en&client_type=firebase'
const API_DOC_SENDING_EVENTS_URL = 'https://developers.google.com/analytics/devguides/collection/protocol/ga4/sending-events?hl=en&client_type=firebase'
const API_DOC_JSON_POST_BODY = 'https://developers.google.com/analytics/devguides/collection/protocol/ga4/reference?hl=en&client_type=firebase#payload_post_body'
const API_DOC_GTAG = 'https://developers.google.com/analytics/devguides/collection/protocol/ga4/sending-events?client_type=gtag#required_parameters'

const BASE_PAYLOAD_ATTRIBUTES = ['app_instance_id', 'api_secret', 'firebase_app_id', 'user_id', 'timestamp_micros', 'user_properties', 'non_personalized_ads']

// formats error messages for clarity; add documentation to each error
export const formatErrorMessages = (errors, payload) => {
export const formatErrorMessages = (errors, payload, useFirebase) => {
const formattedErrors = errors.map(error => {
const { description, fieldPath } = error

Expand All @@ -28,6 +29,7 @@ export const formatErrorMessages = (errors, payload) => {
error['description'] = description.slice(0, end_index) + ALPHA_NUMERIC_OVERRIDE

return error

} else if (BASE_PAYLOAD_ATTRIBUTES.includes(fieldPath?.slice(2))) {
error['fieldPath'] = fieldPath.slice(2)

Expand All @@ -39,20 +41,22 @@ export const formatErrorMessages = (errors, payload) => {
})

const documentedErrors = formattedErrors.map(error => {
error['documentation'] = addDocumentation(error, payload)
error['documentation'] = addDocumentation(error, payload, useFirebase)
return error
})

return documentedErrors
}

const addDocumentation = (error, payload) => {
const addDocumentation = (error, payload, useFirebase) => {
const { fieldPath, validationCode } = error

if (validationCode === 'max-length-error' || validationCode === 'max-properties-error' || validationCode === 'max-body-size') {
return API_DOC_LIMITATIONS_URL
} else if (fieldPath?.startsWith('#/events/')) {
return API_DOC_EVENT_URL + payload?.events[0]?.name
} else if (!useFirebase && (fieldPath === 'client_id' || fieldPath === 'measurement_id')) {
return API_DOC_GTAG
} else if (BASE_PAYLOAD_ATTRIBUTES.includes(fieldPath)) {
return API_DOC_BASE_PAYLOAD_URL + fieldPath
} else if (fieldPath === '#/user_properties') {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import "jest"
import { invalid } from "moment"

Check warning on line 2 in src/components/ga4/EventBuilder/ValidateEvent/schemas/baseContent.spec.ts

View workflow job for this annotation

GitHub Actions / build (12.x)

'invalid' is defined but never used

Check warning on line 2 in src/components/ga4/EventBuilder/ValidateEvent/schemas/baseContent.spec.ts

View workflow job for this annotation

GitHub Actions / build (14.x)

'invalid' is defined but never used

Check warning on line 2 in src/components/ga4/EventBuilder/ValidateEvent/schemas/baseContent.spec.ts

View workflow job for this annotation

GitHub Actions / build (15.x)

'invalid' is defined but never used
import { ValidationStatus } from "../../types"

Check warning on line 3 in src/components/ga4/EventBuilder/ValidateEvent/schemas/baseContent.spec.ts

View workflow job for this annotation

GitHub Actions / build (12.x)

'ValidationStatus' is defined but never used

Check warning on line 3 in src/components/ga4/EventBuilder/ValidateEvent/schemas/baseContent.spec.ts

View workflow job for this annotation

GitHub Actions / build (14.x)

'ValidationStatus' is defined but never used

Check warning on line 3 in src/components/ga4/EventBuilder/ValidateEvent/schemas/baseContent.spec.ts

View workflow job for this annotation

GitHub Actions / build (15.x)

'ValidationStatus' is defined but never used
import { Validator } from "../validator"
import { baseContentSchema } from "./baseContent"

Expand All @@ -17,45 +19,129 @@
expect(validator.isValid(validInput)).toEqual(true)
})

test("is not valid when an app_instance_id has dashes", () => {
test("is not valid with an additional property", () => {
const invalidInput = {
'app_instance_id': '0239500a-23af-4ab0-a79c-58c4042ea175',
'events': []
'app_instance_id': 'bc17b822c8c84a7e84ac7e010cc2f740',
'events': [{
'name': 'something',
'params': {}
}],
'additionalProperty': 123
}

let validator = new Validator(baseContentSchema)

expect(validator.isValid(invalidInput)).toEqual(false)
})

test("is not valid when an app_instance_id is not 32 chars", () => {
const invalidInput = {
'app_instance_id': 'bc17b822c8c84a7e84ac7e010cc2f74',
'events': []
test("validates specific event names", () => {
const validInput = {
'app_instance_id': 'bc17b822c8c84a7e84ac7e010cc2f740',
'events': [{
'name': 'purchase',
'params': {
'transaction_id': '894982',
'value': 89489,
'currency': 'USD',
'items': [
{
'item_name': 'test'
}
]
}
}],
}

let validator = new Validator(baseContentSchema)

expect(validator.isValid(invalidInput)).toEqual(false)
expect(validator.isValid(validInput)).toEqual(true)
})

test("is not valid with an additional property", () => {
const invalidInput = {
test("validates params don't have reserved suffixes", () => {
const validInput = {
'app_instance_id': 'bc17b822c8c84a7e84ac7e010cc2f740',
'events': [],
'additionalProperty': 123
'events': [{
'name': 'purchase',
'params': {
'transaction_id': '894982',
'value': 89489,
'currency': 'USD',
'ga_test': '123',
'items': [
{
'item_name': 'test'
}
]
}
}],
}

let validator = new Validator(baseContentSchema)

expect(validator.isValid(invalidInput)).toEqual(false)
expect(validator.isValid(validInput)).toEqual(false)
})

test("validates required keys are present for certain events", () => {
const validInput = {
'app_instance_id': 'bc17b822c8c84a7e84ac7e010cc2f740',
'events': [{
'name': 'purchase',
'params': {
'transaction_id': '894982',
'currency': 'USD',
'items': [
{
'item_name': 'test'
}
]
}
}],
}

let validator = new Validator(baseContentSchema)

expect(validator.isValid(validInput)).toEqual(false)
})

test("is not valid without app_instance_id", () => {
const invalidInput = {'events': []}
test("does NOT validate empty item aray for named events, because the error message is too complex and this is validated in formatCheckErrors", () => {
const validInput = {
'app_instance_id': 'bc17b822c8c84a7e84ac7e010cc2f740',
'events': [{
'name': 'purchase',
'params': {
'transaction_id': '894982',
'value': 89489,
'currency': 'USD',
'items': [{}]
}
}],
}

let validator = new Validator(baseContentSchema)

expect(validator.isValid(invalidInput)).toEqual(false)
expect(validator.isValid(validInput)).toEqual(true)
})

test("validates that items don't have reserved name keys", () => {
const validInput = {
'app_instance_id': 'bc17b822c8c84a7e84ac7e010cc2f740',
'events': [{
'name': 'purchase',
'params': {
'transaction_id': '894982',
'value': 89489,
'currency': 'USD',
'items': [
{
'ga_test': 'et'
}
]
}
}],
}

let validator = new Validator(baseContentSchema)

expect(validator.isValid(validInput)).toEqual(false)
})
})
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
// Base JSON Body Content Schema.

// from google3.corp.gtech.ads.infrastructure.mapps_s2s_event_validator.schemas import events
import { userPropertiesSchema } from './userProperties'
import { eventsSchema } from './events'

export const baseContentSchema = {
"type": "object",
"required": ["app_instance_id", "events"],
"required": ["events"],
"additionalProperties": false,
"properties": {
"app_instance_id": {
"type": "string",
"format": "app_instance_id"
},
"client_id": {
"type": "string",
},
"user_id": {
"type": "string"
},
Expand All @@ -25,4 +27,4 @@ export const baseContentSchema = {
},
"events": eventsSchema,
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { buildEvents } from "./schemaBuilder"
import { itemsSchema } from "./eventTypes/items"

export const eventSchema = {
"type": "object",
Expand All @@ -12,7 +13,8 @@ export const eventSchema = {
"pattern": "^[A-Za-z][A-Za-z0-9_]*$",
"maxLength": 40
},
"params": {"type": "object"}
"params": {"type": "object"},
"items": itemsSchema
},
"allOf": buildEvents()
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ import { ITEM_FIELDS } from "./fieldDefinitions"

export const itemSchema = {
"type": "object",
"required": [],
"patternProperties": {
".": {
"maxLength": 100
}
},
"propertyNames": {
"maxLength": 40
"maxLength": 40,
"pattern":
"^(?!ga_|google_|firebase_)[A-Za-z][A-Za-z0-9_]*$",
},
"properties": ITEM_FIELDS,
"anyOf": [{
Expand Down
Loading
Loading