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

chore: update LD importer to use v2 endpoints #167

Merged
merged 3 commits into from
Oct 4, 2024
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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -129,4 +129,5 @@ dist
.yarn/install-state.gz
.pnp.*

config.json
config.json
.idea/*
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ Note: This feature importer only supports LaunchDarkly API Version `20220603`. P
- Equivalent env var: OVERWRITE_DUPLICATES
- <b>operationMap</b>: <i>Map<string, string></i>
- A map of LD operations to map to DevCycle operations
- DevCycle operations: `=`, `!=`, `>`, `<`, `>=`, `<=`, `contain`, `!contain`, `exist`, `!exist`
- DevCycle operations: `=`, `!=`, `>`, `<`, `>=`, `<=`, `contain`, `!contain`, `exist`, `!exist`, `startWith`, `!startWith`, `endWith`, `!endWith`
- Equivalent env var: OPERATION_MAP
- <b>provider</b>: <i>string</i>
- The provider to import the feature flags from.
Expand Down Expand Up @@ -99,4 +99,4 @@ PROVIDER='launchdarkly'
- DVC supports the following top-level properties on the user object: see [DVC User Object](https://docs.devcycle.com/docs/sdk/client-side-sdks/javascript#dvc-user-object).
Any other properties used for targeting should be passed within the `customData` map.
- If you are passing a date to be used with LD's before/after operators, the value should to be converted to a long when passed to DVC. The importer will convert `before` & `after` operators to `<` & `>` in DVC.
- DVC doesn't support targeting by the top-level `isAnonymous` property. If you are using LD's targeting with the `anonymous` attribute, make sure to include an `anonymous` property in the user's `customData`
- DVC doesn't support targeting by the top-level `isAnonymous` property. If you are using LD's targeting with the `anonymous` attribute, make sure to include an `anonymous` property in the user's `customData`
21 changes: 12 additions & 9 deletions src/api/DevCycleApiWrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import { FeatureConfiguration } from '../types/DevCycle/targeting'

const DVC_BASE_URL = process.env.DVC_BASE_URL || 'https://api.devcycle.com/v1'
const DVC_BASE_URL_V2 = process.env.DVC_BASE_URL_V2 || 'https://api.devcycle.com/v2'

export default class DevCycleApiWrapper {
constructor(dvcClientId: string, dvcClientSecret: string, provider?: string) {
Expand All @@ -23,7 +24,7 @@
dvcClientId: string
dvcClientSecret: string
apiToken: string
provider: string
provider: string

private async getApiToken(): Promise<string> {
if (this.apiToken) return this.apiToken
Expand Down Expand Up @@ -140,7 +141,7 @@

async createFeature(projectKey: string, feature: Feature): Promise<Feature> {
const headers = await this.getHeaders()
const response = await fetch(`${DVC_BASE_URL}/projects/${projectKey}/features`, {
const response = await fetch(`${DVC_BASE_URL_V2}/projects/${projectKey}/features`, {

Check warning

Code scanning / CodeQL

File data in outbound network request Medium

Outbound network request depends on
file data
.
method: 'POST',
body: JSON.stringify(feature),
headers,
Expand All @@ -151,7 +152,7 @@

async updateFeature(projectKey: string, feature: Feature): Promise<Feature> {
const headers = await this.getHeaders()
const response = await fetch(`${DVC_BASE_URL}/projects/${projectKey}/features/${feature.key}`, {
const response = await fetch(`${DVC_BASE_URL_V2}/projects/${projectKey}/features/${feature.key}`, {

Check warning

Code scanning / CodeQL

File data in outbound network request Medium

Outbound network request depends on
file data
.
method: 'PATCH',
body: JSON.stringify(feature),
headers,
Expand All @@ -162,7 +163,7 @@

async getFeaturesForProject(projectKey: string): Promise<Feature[]> {
const headers = await this.getHeaders()
const response = await fetch(`${DVC_BASE_URL}/projects/${projectKey}/features?perPage=1000`, {
const response = await fetch(`${DVC_BASE_URL_V2}/projects/${projectKey}/features?perPage=1000`, {

Check warning

Code scanning / CodeQL

File data in outbound network request Medium

Outbound network request depends on
file data
.
headers,
})
await this.handleErrors(response)
Expand Down Expand Up @@ -210,17 +211,19 @@

async updateFeatureConfigurations(
projectKey: string,
featureKey: string,
environment: string,
configurations: FeatureConfiguration,
feature: Feature,
configurations: Record<string, FeatureConfiguration>,
options: { throwOnError: boolean } = { throwOnError: true }
): Promise<FeatureConfiguration> {
const headers = await this.getHeaders()
const response = await fetch(
`${DVC_BASE_URL}/projects/${projectKey}/features/${featureKey}/configurations?environment=${environment}`,
`${DVC_BASE_URL_V2}/projects/${projectKey}/features/${feature.key}`,

Check warning

Code scanning / CodeQL

File data in outbound network request Medium

Outbound network request depends on
file data
.
{
method: 'PATCH',
body: JSON.stringify(configurations),
body: JSON.stringify({
...feature,
configurations
}),
headers,
})
if (options.throwOnError) await this.handleErrors(response)
Expand Down
2 changes: 1 addition & 1 deletion src/resources/environments/LDEnvironmentImporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,4 @@ export class LDEnvironmentImporter {

return this.environmentsByKey
}
}
}
25 changes: 13 additions & 12 deletions src/resources/features/LDFeatureImporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,18 +149,19 @@ export class LDFeatureImporter {
const { action, feature, configs = [] } = this.featuresToImport[featureKey]
if (this.errors[feature.key]) continue
if (action === FeatureImportAction.Skip) continue

for (const config of configs) {
try {
if ((action === FeatureImportAction.Create) ||
(action === FeatureImportAction.Update && overwriteDuplicates)) {
await DVC.updateFeatureConfigurations(
targetProjectKey, feature.key, config.environment, config.targetingRules
)
}
} catch (e) {
this.errors[feature.key] = e instanceof Error ? e.message : 'unknown error'
const configurations: Record<string, FeatureConfiguration> = {}
configs.forEach((config) => {
configurations[config.environment] = config.targetingRules
})
try {
if ((action === FeatureImportAction.Create) ||
(action === FeatureImportAction.Update && overwriteDuplicates)) {
await DVC.updateFeatureConfigurations(
targetProjectKey, feature, configurations
)
}
} catch (e) {
this.errors[feature.key] = e instanceof Error ? e.message : 'unknown error'
}
}
}
Expand All @@ -177,4 +178,4 @@ export class LDFeatureImporter {
errored: this.errors,
}
}
}
}
5 changes: 4 additions & 1 deletion src/types/DevCycle/feature.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { FeatureConfiguration } from './targeting'

export type Feature = {
_id?: string
_project?: string
Expand All @@ -9,6 +11,7 @@ export type Feature = {
variables?: Variable[]
tags?: string[]
sdkVisibility?: SDKVisibility
configurations?: FeatureConfiguration[]
}

export type Variable = {
Expand Down Expand Up @@ -46,4 +49,4 @@ type SDKVisibility = {
mobile: boolean
client: boolean
server: boolean
}
}
6 changes: 5 additions & 1 deletion src/utils/DevCycle/targeting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export function createUserFilter(subType: string, comparator: string, values: an
}
normalizedValues = normalizedValues.map((value) => value.toUpperCase())
}

return {
type: 'user',
subType,
Expand Down Expand Up @@ -75,6 +75,10 @@ export function getNegatedComparator(operator: string) {
'!contain': 'contain',
'exist': '!exist',
'!exist': 'exist',
'startWith': '!startWith',
'!startWith': 'startWith',
'!endWith': 'endWith',
'endWith': '!endWith',
}
if (!(operator in negateComparatorMap)) {
throw new Error(`Unsupported operator: ${operator}`)
Expand Down
55 changes: 49 additions & 6 deletions src/utils/LaunchDarkly/targeting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ const mockUnsupportedOpRule = {
_id: 'abc',
attribute: 'email',
negate: false,
op: 'endsWith',
op: 'rhymesWith',
values: ['email.com']
}
],
Expand Down Expand Up @@ -244,7 +244,7 @@ describe('buildTargetingRuleFromRule', () => {
test('builds targeting rule with an op overrided in operationMap', () => {
const audienceImport = new LDAudienceImporter(mockConfig)
const operationMap = {
endsWith: 'contain',
rhymesWith: 'contain',
}

const result = buildTargetingRuleFromRule(
Expand Down Expand Up @@ -274,10 +274,53 @@ describe('buildTargetingRuleFromRule', () => {
})
})

test('builds targeting rule with endsWith', () => {
const audienceImport = new LDAudienceImporter(mockConfig)
const operationMap = {}
const mockUnsupportedNegatedOpRule = {
...mockUnsupportedOpRule,
clauses: [
{
_id: 'abc',
attribute: 'email',
negate: true,
op: 'endsWith',
values: ['email.com']
}
]
}

const result = buildTargetingRuleFromRule(
mockUnsupportedNegatedOpRule,
mockFeature,
'prod',
audienceImport,
operationMap,
)
expect(result).toEqual({
audience: {
name: 'Imported Rule',
filters: {
filters: [{
comparator: '!endWith',
type: 'user',
subType: 'email',
values: ['email.com']
}],
operator: OperatorType.and
}
},
distribution: [{
_variation: 'variation-2',
percentage: 1
}]
})
})

test('builds targeting rule with a negated op overrided in operationMap', () => {
const audienceImport = new LDAudienceImporter(mockConfig)
const operationMap = {
endsWith: 'contain',
rhymesWith: 'contain',
}
const mockUnsupportedNegatedOpRule = {
...mockUnsupportedOpRule,
Expand All @@ -286,7 +329,7 @@ describe('buildTargetingRuleFromRule', () => {
_id: 'abc',
attribute: 'email',
negate: true,
op: 'endsWith',
op: 'rhymesWith',
values: ['email.com']
}
]
Expand Down Expand Up @@ -332,7 +375,7 @@ describe('buildTargetingRuleFromRule', () => {
audienceImport,
operationMap,
)
expect(methodCall).toThrowError('Unsupported operation: endsWith')
expect(methodCall).toThrowError('Unsupported operation: rhymesWith')
})
})

Expand Down Expand Up @@ -396,4 +439,4 @@ describe('buildTargetingRulesFromFallthrough', () => {
]
})
})
})
})
2 changes: 2 additions & 0 deletions src/utils/LaunchDarkly/targeting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ export function getComparator(clause: Clause, customOperationMap: { [key: string
const operationMap = {
in: (neg: boolean) => neg ? '!=' : '=',
contains: (neg: boolean) => neg ? '!contain' : 'contain',
startsWith: (neg: boolean) => neg ? '!startWith' : 'startWith',
endsWith: (neg: boolean) => neg ? '!endWith' : 'endWith',
lessThan: (neg: boolean) => '<',
lessThanOrEqual: (neg: boolean) => '<=',
greaterThan: (neg: boolean) => '>',
Expand Down