Skip to content

Commit

Permalink
chore: update LD importer to use v2 endpoints (#167)
Browse files Browse the repository at this point in the history
* update LD importer to use v2 endpoints

* add support for startWith and endWith

* Update src/api/DevCycleApiWrapper.ts

Co-authored-by: Kaushal Kapasi <[email protected]>

---------

Co-authored-by: Kaushal Kapasi <[email protected]>
  • Loading branch information
elliotCamblor and kaushalkapasi authored Oct 4, 2024
1 parent 3756aaa commit 64b3d9c
Show file tree
Hide file tree
Showing 9 changed files with 90 additions and 33 deletions.
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 {
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 @@ export default class DevCycleApiWrapper {
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 @@ export default class DevCycleApiWrapper {

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`, {
method: 'POST',
body: JSON.stringify(feature),
headers,
Expand All @@ -151,7 +152,7 @@ export default class DevCycleApiWrapper {

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}`, {
method: 'PATCH',
body: JSON.stringify(feature),
headers,
Expand All @@ -162,7 +163,7 @@ export default class DevCycleApiWrapper {

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`, {
headers,
})
await this.handleErrors(response)
Expand Down Expand Up @@ -210,17 +211,19 @@ export default class DevCycleApiWrapper {

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}`,
{
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

0 comments on commit 64b3d9c

Please sign in to comment.