Skip to content
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
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,12 @@ jobs:
!TestData/feature-flags3.json
app-configuration-endpoint: ${{ vars.APP_CONFIGURATION_ENDPOINT }}
strict: true

- name: Test Local Action with label
id: test-action-label
uses: ./
with:
path: TestData/*.json
app-configuration-endpoint: ${{ vars.APP_CONFIGURATION_ENDPOINT }}
strict: true
label: test label
16 changes: 9 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,16 @@ path: |
!/path/to/feature-flags-ignore.json
```

- `app-configuration-endpoint` - An Azure app configuration endpoint eg..
- `app-configuration-endpoint` - An Azure app configuration endpoint. E.g.
`https://<app-configuration-name>.azconfig.io`
- `strict` - If strict, the sync operation deletes feature flags not found in
the config file. Choices: true, false.
- `operation` - [optional] Possible values: validate or deploy - deploy by
default. validate: only validates the configuration file. deploy: deploys the
feature flags to Azure App Configuration deploy: Updates the Azure App
configuration
- `label` - [optional] Azure App Configuration label to apply to the feature
flags. If not specficed, the default is no label.
- `operation` - [optional] Possible values: `validate` or `deploy` - `deploy` by
default. `validate`: only validates the configuration file. `deploy`: deploys
the feature flags to Azure App Configuration
- `strict` - Choices: `true` or `false`. If strict, the operation deletes
feature flags not found in the configuration file. Required when operation is
`deploy`.

### Example workflow

Expand Down
65 changes: 56 additions & 9 deletions __tests__/feature-flag-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,13 @@ describe('Feature Flag Client', () => {

it('should list feature flags', async () => {
const appConfigEndpoint = 'https://example.com'
const label = 'test-label'
const getMock = jest.spyOn(axios, 'get').mockResolvedValue({
status: 200,
data: featureListResponse
})

const results = await listFeatureFlags(appConfigEndpoint)
const results = await listFeatureFlags(appConfigEndpoint, label)
expect(results.items.length).toEqual(1)
expect(results.items[0].key).toEqual(
'.appconfig.featureflag/featureFlagId1'
Expand All @@ -41,27 +42,70 @@ describe('Feature Flag Client', () => {

it('should throw error when list api fails', async () => {
const appConfigEndpoint = 'https://example.com'
const label = 'test-label'

const getMock = jest.spyOn(axios, 'get').mockResolvedValue({
status: 500,
statusText: 'Internal Server Error'
})

await expect(listFeatureFlags(appConfigEndpoint)).rejects.toThrow(ApiError)
await expect(listFeatureFlags(appConfigEndpoint, label)).rejects.toThrow(
ApiError
)
})

it('create or update feature flag', async () => {
it('can create or update feature flag', async () => {
const appConfigEndpoint = 'https://example.com'
const featureFlagId = 'featureFlagId1'
const label = ''
const value = getDummyFeatureFlagItem(featureFlagId)

const getMock = jest.spyOn(axios, 'put').mockResolvedValue({
status: 200
})

await createOrUpdateFeatureFlag(appConfigEndpoint, featureFlagId, value)
await createOrUpdateFeatureFlag(
appConfigEndpoint,
featureFlagId,
value,
label
)
expect(getMock).toBeCalledWith(
`${appConfigEndpoint}/kv/.appconfig.featureflag%2FfeatureFlagId1?api-version=2023-11-01&label=`,
{
content_type:
'application/vnd.microsoft.appconfig.ff+json;charset=utf-8',
value: JSON.stringify(value)
},
{
headers: {
Accept: '*/*',
Authorization: 'Bearer token',
'Content-Type': 'application/vnd.microsoft.appconfig.kv+json'
}
}
)
})

