From ebf248b5f889771b6cde61eb804cf845e68bb189 Mon Sep 17 00:00:00 2001 From: ikuleshov Date: Tue, 12 Jul 2022 10:03:00 +0200 Subject: [PATCH 1/9] Remove iOS Campaign URL Builder for GA4 (#937) --- src/components/CampaignURLBuilder/index.tsx | 10 +---- src/pages/ga4/campaign-url-builder/ios.tsx | 41 --------------------- 2 files changed, 2 insertions(+), 49 deletions(-) delete mode 100644 src/pages/ga4/campaign-url-builder/ios.tsx diff --git a/src/components/CampaignURLBuilder/index.tsx b/src/components/CampaignURLBuilder/index.tsx index b598b3d04..b5252caea 100644 --- a/src/components/CampaignURLBuilder/index.tsx +++ b/src/components/CampaignURLBuilder/index.tsx @@ -45,8 +45,8 @@ export const CampaignURLBuilder: React.FC = ({ return 0 case URLBuilderType.Play: return 1 - case URLBuilderType.Ios: - return 2 + default: + return 0 } }, [type]) @@ -56,8 +56,6 @@ export const CampaignURLBuilder: React.FC = ({ return `/campaign-url-builder/` case 1: return `/campaign-url-builder/play/` - case 2: - return `/campaign-url-builder/ios/` default: throw new Error("No matching idx") } @@ -76,7 +74,6 @@ export const CampaignURLBuilder: React.FC = ({ > - @@ -85,9 +82,6 @@ export const CampaignURLBuilder: React.FC = ({ - - - ) } diff --git a/src/pages/ga4/campaign-url-builder/ios.tsx b/src/pages/ga4/campaign-url-builder/ios.tsx deleted file mode 100644 index 4fb66a1ab..000000000 --- a/src/pages/ga4/campaign-url-builder/ios.tsx +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright 2020 Google Inc. All rights reserved. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -import * as React from "react" - -import Layout from "@/components/Layout" -import CampaignURLBuilder, { - URLBuilderType, -} from "@/components/CampaignURLBuilder" -import { GAVersion } from "@/constants" -import { IS_SSR } from "@/hooks" - -// It's truly baffling to me, but if I try to server-side render this page, -// like all of the css gets broken. -export default ({ location: { pathname } }) => { - return ( - - {IS_SSR ? null : ( - - )} - - ) -} From 202c46d62c737a7db275263c527a51f929bcee1c Mon Sep 17 00:00:00 2001 From: ikuleshov Date: Fri, 10 Feb 2023 09:28:44 -1000 Subject: [PATCH 2/9] Remove an option to include access token to the URL generated by Query (#1249) Explorer --- src/components/QueryExplorer/Report.tsx | 16 +--------------- src/components/QueryExplorer/index.tsx | 2 -- 2 files changed, 1 insertion(+), 17 deletions(-) diff --git a/src/components/QueryExplorer/Report.tsx b/src/components/QueryExplorer/Report.tsx index 91a1b78c8..3b115f36b 100644 --- a/src/components/QueryExplorer/Report.tsx +++ b/src/components/QueryExplorer/Report.tsx @@ -30,8 +30,6 @@ import Spinner from "../../components/Spinner" import { CopyIconButton } from "../../components/CopyButton" import { QueryResponse, APIStatus } from "./useDataAPIRequest" import TSVDownload from "./TSVDownload" -import LabeledCheckbox from "../LabeledCheckbox" -import { useState } from "react" const useStyles = makeStyles(theme => ({ paper: {}, @@ -93,17 +91,14 @@ interface ReportProps { queryResponse: QueryResponse columns: gapi.client.analytics.Column[] | undefined permalink: string | undefined - accessToken: string | undefined } const Report: React.FC = ({ queryResponse, columns, permalink, - accessToken, }) => { const classes = useStyles() - const [includeAccessToken, setIncludeAccessToken] = useState(false) const requestURL = React.useMemo(() => { if ( @@ -115,14 +110,11 @@ const Report: React.FC = ({ const [base, queryParamString] = queryResponse.response.selfLink!.split("?") const existingQueryParams = new URLSearchParams(queryParamString) const nuQueryParams = new URLSearchParams() - if (includeAccessToken && accessToken !== undefined) { - nuQueryParams.append("access_token", accessToken) - } existingQueryParams.forEach((value, key) => { nuQueryParams.append(key, value) }) return `${base}?${nuQueryParams.toString()}` - }, [queryResponse, includeAccessToken, accessToken]) + }, [queryResponse]) if (queryResponse === undefined) { return null @@ -181,12 +173,6 @@ const Report: React.FC = ({ ), }} /> - - include access token -
Link to this report
diff --git a/src/components/QueryExplorer/index.tsx b/src/components/QueryExplorer/index.tsx index dd0207fa3..ac031fc69 100644 --- a/src/components/QueryExplorer/index.tsx +++ b/src/components/QueryExplorer/index.tsx @@ -195,7 +195,6 @@ export const QueryExplorer = () => { runQuery, requiredParameters, queryResponse, - accessToken, } = useDataAPIRequest({ viewID, startDate, @@ -407,7 +406,6 @@ export const QueryExplorer = () => { Date: Tue, 18 Apr 2023 17:27:53 -0700 Subject: [PATCH 3/9] Add initial firebase measurement protocol schema validations (#1597) * Add Schemas and validation class; begin tests * Finish event schema spec * Update state with validationMessages with messages from json validator * ValidationMessages added to event builder frontend * Add custom checks on payload attributes * Remove duplicate error messages * Only validate firebase payloads; aadd tests to schema validations * cleanup * Require login for event builder page * Fix formatting warning * Ensure userProperties exists before adding error * Add specs, fix issues, begin formatting errors * Add documentation to each error * Add focus button to most errors * Fix small bugs, fix failing specs * Add additional check for params and items of events * Respond to code review * Fix failing spec; fix focus button on schema returned errors * Removing params and items list checks -- not sure if they will prevent payload submission * Don't validate empty items for events that don't require items * fix warnings 1 * fix warnings 2 --- package.json | 2 + src/components/ga4/EventBuilder/Parameter.tsx | 1 + .../handlers/formatCheckLib.spec.ts | 202 +++++++++++++++++ .../ValidateEvent/handlers/formatCheckLib.ts | 209 ++++++++++++++++++ .../ValidateEvent/handlers/responseUtil.ts | 62 ++++++ .../ga4/EventBuilder/ValidateEvent/index.tsx | 36 ++- .../ValidateEvent/schemas/baseContent.spec.ts | 61 +++++ .../ValidateEvent/schemas/baseContent.ts | 28 +++ .../ValidateEvent/schemas/event.spec.ts | 45 ++++ .../ValidateEvent/schemas/event.ts | 18 ++ .../schemas/eventTypes/custom.spec.ts | 46 ++++ .../schemas/eventTypes/custom.ts | 27 +++ .../schemas/eventTypes/eventBuilder.ts | 24 ++ .../schemas/eventTypes/eventDefinitions.ts | 33 +++ .../schemas/eventTypes/fieldDefinitions.ts | 60 +++++ .../schemas/eventTypes/item.spec.ts | 47 ++++ .../ValidateEvent/schemas/eventTypes/item.ts | 23 ++ .../ValidateEvent/schemas/eventTypes/items.ts | 6 + .../schemas/eventTypes/propertyConstants.ts | 6 + .../schemas/eventTypes/purchase.spec.ts | 92 ++++++++ .../ValidateEvent/schemas/events.spec.ts | 48 ++++ .../ValidateEvent/schemas/events.ts | 11 + .../ValidateEvent/schemas/schemaBuilder.ts | 71 ++++++ .../schemas/userProperties.spec.ts | 81 +++++++ .../ValidateEvent/schemas/userProperties.ts | 28 +++ .../ValidateEvent/useValidateEvent.ts | 59 ++++- .../EventBuilder/ValidateEvent/validator.ts | 29 +++ src/components/ga4/EventBuilder/index.tsx | 10 + src/components/ga4/EventBuilder/types.ts | 1 + yarn.lock | 46 +++- 30 files changed, 1387 insertions(+), 25 deletions(-) create mode 100644 src/components/ga4/EventBuilder/ValidateEvent/handlers/formatCheckLib.spec.ts create mode 100644 src/components/ga4/EventBuilder/ValidateEvent/handlers/formatCheckLib.ts create mode 100644 src/components/ga4/EventBuilder/ValidateEvent/handlers/responseUtil.ts create mode 100644 src/components/ga4/EventBuilder/ValidateEvent/schemas/baseContent.spec.ts create mode 100644 src/components/ga4/EventBuilder/ValidateEvent/schemas/baseContent.ts create mode 100644 src/components/ga4/EventBuilder/ValidateEvent/schemas/event.spec.ts create mode 100644 src/components/ga4/EventBuilder/ValidateEvent/schemas/event.ts create mode 100644 src/components/ga4/EventBuilder/ValidateEvent/schemas/eventTypes/custom.spec.ts create mode 100644 src/components/ga4/EventBuilder/ValidateEvent/schemas/eventTypes/custom.ts create mode 100644 src/components/ga4/EventBuilder/ValidateEvent/schemas/eventTypes/eventBuilder.ts create mode 100644 src/components/ga4/EventBuilder/ValidateEvent/schemas/eventTypes/eventDefinitions.ts create mode 100644 src/components/ga4/EventBuilder/ValidateEvent/schemas/eventTypes/fieldDefinitions.ts create mode 100644 src/components/ga4/EventBuilder/ValidateEvent/schemas/eventTypes/item.spec.ts create mode 100644 src/components/ga4/EventBuilder/ValidateEvent/schemas/eventTypes/item.ts create mode 100644 src/components/ga4/EventBuilder/ValidateEvent/schemas/eventTypes/items.ts create mode 100644 src/components/ga4/EventBuilder/ValidateEvent/schemas/eventTypes/propertyConstants.ts create mode 100644 src/components/ga4/EventBuilder/ValidateEvent/schemas/eventTypes/purchase.spec.ts create mode 100644 src/components/ga4/EventBuilder/ValidateEvent/schemas/events.spec.ts create mode 100644 src/components/ga4/EventBuilder/ValidateEvent/schemas/events.ts create mode 100644 src/components/ga4/EventBuilder/ValidateEvent/schemas/schemaBuilder.ts create mode 100644 src/components/ga4/EventBuilder/ValidateEvent/schemas/userProperties.spec.ts create mode 100644 src/components/ga4/EventBuilder/ValidateEvent/schemas/userProperties.ts create mode 100644 src/components/ga4/EventBuilder/ValidateEvent/validator.ts diff --git a/package.json b/package.json index 0ae426ccf..d0eb5f8ef 100644 --- a/package.json +++ b/package.json @@ -33,8 +33,10 @@ "gatsby-transformer-sharp": "^3.4.0", "immutable": "^4.0.0-rc.12", "js-base64": "^3.6.1", + "json-schema-library": "^7.4.7", "load-script": "^1.0.0", "moment": "^2.29.1", + "object-sizeof": "^2.6.1", "react": "^17.0.2", "react-dom": "^17.0.2", "react-error-boundary": "^3.1.3", diff --git a/src/components/ga4/EventBuilder/Parameter.tsx b/src/components/ga4/EventBuilder/Parameter.tsx index 11b12044b..a7554e5a5 100644 --- a/src/components/ga4/EventBuilder/Parameter.tsx +++ b/src/components/ga4/EventBuilder/Parameter.tsx @@ -44,6 +44,7 @@ const Parameter: React.FC = ({ const inputs = (
{ + describe("returns appInstanceIdErrors", () => { + test("does not return an error when app_instance_id is 32 alpha-numeric chars", () => { + const payload = {app_instance_id: "12345678901234567890123456789012"} + const firebaseAppId = '1:1233455666:android:abcdefgh' + + let errors = formatCheckLib(payload, firebaseAppId) + + expect(errors).toEqual([]) + }) + + test("returns an error when appInstanceId is not 32 chars", () => { + const appInstanceId = "123456789012345678901234567890123" + const payload = {app_instance_id: appInstanceId} + const firebaseAppId = '1:1233455666:android:abcdefgh' + + let errors = formatCheckLib(payload, firebaseAppId) + + expect(errors[0].description).toEqual( + `Measurement app_instance_id is expected to be a 32 digit hexadecimal number but was [${ appInstanceId.length }] digits.` + ) + }) + + test("returns an error when appInstanceId contains non-alphanumeric char", () => { + const appInstanceId = "1234567890123456789012345678901g" + const payload = {app_instance_id: appInstanceId} + const firebaseAppId = '1:1233455666:android:abcdefgh' + + let errors = formatCheckLib(payload, firebaseAppId) + + expect(errors[0].description).toEqual( + `Measurement app_instance_id contains non hexadecimal character [g].`, + ) + }) + }) + + describe("returns invalidEventName errors", () => { + test("does not return an error for a valid event name", () => { + const payload = {events: [{name: 'add_payment_info'}]} + const firebaseAppId = '1:1233455666:android:abcdefgh' + + let errors = formatCheckLib(payload, firebaseAppId) + + expect(errors).toEqual([]) + }) + + test("returns an error when event's name is a reserved name", () => { + const payload = {events: [{name: 'ad_click'}]} + const firebaseAppId = '1:1233455666:android:abcdefgh' + + let errors = formatCheckLib(payload, firebaseAppId) + + expect(errors[0].description).toEqual( + "ad_click is a reserved event name" + ) + }) + }) + + describe("returns invalidUserPropertyName errors", () => { + test("does not return an error for a valid user property name", () => { + const payload = {user_properties: {'test': 'test'}} + const firebaseAppId = '1:1233455666:android:abcdefgh' + + let errors = formatCheckLib(payload, firebaseAppId) + + expect(errors).toEqual([]) + }) + + test("returns an error when event's name is a reserved name", () => { + const payload = {user_properties: {'first_open_time': 'test'}} + const firebaseAppId = '1:1233455666:android:abcdefgh' + + let errors = formatCheckLib(payload, firebaseAppId) + + expect(errors[0].description).toEqual( + "user_property: 'first_open_time' is a reserved user property name" + ) + }) + }) + + describe("returns invalidCurrencyType errors", () => { + test("does not return an error for a valid currency type", () => { + const payload = {events: [{params: {currency: 'USD'}}]} + const firebaseAppId = '1:1233455666:android:abcdefgh' + + let errors = formatCheckLib(payload, firebaseAppId) + + expect(errors).toEqual([]) + }) + + test("returns an error for an invalid currency type", () => { + const payload = {events: [{params: {currency: 'USDD'}}]} + const firebaseAppId = '1:1233455666:android:abcdefgh' + + let errors = formatCheckLib(payload, firebaseAppId) + + expect(errors[0].description).toEqual( + "currency: USDD must be a valid uppercase 3-letter ISO 4217 format" + ) + }) + }) + + describe("validates items array", () => { + test("does not return an error if items array is valid", () => { + const payload = {events: [{params: {items: [{'item_id': 1234}]}}]} + const firebaseAppId = '1:1233455666:android:abcdefgh' + + let errors = formatCheckLib(payload, firebaseAppId) + + expect(errors).toEqual([]) + }) + + test("returns an error when items does not have either item_id or item_name", () => { + const payload = {events: [{params: {items: [{'item_namee': 'test'}]}}]} + const firebaseAppId = '1:1233455666:android:abcdefgh' + + let errors = formatCheckLib(payload, firebaseAppId) + + expect(errors[0].description).toEqual( + "'items' object must contain one of the following keys: 'item_id' or 'item_name'" + ) + }) + + test("validates empty items array when event requires items", () => { + const payload = {events: [{params: {name: 'purchase', items: []}}]} + const firebaseAppId = '1:1233455666:android:abcdefgh' + + let errors = formatCheckLib(payload, firebaseAppId) + + expect(errors[0].description).toEqual( + "'items' should not be empty; One of 'item_id' or 'item_name' is a required key" + ) + }) + + test("does not validate empty items array when event doesn't require items", () => { + const payload = {events: [{params: {name: 'random', items: []}}]} + const firebaseAppId = '1:1233455666:android:abcdefgh' + + let errors = formatCheckLib(payload, firebaseAppId) + + expect(errors).toEqual([]) + }) + + test("does not validate empty items array when event does not have a name", () => { + const payload = {events: [{params: {items: []}}]} + const firebaseAppId = '1:1233455666:android:abcdefgh' + + let errors = formatCheckLib(payload, firebaseAppId) + + expect(errors).toEqual([]) + }) + + test("returns an error when item_id and item_name keys have empty values", () => { + const payload = { + events: [ + { + params: { + items: [ + { + 'item_name': '', + 'item_id': '' + } + ] + } + } + ] + } + const firebaseAppId = '1:1233455666:android:abcdefgh' + + let errors = formatCheckLib(payload, firebaseAppId) + + expect(errors[0].description).toEqual( + "'items' object must contain one of the following keys: 'item_id' or 'item_name'" + ) + }) + }) + + describe("validates firebase_app_id", () => { + test("does not return an error if firebase_app_id is valid", () => { + const payload = {} + const firebaseAppId = '1:1233455666:android:abcdefgh' + + let errors = formatCheckLib(payload, firebaseAppId) + + expect(errors).toEqual([]) + }) + + test("returns an error when firebase_app_id is invalid", () => { + const payload = {} + const firebaseAppId = '1233455666:android:abcdefgh' + + let errors = formatCheckLib(payload, firebaseAppId) + + expect(errors[0].description).toEqual( + `${firebaseAppId} does not follow firebase_app_id pattern of X:XX:XX:XX at path` + ) + }) + }) +}) \ No newline at end of file diff --git a/src/components/ga4/EventBuilder/ValidateEvent/handlers/formatCheckLib.ts b/src/components/ga4/EventBuilder/ValidateEvent/handlers/formatCheckLib.ts new file mode 100644 index 000000000..bf49c6aa3 --- /dev/null +++ b/src/components/ga4/EventBuilder/ValidateEvent/handlers/formatCheckLib.ts @@ -0,0 +1,209 @@ +import { ValidationMessage } from "../../types" +import 'object-sizeof' +import sizeof from "object-sizeof" +import { eventDefinitions } from "../schemas/eventTypes/eventDefinitions" + +const RESERVED_EVENT_NAMES = [ + "ad_activeview", "ad_click", "ad_exposure", "ad_impression", "ad_query", + "adunit_exposure", "app_clear_data", "app_install", "app_update", + "app_remove", "error", "first_open", "first_visit", "in_app_purchase", + "notification_dismiss", "notification_foreground", "notification_open", + "notification_receive", "os_update", "screen_view", "session_start", + "user_engagement" +] +const RESERVED_USER_PROPERTY_NAMES = [ + "first_open_time", "first_visit_time", "last_deep_link_referrer", "user_id", + "first_open_after_install" +] + +// formatCheckLib provides additional validations for payload not included in +// the schema validations. All checks are consistent with Firebase documentation +export const formatCheckLib = (payload, firebaseAppId) => { + let errors: ValidationMessage[] = [] + + const appInstanceIdErrors = isValidAppInstanceId(payload) + 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 sizeErrors = isTooBig(payload) + + return [ + ...errors, + ...appInstanceIdErrors, + ...eventNameErrors, + ...userPropertyNameErrors, + ...currencyErrors, + ...emptyItemsErrors, + ...itemsRequiredKeyErrors, + ...firebaseAppIdErrors, + ...sizeErrors, + ] +} + +const isValidAppInstanceId = (payload) => { + let errors: ValidationMessage[] = [] + const appInstanceId = payload.app_instance_id + + 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" + }) + } + } + + return errors +} + + +const isValidEventName = (payload) => { + let errors: ValidationMessage[] = [] + + payload.events?.forEach(ev => { + if (RESERVED_EVENT_NAMES.includes(ev.name)) { + errors.push({ + description: `${ev.name} is a reserved event name`, + validationCode: "value_invalid", + fieldPath: "#/events/name" + }) + } + }) + + return errors +} + +const isValidUserPropertyName = (payload) => { + let errors: ValidationMessage[] = [] + const userProperties = payload.user_properties + + if (userProperties) { + Object.keys(userProperties).forEach(prop => { + if (RESERVED_USER_PROPERTY_NAMES.includes(prop)) { + errors.push({ + description: `user_property: '${prop}' is a reserved user property name`, + validationCode: "value_invalid", + fieldPath: "user_property" + }) + } + }) + } + + return errors +} + +const isValidCurrencyType = (payload) => { + let errors: ValidationMessage[] = [] + + payload.events?.forEach(ev => { + if (ev.params && ev.params.currency) { + const currency = ev.params.currency + + if (currency.length !== 3 || !currency.match(/[A-Z]{3}/)) { + errors.push({ + description: `currency: ${currency} must be a valid uppercase 3-letter ISO 4217 format`, + validationCode: "value_invalid", + fieldPath: "#/events/0/params/currency" + }) + } + } + }) + + return errors +} + +const isItemsEmpty = (payload) => { + let errors: ValidationMessage[] = [] + + payload?.events?.forEach(ev => { + if (ev?.params?.items && ev?.params?.items?.length < 1 && eventRequiresItems(ev?.params?.name)){ + errors.push({ + description: "'items' should not be empty; One of 'item_id' or 'item_name' is a required key", + validationCode: "minItems", + fieldPath: "#/events/0/params/item_id" + }) + } + }) + + return errors +} + +const eventRequiresItems = (eventName) => { + if (eventDefinitions[eventName]) { + return eventDefinitions[eventName].includes('items') + } + + return false +} + +const itemsHaveRequiredKey = (payload) => { + let errors: ValidationMessage[] = [] + + payload?.events?.forEach(ev => { + if (ev?.params?.items?.length > 0) { + const itemsObj = ev.params.items[0] + + if (requiredKeysDontExist(itemsObj) || requiredKeysEmpty(itemsObj)) { + errors.push({ + description: "'items' object must contain one of the following keys: 'item_id' or 'item_name'", + validationCode: "limitation", + fieldPath: "#/events/0/params/item_id" + }) + } + } + }) + + return errors +} + +const requiredKeysDontExist = (itemsObj) => { + return !(itemsObj.hasOwnProperty('item_id') || itemsObj.hasOwnProperty('item_name')) +} + +const requiredKeysEmpty = (itemsObj) => { + return !(itemsObj.item_id || itemsObj.item_name) +} + +const isfirebaseAppIdValid = (firebaseAppId) => { + let errors: ValidationMessage[] = [] + + if (!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" + }) + } + + return errors +} + +const isTooBig = (payload) => { + let errors: ValidationMessage[] = [] + + if (sizeof(payload) > 130000) { + errors.push({ + description: 'Post body must be smaller than 130kBs', + validationCode: "max-body-size", + fieldPath: "#" + }) + } + + return errors +} diff --git a/src/components/ga4/EventBuilder/ValidateEvent/handlers/responseUtil.ts b/src/components/ga4/EventBuilder/ValidateEvent/handlers/responseUtil.ts new file mode 100644 index 000000000..6900b43ca --- /dev/null +++ b/src/components/ga4/EventBuilder/ValidateEvent/handlers/responseUtil.ts @@ -0,0 +1,62 @@ +const ALPHA_NUMERIC_NAME = "does not match '^(?!ga_|google_|firebase_)[A-Za-z][A-Za-z0-9_]*$'" +const ALPHA_NUMERIC_OVERRIDE = " may only contain alpha-numeric characters and underscores,start with an alphabetic character, and cannot contain google_, ga_, firebase_" +const CUSTOM_PARAMS_NAME = "can have at most [10] custom params." +const ITEM_INVALID_KEY_OVERRIDE = "Item array has invalid key" + +const API_DOC_LIMITATIONS_URL = 'https://developers.google.com/analytics/devguides/collection/protocol/ga4/sending-events?hl=en&client_type=firebase#limitations' +const API_DOC_BASE_PAYLOAD_URL = 'https://developers.google.com/analytics/devguides/collection/protocol/ga4/reference#' +const API_DOC_EVENT_URL = 'https://developers.google.com/analytics/devguides/collection/protocol/ga4/reference/events#' +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 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) => { + const formattedErrors = errors.map(error => { + const { description, fieldPath } = error + + if (description.endsWith(CUSTOM_PARAMS_NAME)) { + error['description'] = ITEM_INVALID_KEY_OVERRIDE + error['validationCode'] = 'value_invalid' + error['fieldPath'] = '#/events/0/params/item_id' + + return error + } else if (description.endsWith(ALPHA_NUMERIC_NAME)) { + let end_index = description.indexOf(ALPHA_NUMERIC_NAME); + 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) + + return error + } + + return error + + }) + + const documentedErrors = formattedErrors.map(error => { + error['documentation'] = addDocumentation(error, payload) + return error + }) + + return documentedErrors +} + +const addDocumentation = (error, payload) => { + 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 (BASE_PAYLOAD_ATTRIBUTES.includes(fieldPath)) { + return API_DOC_BASE_PAYLOAD_URL + fieldPath + } else if (fieldPath === '#/user_properties') { + return API_DOC_USER_PROPERTIES + } + + return API_DOC_SENDING_EVENTS_URL +} \ No newline at end of file diff --git a/src/components/ga4/EventBuilder/ValidateEvent/index.tsx b/src/components/ga4/EventBuilder/ValidateEvent/index.tsx index ad6bf7e2e..c011e89c3 100644 --- a/src/components/ga4/EventBuilder/ValidateEvent/index.tsx +++ b/src/components/ga4/EventBuilder/ValidateEvent/index.tsx @@ -77,24 +77,14 @@ export interface ValidateEventProps { } const focusFor = (message: ValidationMessage) => { + const { fieldPath } = message let id: string | undefined - switch (message.fieldPath) { - case "api_secret": - id = Label.APISecret - break - case "measurement_id": - id = Label.MeasurementID - break - case "firebase_app_id": - id = Label.FirebaseAppID - break - case "app_instance_id": - id = Label.AppInstanceID - break - case "client_id": - id = Label.ClientID - break + let labelValues: string[] = Object.values(Label) + + if (labelValues.includes(fieldPath)) { + id = fieldPath } + if (id) { return ( = ({ {validationMessages !== undefined && (
    {validationMessages.map((message, idx) => ( -
  • - {focusFor(message)} - {message.description} -
  • +
    +
  • + {focusFor(message)} + {message.description} +
    + Documentation +
  • +
    +
    +
    ))}
)} diff --git a/src/components/ga4/EventBuilder/ValidateEvent/schemas/baseContent.spec.ts b/src/components/ga4/EventBuilder/ValidateEvent/schemas/baseContent.spec.ts new file mode 100644 index 000000000..691595399 --- /dev/null +++ b/src/components/ga4/EventBuilder/ValidateEvent/schemas/baseContent.spec.ts @@ -0,0 +1,61 @@ +import "jest" +import { Validator } from "../validator" +import { baseContentSchema } from "./baseContent" + +describe("baseContentSchema", () => { + test("can be used to validate a valid payload", () => { + const validInput = { + 'app_instance_id': 'bc17b822c8c84a7e84ac7e010cc2f74e', + 'events': [{ + 'name': 'something', + 'params': {} + }] + } + + let validator = new Validator(baseContentSchema) + + expect(validator.isValid(validInput)).toEqual(true) + }) + + test("is not valid when an app_instance_id has dashes", () => { + const invalidInput = { + 'app_instance_id': '0239500a-23af-4ab0-a79c-58c4042ea175', + 'events': [] + } + + 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': [] + } + + let validator = new Validator(baseContentSchema) + + expect(validator.isValid(invalidInput)).toEqual(false) + }) + + test("is not valid with an additional property", () => { + const invalidInput = { + 'app_instance_id': 'bc17b822c8c84a7e84ac7e010cc2f740', + 'events': [], + 'additionalProperty': 123 + } + + let validator = new Validator(baseContentSchema) + + expect(validator.isValid(invalidInput)).toEqual(false) + }) + + test("is not valid without app_instance_id", () => { + const invalidInput = {'events': []} + + let validator = new Validator(baseContentSchema) + + expect(validator.isValid(invalidInput)).toEqual(false) + }) +}) \ No newline at end of file diff --git a/src/components/ga4/EventBuilder/ValidateEvent/schemas/baseContent.ts b/src/components/ga4/EventBuilder/ValidateEvent/schemas/baseContent.ts new file mode 100644 index 000000000..6a98b5f29 --- /dev/null +++ b/src/components/ga4/EventBuilder/ValidateEvent/schemas/baseContent.ts @@ -0,0 +1,28 @@ +// 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"], + "additionalProperties": false, + "properties": { + "app_instance_id": { + "type": "string", + "format": "app_instance_id" + }, + "user_id": { + "type": "string" + }, + "timestamp_micros": { + // "type": "number" + }, + "user_properties": userPropertiesSchema, + "non_personalized_ads": { + "type": "boolean" + }, + "events": eventsSchema, + } +} diff --git a/src/components/ga4/EventBuilder/ValidateEvent/schemas/event.spec.ts b/src/components/ga4/EventBuilder/ValidateEvent/schemas/event.spec.ts new file mode 100644 index 000000000..a65ab2a80 --- /dev/null +++ b/src/components/ga4/EventBuilder/ValidateEvent/schemas/event.spec.ts @@ -0,0 +1,45 @@ +import "jest" +import { Validator } from "../validator" +import { eventSchema } from "./event" + +const appendProperties = (obj, num) => { + for (let i=0; i < num; i++) { + let key = 'property_' + i.toString() + obj[key] = true + } + + return obj +} + +describe("eventSchema", () => { + test("can be used to validate a valid payload", () => { + const validInput = {'name': 'someevent', 'params': {}} + + let validator = new Validator(eventSchema) + + expect(validator.isValid(validInput)).toEqual(true) + }) + + test("returns a 'RequiredPropertyError' when payload does not include a required property", () => { + const inValidPayload = {} + + let validator = new Validator(eventSchema) + let error = validator.getErrors(inValidPayload)[0].name + + expect(validator.isValid(inValidPayload)).toEqual(false) + expect(error).toEqual('RequiredPropertyError') + }) + + test("returns a 'MaxProperties' error when payload properties exceed the max properties allowed", () => { + let payload = {'name': 'someevent'} + const inValidPayload = appendProperties(payload, 25) + + let validator = new Validator(eventSchema) + let errors = validator.getErrors(inValidPayload) + let errorNames = errors.map((error) => error.name) + + expect(errorNames).toContain('MaxPropertiesError') + expect(errorNames).toContain('NoAdditionalPropertiesError') + expect(errorNames).toContain('RequiredPropertyError') + }) +}) \ No newline at end of file diff --git a/src/components/ga4/EventBuilder/ValidateEvent/schemas/event.ts b/src/components/ga4/EventBuilder/ValidateEvent/schemas/event.ts new file mode 100644 index 000000000..df35c9e2d --- /dev/null +++ b/src/components/ga4/EventBuilder/ValidateEvent/schemas/event.ts @@ -0,0 +1,18 @@ +import { buildEvents } from "./schemaBuilder" + +export const eventSchema = { + "type": "object", + "maxProperties": 25, + "required": ["name", "params"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "format": "event_name", + "pattern": "^[A-Za-z][A-Za-z0-9_]*$", + "maxLength": 40 + }, + "params": {"type": "object"} + }, + "allOf": buildEvents() +} diff --git a/src/components/ga4/EventBuilder/ValidateEvent/schemas/eventTypes/custom.spec.ts b/src/components/ga4/EventBuilder/ValidateEvent/schemas/eventTypes/custom.spec.ts new file mode 100644 index 000000000..364245b75 --- /dev/null +++ b/src/components/ga4/EventBuilder/ValidateEvent/schemas/eventTypes/custom.spec.ts @@ -0,0 +1,46 @@ +import "jest" +import { Validator } from "../../validator" +import { customSchema } from "./custom" + +describe("customSchema", () => { + test("can be used to validate a valid payload", () => { + const validInput = {'custom_prop1': 'somevalue'} + + let validator = new Validator(customSchema) + + expect(validator.isValid(validInput)).toEqual(true) + }) + + test("can be used to validate a valid payload with valid currency", () => { + const validInput = {'value': 9.99, 'currency': 'USD'} + + let validator = new Validator(customSchema) + + expect(validator.isValid(validInput)).toEqual(true) + }) + + test("ensures the currency values are numbers", () => { + const validInput = {'value': 'hello', 'currency': 'USD'} + + let validator = new Validator(customSchema) + + expect(validator.isValid(validInput)).toEqual(false) + + }) + + test("ensures properties use valid naming conventions", () => { + const validInput = {'_method': true} + + let validator = new Validator(customSchema) + + expect(validator.isValid(validInput)).toEqual(false) + }) + + test("ensures properties dependency", () => { + const validInput = {'value': 99} + + let validator = new Validator(customSchema) + + expect(validator.isValid(validInput)).toEqual(false) + }) +}) \ No newline at end of file diff --git a/src/components/ga4/EventBuilder/ValidateEvent/schemas/eventTypes/custom.ts b/src/components/ga4/EventBuilder/ValidateEvent/schemas/eventTypes/custom.ts new file mode 100644 index 000000000..1a4ced143 --- /dev/null +++ b/src/components/ga4/EventBuilder/ValidateEvent/schemas/eventTypes/custom.ts @@ -0,0 +1,27 @@ +// Custom Schema. + +// Custom events are not known event names. +// If a known field is sent as part of a custom event, same validation rules apply +// ex: if value is provided, value must be number, and valid currency field is +// required + +import { VALUE_CURRENCY_DEPENDENCY } from "./fieldDefinitions" +import { getAllEventProperties } from "./eventBuilder" + + +export const customSchema = { + "type": "object", + "required": [], + "maxProperties": 25, + "patternProperties": { + ".": { + "maxLength": 100 + } + }, + "propertyNames": { + "pattern": "^(?!ga_|google_|firebase_)[A-Za-z][A-Za-z0-9_]*$", + "maxLength": 40 + }, + "properties": getAllEventProperties(), + "dependencies": VALUE_CURRENCY_DEPENDENCY +} diff --git a/src/components/ga4/EventBuilder/ValidateEvent/schemas/eventTypes/eventBuilder.ts b/src/components/ga4/EventBuilder/ValidateEvent/schemas/eventTypes/eventBuilder.ts new file mode 100644 index 000000000..35eff7a9b --- /dev/null +++ b/src/components/ga4/EventBuilder/ValidateEvent/schemas/eventTypes/eventBuilder.ts @@ -0,0 +1,24 @@ +// event builder module for building generic event wrappers. + +import { EVENT_FIELDS, VALUE_CURRENCY_DEPENDENCY } from "./fieldDefinitions" +import { itemsSchema } from "./items" + + +export const getAllEventProperties = () => { + let knownFields = EVENT_FIELDS + knownFields["items"] = itemsSchema + return knownFields +} + +export const getEventSchema = (requiredFields) => { + return { + "type": "object", + "required": requiredFields, + "properties": getAllEventProperties(), + "dependencies": VALUE_CURRENCY_DEPENDENCY, + "propertyNames": { + "pattern": "^(?!ga_|google_|firebase_)[A-Za-z][A-Za-z0-9_]*$", + "maxLength": 40 + } + } +} \ No newline at end of file diff --git a/src/components/ga4/EventBuilder/ValidateEvent/schemas/eventTypes/eventDefinitions.ts b/src/components/ga4/EventBuilder/ValidateEvent/schemas/eventTypes/eventDefinitions.ts new file mode 100644 index 000000000..7a594bf3e --- /dev/null +++ b/src/components/ga4/EventBuilder/ValidateEvent/schemas/eventTypes/eventDefinitions.ts @@ -0,0 +1,33 @@ +// Event and required property mapping + +export const eventDefinitions = { + "add_payment_info": ["value", "items"], + "add_shipping_info": ["value", "items"], + "add_to_cart": ["value", "items"], + "add_to_wishlist": ["value", "items"], + "begin_checkout": ["value", "items"], + "earn_virtual_currency": [], + "generate_lead": ["value"], + "join_group": [], + "level_up": [], + "login": [], + "post_score": ["score"], + "purchase": ["transaction_id", "items", "value"], + "refund": ["transaction_id", "value"], + "remove_from_cart": ["items", "value"], + "search": ["search_term"], + "select_content": [], + "select_item": ["items"], + "select_promotion": [], + "share": [], + "sign_up": [], + "spend_virtual_currency": ["value", "virtual_currency_name"], + "tutorial_begin": [], + "tutorial_complete": [], + "unlock_achievement": ["achievement_id"], + "view_cart": ["items", "value"], + "view_item": ["items", "value"], + "view_item_list": ["items"], + "view_promotion": ["items"], + "view_search_results": [] +} diff --git a/src/components/ga4/EventBuilder/ValidateEvent/schemas/eventTypes/fieldDefinitions.ts b/src/components/ga4/EventBuilder/ValidateEvent/schemas/eventTypes/fieldDefinitions.ts new file mode 100644 index 000000000..020ba515a --- /dev/null +++ b/src/components/ga4/EventBuilder/ValidateEvent/schemas/eventTypes/fieldDefinitions.ts @@ -0,0 +1,60 @@ +// Event and item level property schema mapping. +import { GENERIC_STRING, GENERIC_NUMBER, CURRENCY_TYPE, CURRENCY_VALUE } from "./propertyConstants" + +export const EVENT_FIELDS = { + "currency": CURRENCY_TYPE, + "transaction_id": GENERIC_STRING, + "value": CURRENCY_VALUE, + "affiliation": GENERIC_STRING, + "coupon": GENERIC_STRING, + "shipping": CURRENCY_VALUE, + "shipping_tier": GENERIC_STRING, + "virtual_currency_name": GENERIC_STRING, + "tax": CURRENCY_VALUE, + "payment_type": GENERIC_STRING, + "group_id": GENERIC_STRING, + "level": GENERIC_NUMBER, + "character": GENERIC_STRING, + "method": GENERIC_STRING, + "search_term": GENERIC_STRING, + "content_type": GENERIC_STRING, + "item_id": GENERIC_STRING, + "score": GENERIC_NUMBER, + "item_list_id": GENERIC_STRING, + "item_list_name": GENERIC_STRING, + "creative_name": GENERIC_STRING, + "creative_slot": GENERIC_STRING, + "location_id": GENERIC_STRING, + "promotion_id": GENERIC_STRING, + "promotion_name": GENERIC_STRING, + "item_name": GENERIC_STRING, + "achievement_id": GENERIC_STRING, +} + +export const ITEM_FIELDS = { + "item_id": GENERIC_STRING, + "item_name": GENERIC_STRING, + "affiliation": GENERIC_STRING, + "coupon": GENERIC_STRING, + "currency": CURRENCY_TYPE, + "discount": CURRENCY_VALUE, + "index": GENERIC_NUMBER, + "item_brand": GENERIC_STRING, + "item_category": GENERIC_STRING, + "item_category2": GENERIC_STRING, + "item_category3": GENERIC_STRING, + "item_category4": GENERIC_STRING, + "item_category5": GENERIC_STRING, + "item_list_id": GENERIC_STRING, + "item_list_name": GENERIC_STRING, + "item_variant": GENERIC_STRING, + "location_id": GENERIC_STRING, + "price": CURRENCY_VALUE, + "quantity": GENERIC_NUMBER, + "creative_name": GENERIC_STRING, + "creative_slot": GENERIC_STRING, + "promotion_id": GENERIC_STRING, + "promotion_name": GENERIC_STRING, +} + +export const VALUE_CURRENCY_DEPENDENCY = {"currency": ["value"], "value": ["currency"]} \ No newline at end of file diff --git a/src/components/ga4/EventBuilder/ValidateEvent/schemas/eventTypes/item.spec.ts b/src/components/ga4/EventBuilder/ValidateEvent/schemas/eventTypes/item.spec.ts new file mode 100644 index 000000000..cc98b8fba --- /dev/null +++ b/src/components/ga4/EventBuilder/ValidateEvent/schemas/eventTypes/item.spec.ts @@ -0,0 +1,47 @@ +import "jest" +import { Validator } from "../../validator" +import { itemSchema } from "./item" + +describe("itemSchema", () => { + test("can be used to validate a valid payload", () => { + const validInput = {'item_id': 'item_1234'} + + let validator = new Validator(itemSchema) + + expect(validator.isValid(validInput)).toEqual(true) + + }) + + test("can be used to validate a valid payload", () => { + const validInput = {'item_name': 'item_1234'} + + let validator = new Validator(itemSchema) + + expect(validator.isValid(validInput)).toEqual(true) + }) + + test("can be used to ensure required properties are included", () => { + const invalidInput = {} + + let validator = new Validator(itemSchema) + + expect(validator.isValid(invalidInput)).toEqual(false) + }) + + test("ensures property names are 40 characters or fewer", () => { + const invalidInput = {'itemitemieitemitemieitemitemieitemitemie1': 'item_1234', 'item_id': '123'} + + let validator = new Validator(itemSchema) + console.log(validator.getErrors(invalidInput)) + + expect(validator.isValid(invalidInput)).toEqual(false) + }) + + test("ensures property values are 100 characters or fewer", () => { + const invalidInput = {'item_id': 'item123456item123456item123456item123456item123456item123456item123456item123456item123456item1234567'} + + let validator = new Validator(itemSchema) + + expect(validator.isValid(invalidInput)).toEqual(false) + }) +}) \ No newline at end of file diff --git a/src/components/ga4/EventBuilder/ValidateEvent/schemas/eventTypes/item.ts b/src/components/ga4/EventBuilder/ValidateEvent/schemas/eventTypes/item.ts new file mode 100644 index 000000000..1972f3d7c --- /dev/null +++ b/src/components/ga4/EventBuilder/ValidateEvent/schemas/eventTypes/item.ts @@ -0,0 +1,23 @@ +// Item Schema. +// https://developers.google.com/analytics/devguides/collection/protocol/ga4/reference/events#purchase_item + +import { ITEM_FIELDS } from "./fieldDefinitions" + +export const itemSchema = { + "type": "object", + "required": [], + "patternProperties": { + ".": { + "maxLength": 100 + } + }, + "propertyNames": { + "maxLength": 40 + }, + "properties": ITEM_FIELDS, + "anyOf": [{ + "required": ["item_id"] + }, { + "required": ["item_name"] + }] +} \ No newline at end of file diff --git a/src/components/ga4/EventBuilder/ValidateEvent/schemas/eventTypes/items.ts b/src/components/ga4/EventBuilder/ValidateEvent/schemas/eventTypes/items.ts new file mode 100644 index 000000000..57b8d0463 --- /dev/null +++ b/src/components/ga4/EventBuilder/ValidateEvent/schemas/eventTypes/items.ts @@ -0,0 +1,6 @@ +// Items Schema. +// https://developers.google.com/analytics/devguides/collection/protocol/ga4/reference/events#purchase_item + +import {itemSchema} from "./item" + +export const itemsSchema = {"type": "array", "minItems": 1, "items": itemSchema} diff --git a/src/components/ga4/EventBuilder/ValidateEvent/schemas/eventTypes/propertyConstants.ts b/src/components/ga4/EventBuilder/ValidateEvent/schemas/eventTypes/propertyConstants.ts new file mode 100644 index 000000000..603d6d7da --- /dev/null +++ b/src/components/ga4/EventBuilder/ValidateEvent/schemas/eventTypes/propertyConstants.ts @@ -0,0 +1,6 @@ +// Generic schema definitions. + +export const GENERIC_STRING = {"type": "string"} +export const GENERIC_NUMBER = {"type": "number"} +export const CURRENCY_TYPE = {"type": "string", "format": "currency_type"} +export const CURRENCY_VALUE = {"type": "number"} diff --git a/src/components/ga4/EventBuilder/ValidateEvent/schemas/eventTypes/purchase.spec.ts b/src/components/ga4/EventBuilder/ValidateEvent/schemas/eventTypes/purchase.spec.ts new file mode 100644 index 000000000..9eca6be3d --- /dev/null +++ b/src/components/ga4/EventBuilder/ValidateEvent/schemas/eventTypes/purchase.spec.ts @@ -0,0 +1,92 @@ +import "jest" +import { Validator } from "../../validator" +import { eventDefinitions } from "./eventDefinitions" +import { getEventSchema } from "./eventBuilder" + +describe("eventSchema for purchase", () => { + test("can be used to validate a valid payload", () => { + const validInput = { + 'transaction_id': 'A12345', + 'currency': 'USD', + 'value': 12, + 'items': [{ + 'item_id': '1234' + }] + } + + let validator = new Validator(getEventSchema(eventDefinitions['purchase'])) + + expect(validator.isValid(validInput)).toEqual(true) + }) + + test("can be used to validate a valid payload", () => { + const validInput = { + 'transaction_id': 'A12345', + 'currency': 'USD', + 'value': 12.00, + 'items': [{ + 'item_id': '1234' + }] + } + + let validator = new Validator(getEventSchema(eventDefinitions['purchase'])) + + expect(validator.isValid(validInput)).toEqual(true) + }) + + test("can be used to validate required fields", () => { + const invalidInput = { + 'items': [{ + 'item_id': '1234' + }] + } + + let validator = new Validator(getEventSchema(eventDefinitions['purchase'])) + + expect(validator.isValid(invalidInput)).toEqual(false) + }) + + test("can be used to validate dependent fields", () => { + const invalidInput = { + 'transaction_id': 'A12345', + 'currency': 'USD', + 'items': [{ + 'item_id': '1234' + }] + } + + let validator = new Validator(getEventSchema(eventDefinitions['purchase'])) + + expect(validator.isValid(invalidInput)).toEqual(false) + }) + + test("can be used to validate dependent fields", () => { + const invalidInput = { + 'transaction_id': 'A12345', + 'value': 12.10, + 'items': [{ + 'item_id': '1234' + }] + } + + let validator = new Validator(getEventSchema(eventDefinitions['purchase'])) + + expect(validator.isValid(invalidInput)).toEqual(false) + }) + + test("can be used to validate value type", () => { + const invalidInput = { + 'transaction_id': 'A12345', + 'value': '12.10', + 'items': [{ + 'item_id': '1234' + }] + } + + let validator = new Validator(getEventSchema(eventDefinitions['purchase'])) + + expect(validator.isValid(invalidInput)).toEqual(false) + }) + + // #TODO: validate actual currency values -- find currency validator library +}) \ No newline at end of file diff --git a/src/components/ga4/EventBuilder/ValidateEvent/schemas/events.spec.ts b/src/components/ga4/EventBuilder/ValidateEvent/schemas/events.spec.ts new file mode 100644 index 000000000..26a75e448 --- /dev/null +++ b/src/components/ga4/EventBuilder/ValidateEvent/schemas/events.spec.ts @@ -0,0 +1,48 @@ +import "jest" +import { Validator } from "../validator" +import { eventsSchema } from "./events" + +describe("eventsSchema", () => { + test("can be used to validate a valid payload", () => { + const validInput = [{'name': 'someevent', 'params': {}}] + + let validator = new Validator(eventsSchema) + + expect(validator.isValid(validInput)).toEqual(true) + }) + + test("is invalid for events with more than 25 items", () => { + const invalidInput: Array<{}> = [] + for (let i=0; i<= 25; i++) { + invalidInput.push({ 'name': 'someevent', 'params': {} }) + } + + let validator = new Validator(eventsSchema) + + expect(validator.isValid(invalidInput)).toEqual(false) + }) + + test("is invalid if additional properties are included", () => { + const invalidInput = [{'name': 'someevent', 'params': {}, 'additionalprop': 123}] + + let validator = new Validator(eventsSchema) + + expect(validator.isValid(invalidInput)).toEqual(false) + }) + + test("is invalid if add_payment_info event does not include currency", () => { + const invalidInput = [{'name': 'add_payment_info', 'params': {'value': 8.98, 'items': [{'item_id': 1234}]}}] + + let validator = new Validator(eventsSchema) + + expect(validator.isValid(invalidInput)).toEqual(false) + }) + + test("is invalid if add_payment_info event does not include value", () => { + const invalidInput = [{'name': 'add_payment_info', 'params': {'items': [{'item_id': 1234}], 'currency': 'USD'}}] + + let validator = new Validator(eventsSchema) + + expect(validator.isValid(invalidInput)).toEqual(false) + }) +}) \ No newline at end of file diff --git a/src/components/ga4/EventBuilder/ValidateEvent/schemas/events.ts b/src/components/ga4/EventBuilder/ValidateEvent/schemas/events.ts new file mode 100644 index 000000000..55577b5e8 --- /dev/null +++ b/src/components/ga4/EventBuilder/ValidateEvent/schemas/events.ts @@ -0,0 +1,11 @@ +// Events Schema. + +import { eventSchema } from "./event"; + +export const eventsSchema = { + "type": "array", + "maxItems": 25, + "items": eventSchema, + "minItems": 1 +} + diff --git a/src/components/ga4/EventBuilder/ValidateEvent/schemas/schemaBuilder.ts b/src/components/ga4/EventBuilder/ValidateEvent/schemas/schemaBuilder.ts new file mode 100644 index 000000000..48ac57cfc --- /dev/null +++ b/src/components/ga4/EventBuilder/ValidateEvent/schemas/schemaBuilder.ts @@ -0,0 +1,71 @@ +// """Event Builder Module.""" + +// from google3.corp.gtech.ads.infrastructure.mapps_s2s_event_validator.schemas.event_types import custom +import { eventDefinitions } from "./eventTypes/eventDefinitions" +import { VALUE_CURRENCY_DEPENDENCY } from "./eventTypes/fieldDefinitions" +import { getAllEventProperties } from "./eventTypes/eventBuilder" +import { customSchema } from "./eventTypes/custom" + +type conditionalObject = { + [key:string]: any; +} + +// Build condition list of event to schema mapping. +export const buildEvents = () => { + let allEventTypes: conditionalObject[] = [] +// Add all recommended event schemas + for (let eventName in eventDefinitions) { + const cond = { + "if": { + "properties": { + "name": { + "const": eventName + } + } + }, + "then": { + "properties": { + "params": { + "type": "object", + "required": eventDefinitions[eventName], + "properties": getAllEventProperties(), + "dependencies": VALUE_CURRENCY_DEPENDENCY, + "propertyNames": { + "pattern": + "^(?!ga_|google_|firebase_)[A-Za-z][A-Za-z0-9_]*$", + "maxLength": + 40 + }, + "patternProperties": { + ".": { + "maxLength": 100 + } + } + } + } + } + } + + allEventTypes.push(cond) + } + + let knownEventList = "|" + Object.keys(eventDefinitions) + let nameNotMatchPattern = "^(?!(" + knownEventList + ")$).*$" + let customCond = { + "if": { + "properties": { + "name": { + "pattern": nameNotMatchPattern + } + } + }, + "then": { + "properties": { + "params": customSchema, + } + } + } + allEventTypes.push(customCond) + + return allEventTypes +} \ No newline at end of file diff --git a/src/components/ga4/EventBuilder/ValidateEvent/schemas/userProperties.spec.ts b/src/components/ga4/EventBuilder/ValidateEvent/schemas/userProperties.spec.ts new file mode 100644 index 000000000..dd0899d8b --- /dev/null +++ b/src/components/ga4/EventBuilder/ValidateEvent/schemas/userProperties.spec.ts @@ -0,0 +1,81 @@ +import "jest" +import { Validator } from "../validator" +import { userPropertiesSchema } from "./userProperties" + +describe("userPropertiesSchema", () => { + test("can be used to validate a valid payload", () => { + const validInput = {'custom_user_id': {'value': '123456'}} + + let validator = new Validator(userPropertiesSchema) + + expect(validator.isValid(validInput)).toEqual(true) + }) + + test("is invalid for events with more than 25 items", () => { + let invalidInput = {} + for (let i=0; i<= 25; i++) { + invalidInput['property_' + i] = {'value': '123456'} + } + + let validator = new Validator(userPropertiesSchema) + + expect(validator.isValid(invalidInput)).toEqual(false) + }) + + test("is invalid for values with over 36 chars", () => { + const invalidInput = {'custom_user_id': {'value': '1234567890123456789012345678901234567'}} + + let validator = new Validator(userPropertiesSchema) + + expect(validator.isValid(invalidInput)).toEqual(false) + }) + + test("is invalid when additional properties are added", () => { + const invalidInput = { + 'custom_user_id': { + 'value': '1234', + 'addtlprop': '1234' + } + } + + let validator = new Validator(userPropertiesSchema) + + expect(validator.isValid(invalidInput)).toEqual(false) + }) + + test("is invalid when value prop is not included", () => { + const invalidInput = { + 'custom_user_id': { + 'valuee': '1234' + } + } + + let validator = new Validator(userPropertiesSchema) + + expect(validator.isValid(invalidInput)).toEqual(false) + }) + + test("is invalid when property name is over 24 chars long", () => { + const invalidInput = { + 'propproppropproppropprop1': { + 'value': '1234' + } + } + + let validator = new Validator(userPropertiesSchema) + + expect(validator.isValid(invalidInput)).toEqual(false) + }) + + test("is invalid when property name has invalid characters", () => { + const invalidInput = { + 'prop!': { + 'value': '1234' + } + } + + let validator = new Validator(userPropertiesSchema) + + expect(validator.isValid(invalidInput)).toEqual(false) + }) +}) \ No newline at end of file diff --git a/src/components/ga4/EventBuilder/ValidateEvent/schemas/userProperties.ts b/src/components/ga4/EventBuilder/ValidateEvent/schemas/userProperties.ts new file mode 100644 index 000000000..7101a5279 --- /dev/null +++ b/src/components/ga4/EventBuilder/ValidateEvent/schemas/userProperties.ts @@ -0,0 +1,28 @@ +// User Properties Schema. + +export const userPropertiesSchema = { + "type": "object", + "maxProperties": 25, + "patternProperties": { + ".": { + "type": "object", + "required": ["value"], + "additionalProperties": false, + + "properties": { + "value": { + "maxLength": 36 + }, + "timestamp_micros": { + "type": "number", + "maxLength": 36 + } + } + } + }, + "propertyNames": { + "format": "user_property_name", + "pattern": "^(?!ga_|google_|firebase_)[A-Za-z][A-Za-z0-9_]*$", + "maxLength": 24 + }, +} \ No newline at end of file diff --git a/src/components/ga4/EventBuilder/ValidateEvent/useValidateEvent.ts b/src/components/ga4/EventBuilder/ValidateEvent/useValidateEvent.ts index 44a5f21e3..daa406f1b 100644 --- a/src/components/ga4/EventBuilder/ValidateEvent/useValidateEvent.ts +++ b/src/components/ga4/EventBuilder/ValidateEvent/useValidateEvent.ts @@ -1,5 +1,9 @@ import { useCopy } from "@/hooks" import { Requestable, RequestStatus } from "@/types" +import { Validator } from "./validator" +import { baseContentSchema } from "./schemas/baseContent" +import { formatCheckLib } from "./handlers/formatCheckLib" +import { formatErrorMessages } from "./handlers/responseUtil" import { createContext, useCallback, @@ -120,19 +124,31 @@ const useValidateEvent = (): Requestable< return } setStatus(RequestStatus.InProgress) + setValidationMessages([]) + let validatorErrors = useFirebase ? validatePayloadAttributes(payload) : [] + validateHit(payload, instanceId, api_secret) .then(messages => { setTimeout(() => { - if (messages.length > 0) { - setValidationMessages( - messages.filter(a => + if (messages.length > 0 || validatorErrors.length > 0) { + let apiValidationErrors = messages.filter(a => a.fieldPath === "measurement_id" ? !useFirebase : a.fieldPath === "firebase_app_id" ? useFirebase : true ) - ) + + apiValidationErrors.forEach(err => { + if (!validatorErrors.map(e => e.description).includes(err.description)) { + validatorErrors.push(err) + } + }) + + validatorErrors = formatErrorMessages(validatorErrors, payload) + console.log('validatorErrors', validatorErrors) + + setValidationMessages(validatorErrors) setStatus(RequestStatus.Failed) } else { setStatus(RequestStatus.Successful) @@ -144,6 +160,41 @@ const useValidateEvent = (): Requestable< }) }, [status, payload, api_secret, instanceId, useFirebase]) + const validatePayloadAttributes = (payload) => { + let validator = new Validator(baseContentSchema) + let formatCheckErrors: ValidationMessage[] | [] = formatCheckLib(payload, instanceId?.firebase_app_id) + + if (!validator.isValid(payload) || formatCheckErrors) { + let validatorErrors: ValidationMessage[] = validator.getErrors(payload).map((err) => { + return { + description: err.message, + validationCode: err?.data?.validationError?.code ? err?.data?.validationError?.code : err.code, + fieldPath: defineFieldCode(err) + } + }) + + return [...validatorErrors, ...formatCheckErrors] + } + + return [] + } + + const defineFieldCode = (error) => { + const { data } = error + + if (data?.pointer) { + if (data?.key) { + return data.pointer + '/' + data.key + } else if (data?.missingProperty) { + return data.pointer + '/' + data.missingProperty + } + + return data.pointer + } + + return data.key + } + if (status === RequestStatus.Successful) { return { status, sendToGA, copyPayload, copySharableLink, sent } } else if (status === RequestStatus.NotStarted) { diff --git a/src/components/ga4/EventBuilder/ValidateEvent/validator.ts b/src/components/ga4/EventBuilder/ValidateEvent/validator.ts new file mode 100644 index 000000000..df3036699 --- /dev/null +++ b/src/components/ga4/EventBuilder/ValidateEvent/validator.ts @@ -0,0 +1,29 @@ +import { Draft07 } from "json-schema-library" + +type schemaObject = { + [key:string]: any; +} + +// Create a class that instantiates the Draft07 JSON schema validation. +// Methods .isValid and .getErrors use Draft07 schema to validate provided +// payload and return errors based on payload schema + +export class Validator { + schema: schemaObject + validator: any + + constructor( + schema: schemaObject + ) { + this.schema = schema + this.validator = new Draft07(schema) + } + + public isValid = (payload) => { + return this.validator.isValid(payload) + } + + public getErrors = (payload) => { + return this.validator.validate(payload) + } +} \ No newline at end of file diff --git a/src/components/ga4/EventBuilder/index.tsx b/src/components/ga4/EventBuilder/index.tsx index 2a0125d15..cbe3aac68 100644 --- a/src/components/ga4/EventBuilder/index.tsx +++ b/src/components/ga4/EventBuilder/index.tsx @@ -56,6 +56,16 @@ export enum Label { NonPersonalizedAds = "non_personalized_ads", Payload = "payload", + + // event params + Coupon = '#/events/0/params/coupon', + Currency = '#/events/0/params/currency', + Value = '#/events/0/params/value', + ItemId = '#/events/0/params/item_id', + TransactionId = '#/events/0/params/transaction_id', + Affiliation = '#/events/0/params/affiliation', + Shipping = '#/events/0/params/shipping', + Tax = '#/events/0/params/tax', } const ga4MeasurementProtocol = ( diff --git a/src/components/ga4/EventBuilder/types.ts b/src/components/ga4/EventBuilder/types.ts index 2c9b8b8f1..67190dda4 100644 --- a/src/components/ga4/EventBuilder/types.ts +++ b/src/components/ga4/EventBuilder/types.ts @@ -113,6 +113,7 @@ export interface ValidationMessage { fieldPath: string description: string validationCode: string + documentation?: string } export interface WebIds { diff --git a/yarn.lock b/yarn.lock index 77c3d2f6e..cacb2d485 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2387,6 +2387,19 @@ prop-types "^15.6.1" react-lifecycles-compat "^3.0.4" +"@sagold/json-pointer@^5.0.0": + version "5.0.1" + resolved "https://registry.yarnpkg.com/@sagold/json-pointer/-/json-pointer-5.0.1.tgz#94319addbd798c2da571bd6eab86fb0b3265349d" + integrity sha512-cKY4N+3YC7CHUW28lOgqH6020EMqFS5nW9cT+/MBdUNXWEmLWeTsOtiaHxXuixL5jCJNv4AFqtGi4dQIYGG8XQ== + +"@sagold/json-query@^6.0.0": + version "6.0.0" + resolved "https://registry.yarnpkg.com/@sagold/json-query/-/json-query-6.0.0.tgz#a250a98d19cc20d8c06e8e9eeaf9d1de833f1ed4" + integrity sha512-fk9BimvNrzlhXiy+dvlwyA97W2N6GmPWCJo/2kwKEtU9oc93cVKamca9NcnjKx1hhjECjPfu30NQ8Tg2JGv/pA== + dependencies: + "@sagold/json-pointer" "^5.0.0" + ebnf "^1.9.0" + "@sideway/address@^4.1.0": version "4.1.2" resolved "https://registry.yarnpkg.com/@sideway/address/-/address-4.1.2.tgz#811b84333a335739d3969cfc434736268170cad1" @@ -4434,6 +4447,14 @@ buffer@^5.2.0, buffer@^5.2.1, buffer@^5.5.0, buffer@^5.7.0: base64-js "^1.3.1" ieee754 "^1.1.13" +buffer@^6.0.3: + version "6.0.3" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6" + integrity sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA== + dependencies: + base64-js "^1.3.1" + ieee754 "^1.2.1" + buffers@~0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/buffers/-/buffers-0.1.1.tgz#b24579c3bed4d6d396aeee6d9a8ae7f5482ab7bb" @@ -6338,6 +6359,11 @@ duplexify@^4.0.0: readable-stream "^3.1.1" stream-shift "^1.0.0" +ebnf@^1.9.0: + version "1.9.0" + resolved "https://registry.yarnpkg.com/ebnf/-/ebnf-1.9.0.tgz#9c2dd6052f3ed43a69c1f0b07b15bd03cefda764" + integrity sha512-LKK899+j758AgPq00ms+y90mo+2P86fMKUWD28sH0zLKUj7aL6iIH2wy4jejAMM9I2BawJ+2kp6C3mMXj+Ii5g== + ecc-jsbn@~0.1.1: version "0.1.2" resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9" @@ -7201,7 +7227,7 @@ fast-copy@^2.1.0: resolved "https://registry.yarnpkg.com/fast-copy/-/fast-copy-2.1.1.tgz#f5cbcf2df64215e59b8e43f0b2caabc19848083a" integrity sha512-Qod3DdRgFZ8GUIM6ygeoZYpQ0QLW9cf/FS9KhhjlYggcSZXWAemAw8BOCO5LuYCrR3Uj3qXDVTUzOUwG8C7beQ== -fast-deep-equal@^3.1.1: +fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== @@ -10824,6 +10850,17 @@ json-ptr@^1.3.1: dependencies: tslib "^2.0.0" +json-schema-library@^7.4.7: + version "7.4.7" + resolved "https://registry.yarnpkg.com/json-schema-library/-/json-schema-library-7.4.7.tgz#764f03e6759053d6d9d18fad71c3d4e3c2b32449" + integrity sha512-m7MzIVbwDH6L0RzrPjQAZfpEffaa0wUyyJ049xK5YWX9yXQfOh89ER4WqiFGA+6OPK8dlCPGLmFb3q12FkeriQ== + dependencies: + "@sagold/json-pointer" "^5.0.0" + "@sagold/json-query" "^6.0.0" + deepmerge "^4.2.2" + fast-deep-equal "^3.1.3" + valid-url "^1.0.9" + json-schema-traverse@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" @@ -12616,6 +12653,13 @@ object-path@0.11.5: resolved "https://registry.yarnpkg.com/object-path/-/object-path-0.11.5.tgz#d4e3cf19601a5140a55a16ad712019a9c50b577a" integrity sha512-jgSbThcoR/s+XumvGMTMf81QVBmah+/Q7K7YduKeKVWL7N111unR2d6pZZarSk6kY/caeNxUDyxOvMWyzoU2eg== +object-sizeof@^2.6.1: + version "2.6.1" + resolved "https://registry.yarnpkg.com/object-sizeof/-/object-sizeof-2.6.1.tgz#1e2b6a01d182c268dbb07ee3403f539de45f63d3" + integrity sha512-a7VJ1Zx7ZuHceKwjgfsSqzV/X0PVGvpZz7ho3Dn4Cs0LLcR5e5WuV+gsbizmplD8s0nAXMJmckKB2rkSiPm/Gg== + dependencies: + buffer "^6.0.3" + object-visit@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/object-visit/-/object-visit-1.0.1.tgz#f79c4493af0c5377b59fe39d395e41042dd045bb" From c8da06f45de568e44fef77a99a8f32af378f16c5 Mon Sep 17 00:00:00 2001 From: Mary Beliveau <39474777+marycodes2@users.noreply.github.com> Date: Wed, 28 Jun 2023 23:35:24 -0700 Subject: [PATCH 4/9] Add TextBox to Event Builder (#1657) * Add payload textbox to Event Builder * Create payload textbox, begin payload formatting logic * Added format Payload button; able to format payload * Add format Payload ability before validate event * Payload is now being validated, bug still bugs * Able to validate payload from textbox on first entry * Add payload formatting errors * Fix formatting issues * Remove console logs * Cleanup * Fix typescript errors * Improve design, fix linter errors * Only validate payload for textbox entries * Validate api_secret exists * Fix linter errors * Update naming * Change payload URL * Update styling --- src/components/Buttons.tsx | 7 +- src/components/CampaignURLBuilder/index.tsx | 1 - src/components/TextBox.tsx | 61 ++ .../handlers/formatCheckLib.spec.ts | 75 ++- .../ValidateEvent/handlers/formatCheckLib.ts | 22 +- .../ValidateEvent/handlers/responseUtil.ts | 14 +- .../ga4/EventBuilder/ValidateEvent/index.tsx | 118 +++- .../EventBuilder/ValidateEvent/usePayload.ts | 19 +- .../ValidateEvent/useValidateEvent.ts | 116 ++-- src/components/ga4/EventBuilder/index.tsx | 616 +++++++++++------- src/components/ga4/EventBuilder/types.ts | 4 + src/components/ga4/EventBuilder/useInputs.ts | 27 + src/constants.ts | 3 + 13 files changed, 746 insertions(+), 337 deletions(-) create mode 100644 src/components/TextBox.tsx diff --git a/src/components/Buttons.tsx b/src/components/Buttons.tsx index 9035359a8..b6252dab9 100644 --- a/src/components/Buttons.tsx +++ b/src/components/Buttons.tsx @@ -65,12 +65,13 @@ export const PlainButton: React.FC = ({ ...props }) => { } export const TooltipIconButton: React.FC<{ - tooltip: string + tooltip: any size?: "small" | "medium" className?: string disabled?: boolean + placement?: "bottom" | "left" | "right" | "top" | "bottom-end" | "bottom-start" | "left-end" | "left-start" | "right-end" | "right-start" | "top-end" | "top-start" | undefined onClick?: () => void -}> = ({ tooltip, children, onClick, className, disabled, size = "small" }) => { +}> = ({ tooltip, children, onClick, className, disabled, size = "small", placement='bottom'}) => { if (disabled) { return ( + {children} diff --git a/src/components/CampaignURLBuilder/index.tsx b/src/components/CampaignURLBuilder/index.tsx index b5252caea..34da89c22 100644 --- a/src/components/CampaignURLBuilder/index.tsx +++ b/src/components/CampaignURLBuilder/index.tsx @@ -22,7 +22,6 @@ import { GAVersion } from "@/constants" import TabPanel from "@/components/TabPanel" import WebURLBuilder from "./Web" import PlayURLBuilder from "./Play" -import IOSURLBuilder from "./IOS" export enum URLBuilderType { Web = "web", diff --git a/src/components/TextBox.tsx b/src/components/TextBox.tsx new file mode 100644 index 000000000..b85f2281c --- /dev/null +++ b/src/components/TextBox.tsx @@ -0,0 +1,61 @@ +import React from "react" +import { TextField } from "@material-ui/core" +import ExternalLink from "./ExternalLink" + +export interface TextBoxProps { + href: string + linkTitle: string + value: string | undefined | object + label: string + onChange: (e: string) => void + helperText?: string | JSX.Element + extraAction?: JSX.Element + required?: true + disabled?: boolean + id?: string +} + +const TextBox: React.FC = ({ + href, + linkTitle, + label, + value, + onChange, + required, + helperText, + disabled, + extraAction, + id, +}) => { + return ( + + {extraAction} + + + ), + }} + id={id} + size="medium" + variant="outlined" + fullWidth + label={label} + value={value === undefined ? "" : value} + onChange={e => onChange(e.target.value)} + required={required} + helperText={helperText} + disabled={disabled} + multiline={true} + maxRows={15} + minRows={15} + /> + ) +} + +export default TextBox diff --git a/src/components/ga4/EventBuilder/ValidateEvent/handlers/formatCheckLib.spec.ts b/src/components/ga4/EventBuilder/ValidateEvent/handlers/formatCheckLib.spec.ts index 3d5c9f77c..0e298d50d 100644 --- a/src/components/ga4/EventBuilder/ValidateEvent/handlers/formatCheckLib.spec.ts +++ b/src/components/ga4/EventBuilder/ValidateEvent/handlers/formatCheckLib.spec.ts @@ -6,8 +6,9 @@ describe("formatCheckLib", () => { test("does not return an error when app_instance_id is 32 alpha-numeric chars", () => { const payload = {app_instance_id: "12345678901234567890123456789012"} const firebaseAppId = '1:1233455666:android:abcdefgh' + const api_secret = '123' - let errors = formatCheckLib(payload, firebaseAppId) + let errors = formatCheckLib(payload, firebaseAppId, api_secret) expect(errors).toEqual([]) }) @@ -16,8 +17,9 @@ describe("formatCheckLib", () => { const appInstanceId = "123456789012345678901234567890123" const payload = {app_instance_id: appInstanceId} const firebaseAppId = '1:1233455666:android:abcdefgh' + const api_secret = '123' - let errors = formatCheckLib(payload, firebaseAppId) + let errors = formatCheckLib(payload, firebaseAppId, api_secret) expect(errors[0].description).toEqual( `Measurement app_instance_id is expected to be a 32 digit hexadecimal number but was [${ appInstanceId.length }] digits.` @@ -28,8 +30,9 @@ describe("formatCheckLib", () => { const appInstanceId = "1234567890123456789012345678901g" const payload = {app_instance_id: appInstanceId} const firebaseAppId = '1:1233455666:android:abcdefgh' + const api_secret = '123' - let errors = formatCheckLib(payload, firebaseAppId) + let errors = formatCheckLib(payload, firebaseAppId, api_secret) expect(errors[0].description).toEqual( `Measurement app_instance_id contains non hexadecimal character [g].`, @@ -41,8 +44,9 @@ describe("formatCheckLib", () => { test("does not return an error for a valid event name", () => { const payload = {events: [{name: 'add_payment_info'}]} const firebaseAppId = '1:1233455666:android:abcdefgh' + const api_secret = '123' - let errors = formatCheckLib(payload, firebaseAppId) + let errors = formatCheckLib(payload, firebaseAppId, api_secret) expect(errors).toEqual([]) }) @@ -50,8 +54,9 @@ describe("formatCheckLib", () => { test("returns an error when event's name is a reserved name", () => { const payload = {events: [{name: 'ad_click'}]} const firebaseAppId = '1:1233455666:android:abcdefgh' + const api_secret = '123' - let errors = formatCheckLib(payload, firebaseAppId) + let errors = formatCheckLib(payload, firebaseAppId, api_secret) expect(errors[0].description).toEqual( "ad_click is a reserved event name" @@ -63,8 +68,9 @@ describe("formatCheckLib", () => { test("does not return an error for a valid user property name", () => { const payload = {user_properties: {'test': 'test'}} const firebaseAppId = '1:1233455666:android:abcdefgh' + const api_secret = '123' - let errors = formatCheckLib(payload, firebaseAppId) + let errors = formatCheckLib(payload, firebaseAppId, api_secret) expect(errors).toEqual([]) }) @@ -72,8 +78,9 @@ describe("formatCheckLib", () => { test("returns an error when event's name is a reserved name", () => { const payload = {user_properties: {'first_open_time': 'test'}} const firebaseAppId = '1:1233455666:android:abcdefgh' + const api_secret = '123' - let errors = formatCheckLib(payload, firebaseAppId) + let errors = formatCheckLib(payload, firebaseAppId, api_secret) expect(errors[0].description).toEqual( "user_property: 'first_open_time' is a reserved user property name" @@ -85,8 +92,9 @@ describe("formatCheckLib", () => { test("does not return an error for a valid currency type", () => { const payload = {events: [{params: {currency: 'USD'}}]} const firebaseAppId = '1:1233455666:android:abcdefgh' + const api_secret = '123' - let errors = formatCheckLib(payload, firebaseAppId) + let errors = formatCheckLib(payload, firebaseAppId, api_secret) expect(errors).toEqual([]) }) @@ -94,8 +102,9 @@ describe("formatCheckLib", () => { test("returns an error for an invalid currency type", () => { const payload = {events: [{params: {currency: 'USDD'}}]} const firebaseAppId = '1:1233455666:android:abcdefgh' + const api_secret = '123' - let errors = formatCheckLib(payload, firebaseAppId) + let errors = formatCheckLib(payload, firebaseAppId, api_secret) expect(errors[0].description).toEqual( "currency: USDD must be a valid uppercase 3-letter ISO 4217 format" @@ -107,8 +116,9 @@ describe("formatCheckLib", () => { test("does not return an error if items array is valid", () => { const payload = {events: [{params: {items: [{'item_id': 1234}]}}]} const firebaseAppId = '1:1233455666:android:abcdefgh' + const api_secret = '123' - let errors = formatCheckLib(payload, firebaseAppId) + let errors = formatCheckLib(payload, firebaseAppId, api_secret) expect(errors).toEqual([]) }) @@ -116,8 +126,9 @@ describe("formatCheckLib", () => { test("returns an error when items does not have either item_id or item_name", () => { const payload = {events: [{params: {items: [{'item_namee': 'test'}]}}]} const firebaseAppId = '1:1233455666:android:abcdefgh' + const api_secret = '123' - let errors = formatCheckLib(payload, firebaseAppId) + let errors = formatCheckLib(payload, firebaseAppId, api_secret) expect(errors[0].description).toEqual( "'items' object must contain one of the following keys: 'item_id' or 'item_name'" @@ -127,8 +138,9 @@ describe("formatCheckLib", () => { test("validates empty items array when event requires items", () => { const payload = {events: [{params: {name: 'purchase', items: []}}]} const firebaseAppId = '1:1233455666:android:abcdefgh' + const api_secret = '123' - let errors = formatCheckLib(payload, firebaseAppId) + let errors = formatCheckLib(payload, firebaseAppId, api_secret) expect(errors[0].description).toEqual( "'items' should not be empty; One of 'item_id' or 'item_name' is a required key" @@ -138,8 +150,9 @@ describe("formatCheckLib", () => { test("does not validate empty items array when event doesn't require items", () => { const payload = {events: [{params: {name: 'random', items: []}}]} const firebaseAppId = '1:1233455666:android:abcdefgh' + const api_secret = '123' - let errors = formatCheckLib(payload, firebaseAppId) + let errors = formatCheckLib(payload, firebaseAppId, api_secret) expect(errors).toEqual([]) }) @@ -147,8 +160,9 @@ describe("formatCheckLib", () => { test("does not validate empty items array when event does not have a name", () => { const payload = {events: [{params: {items: []}}]} const firebaseAppId = '1:1233455666:android:abcdefgh' + const api_secret = '123' - let errors = formatCheckLib(payload, firebaseAppId) + let errors = formatCheckLib(payload, firebaseAppId, api_secret) expect(errors).toEqual([]) }) @@ -169,8 +183,9 @@ describe("formatCheckLib", () => { ] } const firebaseAppId = '1:1233455666:android:abcdefgh' + const api_secret = '123' - let errors = formatCheckLib(payload, firebaseAppId) + let errors = formatCheckLib(payload, firebaseAppId, api_secret) expect(errors[0].description).toEqual( "'items' object must contain one of the following keys: 'item_id' or 'item_name'" @@ -182,8 +197,9 @@ describe("formatCheckLib", () => { test("does not return an error if firebase_app_id is valid", () => { const payload = {} const firebaseAppId = '1:1233455666:android:abcdefgh' + const api_secret = '123' - let errors = formatCheckLib(payload, firebaseAppId) + let errors = formatCheckLib(payload, firebaseAppId, api_secret) expect(errors).toEqual([]) }) @@ -191,12 +207,37 @@ describe("formatCheckLib", () => { test("returns an error when firebase_app_id is invalid", () => { const payload = {} const firebaseAppId = '1233455666:android:abcdefgh' + const api_secret = '123' - let errors = formatCheckLib(payload, firebaseAppId) + let errors = formatCheckLib(payload, firebaseAppId, api_secret) expect(errors[0].description).toEqual( `${firebaseAppId} does not follow firebase_app_id pattern of X:XX:XX:XX at path` ) }) }) + + describe("validates api_secret", () => { + test("does not return an error api_secret is not null", () => { + const payload = {} + const firebaseAppId = '1:1233455666:android:abcdefgh' + const api_secret = '123' + + let errors = formatCheckLib(payload, firebaseAppId, api_secret) + + expect(errors).toEqual([]) + }) + + test("returns an error when api_secret is null", () => { + const payload = {} + const firebaseAppId = '1:1233455666:android:abcdefgh' + const api_secret = '' + + let errors = formatCheckLib(payload, firebaseAppId, api_secret) + + expect(errors[0].description).toEqual( + "Unable to find non-empty parameter [api_secret] value in request." + ) + }) + }) }) \ No newline at end of file diff --git a/src/components/ga4/EventBuilder/ValidateEvent/handlers/formatCheckLib.ts b/src/components/ga4/EventBuilder/ValidateEvent/handlers/formatCheckLib.ts index bf49c6aa3..6400f1872 100644 --- a/src/components/ga4/EventBuilder/ValidateEvent/handlers/formatCheckLib.ts +++ b/src/components/ga4/EventBuilder/ValidateEvent/handlers/formatCheckLib.ts @@ -17,8 +17,8 @@ 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) => { +// the schema validations. All checks are consistent with Firebase documentation. +export const formatCheckLib = (payload, firebaseAppId, api_secret) => { let errors: ValidationMessage[] = [] const appInstanceIdErrors = isValidAppInstanceId(payload) @@ -28,6 +28,7 @@ export const formatCheckLib = (payload, firebaseAppId) => { const emptyItemsErrors = isItemsEmpty(payload) const itemsRequiredKeyErrors = itemsHaveRequiredKey(payload) const firebaseAppIdErrors = isfirebaseAppIdValid(firebaseAppId) + const apiSecretErrors = isApiSecretNotNull(api_secret) const sizeErrors = isTooBig(payload) return [ @@ -39,6 +40,7 @@ export const formatCheckLib = (payload, firebaseAppId) => { ...emptyItemsErrors, ...itemsRequiredKeyErrors, ...firebaseAppIdErrors, + ...apiSecretErrors, ...sizeErrors, ] } @@ -183,7 +185,7 @@ const requiredKeysEmpty = (itemsObj) => { const isfirebaseAppIdValid = (firebaseAppId) => { let errors: ValidationMessage[] = [] - if (!firebaseAppId.match(/[0-9]:[0-9]+:[a-zA-Z]+:[a-zA-Z0-9]+$/)) { + 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", @@ -194,6 +196,20 @@ const isfirebaseAppIdValid = (firebaseAppId) => { return errors } +const isApiSecretNotNull = (api_secret) => { + let errors: ValidationMessage[] = [] + + if (!api_secret) { + errors.push({ + description: "Unable to find non-empty parameter [api_secret] value in request.", + validationCode: "VALUE_REQUIRED", + fieldPath: "api_secret" + }) + } + + return errors +} + const isTooBig = (payload) => { let errors: ValidationMessage[] = [] diff --git a/src/components/ga4/EventBuilder/ValidateEvent/handlers/responseUtil.ts b/src/components/ga4/EventBuilder/ValidateEvent/handlers/responseUtil.ts index 6900b43ca..e233d3391 100644 --- a/src/components/ga4/EventBuilder/ValidateEvent/handlers/responseUtil.ts +++ b/src/components/ga4/EventBuilder/ValidateEvent/handlers/responseUtil.ts @@ -8,6 +8,7 @@ const API_DOC_BASE_PAYLOAD_URL = 'https://developers.google.com/analytics/devgui const API_DOC_EVENT_URL = 'https://developers.google.com/analytics/devguides/collection/protocol/ga4/reference/events#' 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 BASE_PAYLOAD_ATTRIBUTES = ['app_instance_id', 'api_secret', 'firebase_app_id', 'user_id', 'timestamp_micros', 'user_properties', 'non_personalized_ads'] @@ -27,7 +28,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))) { + } else if (BASE_PAYLOAD_ATTRIBUTES.includes(fieldPath?.slice(2))) { error['fieldPath'] = fieldPath.slice(2) return error @@ -50,7 +51,7 @@ const addDocumentation = (error, payload) => { if (validationCode === 'max-length-error' || validationCode === 'max-properties-error' || validationCode === 'max-body-size') { return API_DOC_LIMITATIONS_URL - } else if (fieldPath.startsWith('#/events/')) { + } else if (fieldPath?.startsWith('#/events/')) { return API_DOC_EVENT_URL + payload?.events[0]?.name } else if (BASE_PAYLOAD_ATTRIBUTES.includes(fieldPath)) { return API_DOC_BASE_PAYLOAD_URL + fieldPath @@ -59,4 +60,13 @@ const addDocumentation = (error, payload) => { } return API_DOC_SENDING_EVENTS_URL +} + +export const formatValidationMessage = () => { + return [{ + 'description': 'Fix formatting issue and re-validate payload by clicking `Validate Event` below', + 'validationCode': 'format_invalid', + 'fieldPath': '#', + 'documentation': API_DOC_JSON_POST_BODY + }] } \ No newline at end of file diff --git a/src/components/ga4/EventBuilder/ValidateEvent/index.tsx b/src/components/ga4/EventBuilder/ValidateEvent/index.tsx index c011e89c3..80624ab73 100644 --- a/src/components/ga4/EventBuilder/ValidateEvent/index.tsx +++ b/src/components/ga4/EventBuilder/ValidateEvent/index.tsx @@ -35,6 +35,35 @@ interface StyleProps { error?: boolean valid?: boolean } + +interface TemplateProps { + heading: string + headingIcon?: JSX.Element + body: JSX.Element | string + validateEvent?: () => void + validationMessages?: ValidationMessage[] + sendToGA?: () => void + copyPayload?: () => void + copySharableLink?: () => void + error?: boolean + valid?: boolean + sent?: boolean + payloadErrors?: string | undefined + useTextBox?: boolean +} + +export interface ValidateEventProps { + measurement_id: string + app_instance_id: string + firebase_app_id: string + api_secret: string + client_id: string + user_id: string + formatPayload: () => void + payloadErrors: string | undefined + useTextBox: boolean +} + const useStyles = makeStyles(theme => ({ template: { padding: theme.spacing(2), @@ -67,21 +96,13 @@ const useStyles = makeStyles(theme => ({ }, })) -export interface ValidateEventProps { - measurement_id: string - app_instance_id: string - firebase_app_id: string - api_secret: string - client_id: string - user_id: string -} -const focusFor = (message: ValidationMessage) => { +const focusFor = (message: ValidationMessage, useTextBox) => { const { fieldPath } = message let id: string | undefined let labelValues: string[] = Object.values(Label) - if (labelValues.includes(fieldPath)) { + if (labelValues.includes(fieldPath) && !useTextBox) { id = fieldPath } @@ -100,19 +121,7 @@ const focusFor = (message: ValidationMessage) => { } } -interface TemplateProps { - heading: string - headingIcon?: JSX.Element - body: JSX.Element | string - validateEvent?: () => void - validationMessages?: ValidationMessage[] - sendToGA?: () => void - copyPayload?: () => void - copySharableLink?: () => void - error?: boolean - valid?: boolean - sent?: boolean -} + const Template: React.FC = ({ sent, heading, @@ -125,11 +134,14 @@ const Template: React.FC = ({ copySharableLink, error, valid, + payloadErrors, + useTextBox }) => { + const { instanceId, api_secret } = useContext(EventCtx)! const classes = useStyles({ error, valid }) const payload = usePayload() const formClasses = useFormStyles() - const { instanceId, api_secret } = useContext(EventCtx)! + return ( = ({ {headingIcon} {heading} - {validationMessages !== undefined && ( + + {validationMessages !== undefined && + ( + (useTextBox && !payloadErrors) || + !useTextBox + ) && (
    {validationMessages.map((message, idx) => (
  • - {focusFor(message)} + {focusFor(message, useTextBox)} {message.description}
    Documentation @@ -156,11 +173,26 @@ const Template: React.FC = ({
)} + {useTextBox && payloadErrors && ( +
+
    +
  • + JSON formatting error: {payloadErrors} +
  • +
+
+
+
+ )} + {body}
{validateEvent !== undefined && ( - + { + validateEvent() + } + }> validate event )} @@ -178,8 +210,11 @@ const Template: React.FC = ({ )}
+
+ Request info + POST /mp/collect?api_secret={api_secret} {instanceId.firebase_app_id && @@ -190,7 +225,9 @@ const Template: React.FC = ({ HOST: www.google-analytics.com
Content-Type: application/json
+ Payload +
= ({ ) } -const ValidateEvent: React.FC = () => { +const ValidateEvent: React.FC = ({formatPayload, payloadErrors, useTextBox}) => { const request = useValidateEvent() return ( @@ -217,6 +254,7 @@ const ValidateEvent: React.FC = () => { <> Update the event using the controls above. + # should update this language! When you're done editing the event, click "Validate Event" to @@ -224,23 +262,39 @@ const ValidateEvent: React.FC = () => { } - validateEvent={validateEvent} + validateEvent={ () => { + if (formatPayload) { + formatPayload() + } + + validateEvent() + } + } /> )} renderInProgress={() => (