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

Reduce size of create-wrapper.ts #990

Merged
merged 1 commit into from
Nov 7, 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
5 changes: 5 additions & 0 deletions .changeset/gold-impalas-kneel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@segment/analytics-consent-tools': patch
---

Refactor consent wrapper; export GetCategoriesFunction
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ describe(createConsentStampingMiddleware, () => {
let middlewareFn: MiddlewareFunction
const nextFn = jest.fn()
const getCategories = jest.fn()
// @ts-ignore
const payload = {
obj: {
type: 'track',
Expand Down Expand Up @@ -42,4 +41,30 @@ describe(createConsentStampingMiddleware, () => {
Advertising: true,
})
})

it('should throw an error if getCategories returns an invalid value', async () => {
middlewareFn = createConsentStampingMiddleware(getCategories)
getCategories.mockReturnValue(null as any)
await expect(() =>
middlewareFn({
next: nextFn,
// @ts-ignore
payload,
})
).rejects.toThrowError(/Validation/)
expect(nextFn).not.toHaveBeenCalled()
})

it('should throw an error if getCategories returns an invalid async value', async () => {
middlewareFn = createConsentStampingMiddleware(getCategories)
getCategories.mockResolvedValue(null as any)
await expect(() =>
middlewareFn({
next: nextFn,
// @ts-ignore
payload,
})
).rejects.toThrowError(/Validation/)
expect(nextFn).not.toHaveBeenCalled()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -487,45 +487,6 @@ describe(createWrapper, () => {
expect(analyticsLoadSpy).not.toBeCalled()
})
})
test.each([
{
getCategories: () =>
({
invalidCategory: 'hello',
} as any),
returnVal: 'Categories',
},
{
getCategories: () =>
Promise.resolve({
invalidCategory: 'hello',
}) as any,
returnVal: 'Promise<Categories>',
},
])(
'should throw an error if getCategories() returns invalid categories during consent stamping ($returnVal))',
async ({ getCategories }) => {
const fn = jest.spyOn(ConsentStamping, 'createConsentStampingMiddleware')
const mockCdnSettings = settingsBuilder.build()

wrapTestAnalytics({
getCategories,
shouldLoadSegment: () => {
// on first load, we should not get an error because this is a valid category setting
return { invalidCategory: true }
},
})
await analytics.load({
...DEFAULT_LOAD_SETTINGS,
cdnSettings: mockCdnSettings,
})

const getCategoriesFn = fn.mock.lastCall[0]
await expect(getCategoriesFn()).rejects.toMatchInlineSnapshot(
`[ValidationError: [Validation] Consent Categories should be {[categoryName: string]: boolean} (Received: {"invalidCategory":"hello"})]`
)
}
)

describe('shouldEnableIntegration', () => {
it('should let user customize the logic that determines whether or not a destination is enabled', async () => {
Expand Down
2 changes: 2 additions & 0 deletions packages/consent/consent-tools/src/domain/consent-stamping.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { AnyAnalytics, Categories } from '../types'
import { validateCategories } from './validation'

type CreateConsentMw = (
getCategories: () => Promise<Categories>
Expand All @@ -11,6 +12,7 @@ export const createConsentStampingMiddleware: CreateConsentMw =
(getCategories) =>
async ({ payload, next }) => {
const categories = await getCategories()
validateCategories(categories)
payload.obj.context.consent = {
...payload.obj.context.consent,
categoryPreferences: categories,
Expand Down
64 changes: 18 additions & 46 deletions packages/consent/consent-tools/src/domain/create-wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ import {
validateSettings,
} from './validation'
import { createConsentStampingMiddleware } from './consent-stamping'
import { pipe, pick, uniq } from '../utils'
import { pipe } from '../utils'
import { AbortLoadError, LoadContext } from './load-cancellation'
import { ValidationError } from './validation/validation-error'
import { validateAndSendConsentChangedEvent } from './consent-changed'
import { getPrunedCategories } from './pruned-categories'

export const createWrapper = <Analytics extends AnyAnalytics>(
...[createWrapperOptions]: Parameters<CreateWrapper<Analytics>>
Expand Down Expand Up @@ -79,56 +79,28 @@ export const createWrapper = <Analytics extends AnyAnalytics>(

validateCategories(initialCategories)

const getPrunedCategories = async (
cdnSettingsP: Promise<CDNSettings>
): Promise<Categories> => {
const cdnSettings = await cdnSettingsP
// we don't want to send _every_ category to segment, only the ones that the user has explicitly configured in their integrations
let allCategories: string[]
// We need to get all the unique categories so we can prune the consent object down to only the categories that are configured
// There can be categories that are not included in any integration in the integrations object (e.g. 2 cloud mode categories), which is why we need a special allCategories array
if (integrationCategoryMappings) {
allCategories = uniq(
Object.values(integrationCategoryMappings).reduce((p, n) =>
p.concat(n)
)
)
} else {
allCategories = cdnSettings.consentSettings?.allCategories || []
}
// we need to register the listener before .load is called so we don't miss it.
// note: the 'initialize' API event is emitted so before the final flushing of events, so this promise won't block the pipeline.
const cdnSettings = new Promise<CDNSettings>((resolve) =>
analytics.on('initialize', resolve)
)

if (!allCategories.length) {
// No configured integrations found, so no categories will be sent (should not happen unless there's a configuration error)
throw new ValidationError(
'Invariant: No consent categories defined in Segment',
[]
// normalize getCategories pruning is turned on or off
const getCategoriesForConsentStamping = async (): Promise<Categories> => {
if (pruneUnmappedCategories) {
return getPrunedCategories(
getCategories,
await cdnSettings,
integrationCategoryMappings
)
} else {
return getCategories()
}

const categories = await getCategories()

return pick(categories, allCategories)
}

// create getCategories and validate them regardless of whether pruning is turned on or off
const getValidCategoriesForConsentStamping = pipe(
pruneUnmappedCategories
? getPrunedCategories.bind(
this,
new Promise<CDNSettings>((resolve) =>
analytics.on('initialize', resolve)
)
)
: getCategories,
async (categories) => {
validateCategories(await categories)
return categories
}
) as () => Promise<Categories>

// register listener to stamp all events with latest consent information
analytics.addSourceMiddleware(
createConsentStampingMiddleware(getValidCategoriesForConsentStamping)
createConsentStampingMiddleware(getCategoriesForConsentStamping)
)

const updateCDNSettings: InitOptions['updateCDNSettings'] = (
Expand All @@ -150,7 +122,7 @@ export const createWrapper = <Analytics extends AnyAnalytics>(
...options,
updateCDNSettings: pipe(
updateCDNSettings,
options?.updateCDNSettings ? options.updateCDNSettings : (f) => f
options?.updateCDNSettings || ((id) => id)
),
})
}
Expand Down
38 changes: 38 additions & 0 deletions packages/consent/consent-tools/src/domain/pruned-categories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { uniq, pick } from 'lodash'
import {
CDNSettings,
CreateWrapperSettings,
Categories,
GetCategoriesFunction,
} from '../types'
import { ValidationError } from './validation/validation-error'

export const getPrunedCategories = async (
getCategories: GetCategoriesFunction,
cdnSettings: CDNSettings,
integrationCategoryMappings?: CreateWrapperSettings['integrationCategoryMappings']
): Promise<Categories> => {
// we don't want to send _every_ category to segment, only the ones that the user has explicitly configured in their integrations
let allCategories: string[]
// We need to get all the unique categories so we can prune the consent object down to only the categories that are configured
// There can be categories that are not included in any integration in the integrations object (e.g. 2 cloud mode categories), which is why we need a special allCategories array
if (integrationCategoryMappings) {
allCategories = uniq(
Object.values(integrationCategoryMappings).reduce((p, n) => p.concat(n))
)
} else {
allCategories = cdnSettings.consentSettings?.allCategories || []
}

if (!allCategories.length) {
// No configured integrations found, so no categories will be sent (should not happen unless there's a configuration error)
throw new ValidationError(
'Invariant: No consent categories defined in Segment',
[]
)
}

const categories = await getCategories()

return pick(categories, allCategories)
}
1 change: 1 addition & 0 deletions packages/consent/consent-tools/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export type {
CreateWrapperSettings,
IntegrationCategoryMappings,
Categories,
GetCategoriesFunction,
RegisterOnConsentChangedFunction,
AnyAnalytics,
} from './types'
12 changes: 9 additions & 3 deletions packages/consent/consent-tools/src/types/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,18 @@ import type {
CDNSettingsRemotePlugin,
} from './wrapper'

/**
* See {@link CreateWrapperSettings.registerOnConsentChanged}
*/
export type RegisterOnConsentChangedFunction = (
categoriesChangedCb: (categories: Categories) => void
) => void

/**
* See {@link CreateWrapperSettings.getCategories}
*/
export type GetCategoriesFunction = () => Categories | Promise<Categories>

/**
* Consent wrapper function configuration
*/
Expand Down Expand Up @@ -55,11 +63,9 @@ export interface CreateWrapperSettings {
/**
* Fetch the categories which stamp every event. Called each time a new Segment event is dispatched.
* @example
* ```ts
* () => ({ "Advertising": true, "Analytics": false })
* ```
**/
getCategories: () => Categories | Promise<Categories>
getCategories: GetCategoriesFunction

/**
* Function to register a listener for consent changes to programatically send a "Segment Consent Preference" event to Segment when consent preferences change.
Expand Down