it('can create or update feature flag with label', async () => {
const appConfigEndpoint = 'https://example.com'
const featureFlagId = 'featureFlagId1'
// Use a label with special characters to test URL encoding
const label = 'test label/with special&chars'
const value = getDummyFeatureFlagItem(featureFlagId)

const getMock = jest.spyOn(axios, 'put').mockResolvedValue({
status: 200
})

await createOrUpdateFeatureFlag(
appConfigEndpoint,
featureFlagId,
value,
label
)
expect(getMock).toBeCalledWith(
`${appConfigEndpoint}/kv/.appconfig.featureflag%2FfeatureFlagId1?api-version=2023-11-01`,
`${appConfigEndpoint}/kv/.appconfig.featureflag%2FfeatureFlagId1?api-version=2023-11-01&label=test%20label%2Fwith%20special%26chars`,
{
content_type:
'application/vnd.microsoft.appconfig.ff+json;charset=utf-8',
Expand All @@ -80,6 +124,7 @@ describe('Feature Flag Client', () => {
it('should throw error when create or update feature flag fails', async () => {
const appConfigEndpoint = 'https://example.com'
const featureFlagId = 'featureFlagId1'
const label = 'test-label'
const value = getDummyFeatureFlagItem(featureFlagId)

const getMock = jest.spyOn(axios, 'put').mockResolvedValue({
Expand All @@ -88,21 +133,22 @@ describe('Feature Flag Client', () => {
})

await expect(
createOrUpdateFeatureFlag(appConfigEndpoint, featureFlagId, value)
createOrUpdateFeatureFlag(appConfigEndpoint, featureFlagId, value, label)
).rejects.toThrow(ApiError)
})

it('should delete feature flag', async () => {
const appConfigEndpoint = 'https://example.com'
const featureFlagId = 'featureFlagId1'
const label = 'test label/with special&chars'

const getMock = jest.spyOn(axios, 'delete').mockResolvedValue({
status: 200
})

await deleteFeatureFlag(appConfigEndpoint, featureFlagId)
await deleteFeatureFlag(appConfigEndpoint, featureFlagId, label)
expect(getMock).toBeCalledWith(
`${appConfigEndpoint}/kv/.appconfig.featureflag%2FfeatureFlagId1?api-version=2023-11-01`,
`${appConfigEndpoint}/kv/.appconfig.featureflag%2FfeatureFlagId1?api-version=2023-11-01&label=test%20label%2Fwith%20special%26chars`,
{
headers: {
Accept: '*/*',
Expand All @@ -116,13 +162,14 @@ describe('Feature Flag Client', () => {
it('should throw error when delete feature flag fails', async () => {
const appConfigEndpoint = 'https://example.com'
const featureFlagId = 'featureFlagId1'
const label = 'test-label'

const getMock = jest.spyOn(axios, 'delete').mockResolvedValue({
status: 500
})

await expect(
deleteFeatureFlag(appConfigEndpoint, featureFlagId)
deleteFeatureFlag(appConfigEndpoint, featureFlagId, label)
).rejects.toThrow(ApiError)
})

Expand Down
6 changes: 4 additions & 2 deletions __tests__/input.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ describe('getActionInput', () => {
configFile: 'configFile',
strictSync: true,
appConfigEndpoint: 'https://example.com',
operation: 'deploy'
operation: 'deploy',
label: ''
})
})

Expand All @@ -38,7 +39,8 @@ describe('getActionInput', () => {
configFile: 'configFile',
strictSync: false, // doesn't matter in validate mode
appConfigEndpoint: '', // doesn't matter in validate mode
operation: 'validate'
operation: 'validate',
label: ''
})
})

Expand Down
12 changes: 8 additions & 4 deletions __tests__/update-feature-flags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ describe('updateFeatureFlags', () => {
expect(createOrUpdateFeatureFlag).toHaveBeenCalledWith(
input.appConfigEndpoint,
'featureFlagId2',
configs[1]
configs[1],
'test-label'
)
expect(infoMock).toHaveBeenCalledWith('Updated 1 feature flags')
})
Expand Down Expand Up @@ -81,7 +82,8 @@ describe('updateFeatureFlags', () => {
expect(createOrUpdateFeatureFlag).toHaveBeenCalledWith(
input.appConfigEndpoint,
'featureFlagId2',
configs[1]
configs[1],
'test-label'
)
expect(infoMock).toHaveBeenCalledWith('Updated 1 feature flags')
expect(infoMock).toHaveBeenCalledWith(
Expand All @@ -90,7 +92,8 @@ describe('updateFeatureFlags', () => {
expect(deleteFeatureFlag).toHaveBeenCalledTimes(1)
expect(deleteFeatureFlag).toHaveBeenCalledWith(
input.appConfigEndpoint,
'featureFlagId3'
'featureFlagId3',
'test-label'
)
})

Expand All @@ -99,7 +102,8 @@ describe('updateFeatureFlags', () => {
configFile: 'configFile',
strictSync: false,
appConfigEndpoint: 'https://example.com',
operation: 'deploy'
operation: 'deploy',
label: 'test-label'
}
}

Expand Down
11 changes: 9 additions & 2 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,21 @@ inputs:
required: true

app-configuration-endpoint:
description: 'Destination endpoint for the Azure App Configuration store'
description: 'Destination endpoint for the Azure App Configuration store.'
required: false

label:
description:
'Azure App Configuration label to apply to the feature flags. If not
specficed, the default is no label.'
required: false
default: None

operation: # Validate the configuration file only
description:
'Possible values: validate or deploy - deploy by default. validate: only
validates the configuration file. deploy: deploys the feature flags to
Azure App Configuration'
Azure App Configuration.'
required: false
default: 'deploy'

Expand Down
24 changes: 13 additions & 11 deletions dist/index.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion dist/index.js.map

Large diffs are not rendered by default.

15 changes: 9 additions & 6 deletions src/feature-flag-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@ import { FeatureFlag } from './models/feature-flag.models'
const apiVersion = '2023-11-01'

export const listFeatureFlags = async (
appConfigEndpoint: string
appConfigEndpoint: string,
label: string
): Promise<FeatureListResponse> => {
const response = await axios.get<FeatureListResponse>(
`${appConfigEndpoint}/kv?key=.appconfig.featureflag*&api-version=${apiVersion}`,
`${appConfigEndpoint}/kv?key=.appconfig.featureflag*&api-version=${apiVersion}&label=${encodeURIComponent(label)}`,
{ headers: await getHeaders(appConfigEndpoint) }
)

Expand All @@ -32,14 +33,15 @@ export const listFeatureFlags = async (
export const createOrUpdateFeatureFlag = async (
appConfigEndpoint: string,
featureFlagId: string,
value: FeatureFlag
value: FeatureFlag,
label: string
): Promise<void> => {
const payload: FeatureFlagResponse = {
content_type: 'application/vnd.microsoft.appconfig.ff+json;charset=utf-8',
value: JSON.stringify(value)
}
const response = await axios.put(
`${appConfigEndpoint}/kv/${getAppConfigKey(featureFlagId)}?api-version=${apiVersion}`,
`${appConfigEndpoint}/kv/${getAppConfigKey(encodeURIComponent(featureFlagId))}?api-version=${apiVersion}&label=${encodeURIComponent(label)}`,
payload,
{ headers: await getHeaders(appConfigEndpoint) }
)
Expand All @@ -53,10 +55,11 @@ export const createOrUpdateFeatureFlag = async (

export const deleteFeatureFlag = async (
appConfigEndpoint: string,
featureFlagId: string
featureFlagId: string,
label: string
): Promise<void> => {
const response = await axios.delete(
`${appConfigEndpoint}/kv/${getAppConfigKey(featureFlagId)}?api-version=${apiVersion}`,
`${appConfigEndpoint}/kv/${getAppConfigKey(featureFlagId)}?api-version=${apiVersion}&label=${encodeURIComponent(label)}`,
{ headers: await getHeaders(appConfigEndpoint) }
)

Expand Down
6 changes: 4 additions & 2 deletions src/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,16 @@ export function getActionInput(): Input {
configFile: getRequiredInputString('path'),
strictSync: false, // In validate mode, strict sync is not required
appConfigEndpoint: '', // In validate mode, app config endpoint is not required
operation: operation
operation: operation,
label: getNonRequiredInputString('label') || ''
}
}
return {
configFile: getRequiredInputString('path'),
strictSync: getRequiredBooleanInput('strict'),
appConfigEndpoint: getAppConfigEndpoint(),
operation: getOperationType()
operation: getOperationType(),
label: getNonRequiredInputString('label') || ''
}
}

Expand Down
Loading