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
2 changes: 1 addition & 1 deletion sdk/appconfiguration/app-configuration/assets.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@
"AssetsRepo": "Azure/azure-sdk-assets",
"AssetsRepoPrefixPath": "js",
"TagPrefix": "js/appconfiguration/app-configuration",
"Tag": "js/appconfiguration/app-configuration_b28373e403"
"Tag": "js/appconfiguration/app-configuration_0282ec10b3"
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export class AppConfigurationClient {
archiveSnapshot(name: string, options?: UpdateSnapshotOptions): Promise<UpdateSnapshotResponse>;
beginCreateSnapshot(snapshot: SnapshotInfo, options?: CreateSnapshotOptions): Promise<SimplePollerLike<OperationState<CreateSnapshotResponse>, CreateSnapshotResponse>>;
beginCreateSnapshotAndWait(snapshot: SnapshotInfo, options?: CreateSnapshotOptions): Promise<CreateSnapshotResponse>;
checkConfigurationSettings(options?: CheckConfigurationSettingsOptions): PagedAsyncIterableIterator<ConfigurationSetting, ListConfigurationSettingPage, PageSettings>;
deleteConfigurationSetting(id: ConfigurationSettingId, options?: DeleteConfigurationSettingOptions): Promise<DeleteConfigurationSettingResponse>;
getConfigurationSetting(id: ConfigurationSettingId, options?: GetConfigurationSettingOptions): Promise<GetConfigurationSettingResponse>;
getSnapshot(name: string, options?: GetSnapshotOptions): Promise<GetSnapshotResponse>;
Expand All @@ -51,6 +52,11 @@ export interface AppConfigurationClientOptions extends CommonClientOptions {
audience?: string;
}

// @public
export interface CheckConfigurationSettingsOptions extends OperationOptions, ListSettingsOptions {
pageEtags?: string[];
}

// @public
export type ConfigurationSetting<T extends string | FeatureFlagValue | SecretReferenceValue | SnapshotReferenceValue = string> = ConfigurationSettingParam<T> & {
isReadOnly: boolean;
Expand Down
100 changes: 100 additions & 0 deletions sdk/appconfiguration/app-configuration/src/appConfigurationClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
type AddConfigurationSettingParam,
type AddConfigurationSettingResponse,
type AppConfigurationClientOptions,
type CheckConfigurationSettingsOptions,
type ConfigurationSetting,
type ConfigurationSettingId,
type CreateSnapshotOptions,
Expand Down Expand Up @@ -44,9 +45,11 @@ import type {
AppConfigurationGetKeyValuesHeaders,
AppConfigurationGetRevisionsHeaders,
AppConfigurationGetSnapshotsHeaders,
AppConfigurationCheckKeyValuesHeaders,
GetKeyValuesResponse,
GetRevisionsResponse,
GetSnapshotsResponse,
CheckKeyValuesResponse,
ConfigurationSnapshot,
GetLabelsResponse,
AppConfigurationGetLabelsHeaders,
Expand Down Expand Up @@ -447,6 +450,81 @@ export class AppConfigurationClient {
return getPagedAsyncIterator(pagedResult);
}

/**
* Checks settings from the Azure App Configuration service using a HEAD request, returning only headers without the response body.
* This is useful for efficiently checking if settings have changed by comparing ETags.
*
* Example code:
* ```ts snippet:CheckConfigurationSettings
* import { DefaultAzureCredential } from "@azure/identity";
* import { AppConfigurationClient } from "@azure/app-configuration";
*
* // The endpoint for your App Configuration resource
* const endpoint = "https://example.azconfig.io";
* const credential = new DefaultAzureCredential();
* const client = new AppConfigurationClient(endpoint, credential);
*
* const pageIterator = client.checkConfigurationSettings({ keyFilter: "MyKey" }).byPage();
* ```
* @param options - Optional parameters for the request.
*/
checkConfigurationSettings(
options: CheckConfigurationSettingsOptions = {},
): PagedAsyncIterableIterator<ConfigurationSetting, ListConfigurationSettingPage, PageSettings> {
const pageEtags = options.pageEtags ? [...options.pageEtags] : undefined;
delete options.pageEtags;
const pagedResult: PagedResult<ListConfigurationSettingPage, PageSettings, string | undefined> =
{
firstPageLink: undefined,
getPage: async (pageLink: string | undefined) => {
const etag = pageEtags?.shift();
try {
const response = await this.checkConfigurationSettingsRequest(
{ ...options, etag },
pageLink,
);
const link = response._response?.headers?.get("link");
const continuationToken = link ? extractAfterTokenFromLinkHeader(link) : undefined;
const currentResponse: ListConfigurationSettingPage = {
...response,
etag: response._response?.headers?.get("etag"),
items: [],
continuationToken: continuationToken,
_response: response._response,
};
return {
page: currentResponse,
nextPageLink: currentResponse.continuationToken,
};
} catch (error) {
const err = error as RestError;

const link = err.response?.headers?.get("link");
const continuationToken = link ? extractAfterTokenFromLinkHeader(link) : undefined;

if (err.statusCode === 304) {
err.message = `Status 304: No updates for this page`;
logger.info(
`[checkConfigurationSettings] No updates for this page. The current etag for the page is ${etag}`,
);
return {
page: {
items: [],
etag,
_response: { ...err.response, status: 304 },
} as unknown as ListConfigurationSettingPage,
nextPageLink: continuationToken,
};
}

throw err;
}
},
toElements: (page) => page.items,
};
return getPagedAsyncIterator(pagedResult);
}

/**
* Lists settings from the Azure App Configuration service for snapshots based on name, optionally
* filtered by key names, labels and accept datetime.
Expand Down Expand Up @@ -580,6 +658,28 @@ export class AppConfigurationClient {
);
}

private async checkConfigurationSettingsRequest(
options: SendConfigurationSettingsOptions & PageSettings = {},
pageLink: string | undefined,
): Promise<CheckKeyValuesResponse & HttpResponseField<AppConfigurationCheckKeyValuesHeaders>> {
return tracingClient.withSpan(
"AppConfigurationClient.checkConfigurationSettings",
options,
async (updatedOptions) => {
const response = await this.client.checkKeyValues({
...updatedOptions,
...formatAcceptDateTime(options),
...formatConfigurationSettingsFiltersAndSelect(options),
...checkAndFormatIfAndIfNoneMatch({ etag: options.etag }, { onlyIfChanged: true }),
after: pageLink,
});

return response as CheckKeyValuesResponse &
HttpResponseField<AppConfigurationCheckKeyValuesHeaders>;
},
);
}

/**
* Lists revisions of a set of keys, optionally filtered by key names,
* labels and accept datetime.
Expand Down
10 changes: 10 additions & 0 deletions sdk/appconfiguration/app-configuration/src/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,16 @@ export interface ListConfigurationSettingsOptions extends OperationOptions, List
pageEtags?: string[];
}

/**
* Options for checkConfigurationSettings that allow for filtering based on keys, labels and other fields.
*/
export interface CheckConfigurationSettingsOptions extends OperationOptions, ListSettingsOptions {
/**
* Etags list for page
*/
pageEtags?: string[];
}

/**
* Options for listLabels
*/
Expand Down
167 changes: 166 additions & 1 deletion sdk/appconfiguration/app-configuration/test/public/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1151,6 +1151,171 @@ describe("AppConfigurationClient", () => {
});
});

describe("checkConfigurationSettings", () => {
it("returns empty items with valid response structure", async () => {
const key = recorder.variable(
"checkConfigSetting-emptyItems",
`checkConfigSetting-emptyItems${Math.floor(Math.random() * 100000)}`,
);

await client.addConfigurationSetting({
key,
value: "[A] production value",
});

try {
const pageIterator = client.checkConfigurationSettings({ keyFilter: key }).byPage();

const firstPage = await pageIterator.next();
assert.isFalse(firstPage.done);
assert.isDefined(firstPage.value);
assert.equal(firstPage.value.items.length, 0, "items should be empty for HEAD request");
assert.isDefined(firstPage.value.etag, "etag should be present");
assert.equal(firstPage.value._response.status, 200);
assert.isDefined(firstPage.value._response.headers.get("x-ms-date"));
} finally {
await deleteKeyCompletely([key], client);
}
});

it("returns 304 when using valid etag and no changes occurred", async () => {
const key = recorder.variable(
"checkConfigSetting-304",
`checkConfigSetting-304${Math.floor(Math.random() * 100000)}`,
);

await client.addConfigurationSetting({
key,
value: "[A] production value",
});

try {
// First call to get the etag
const pageIterator1 = client.checkConfigurationSettings({ keyFilter: key }).byPage();
const firstPage1 = await pageIterator1.next();
const etag = firstPage1.value.etag;

assert.isDefined(etag);
const etags: string[] = [etag!];

// Second call with the same etag - should return 304
const pageIterator2 = client
.checkConfigurationSettings({ keyFilter: key, pageEtags: etags })
.byPage();
const firstPage2 = await pageIterator2.next();

assert.isFalse(firstPage2.done);
assert.equal(firstPage2.value.items.length, 0);
assert.equal(firstPage2.value._response.status, 304, "should return 304 Not Modified");
assert.equal(firstPage2.value.etag, etag, "etag should be the same");
assert.isDefined(firstPage2.value._response.headers.get("x-ms-date"));
} finally {
await deleteKeyCompletely([key], client);
}
});

it("returns 200 when using etag but changes were made", async () => {
const key = recorder.variable(
"checkConfigSetting-200",
`checkConfigSetting-200${Math.floor(Math.random() * 100000)}`,
);

await client.addConfigurationSetting({
key,
value: "[A] production value",
});

try {
// First call to get the etag
const pageIterator1 = client.checkConfigurationSettings({ keyFilter: key }).byPage();
const firstPage1 = await pageIterator1.next();
const etag = firstPage1.value.etag;

assert.isDefined(etag);
const etags: string[] = [etag!];

// Make a change
await client.setConfigurationSetting({
key,
value: "[A] modified value",
});

// Second call with the old etag - should return 200 because content changed
const pageIterator2 = client
.checkConfigurationSettings({ keyFilter: key, pageEtags: etags })
.byPage();
const firstPage2 = await pageIterator2.next();

assert.isFalse(firstPage2.done);
assert.equal(firstPage2.value.items.length, 0);
assert.equal(firstPage2.value._response.status, 200, "should return 200 with changes");
assert.notEqual(firstPage2.value.etag, etag, "etag should be different");
} finally {
await deleteKeyCompletely([key], client);
}
});

// Skip in live mode to avoid throttling (429) when creating 100+ settings
it("returns different etags for different pages", { skip: isLiveMode() }, async () => {
const key = recorder.variable(
"checkConfigSetting-multiPage",
`checkConfigSetting-multiPage${Math.floor(Math.random() * 100000)}`,
);

// Create 101 settings to ensure we have at least 2 pages (page size is 100)
const expectedNumberOfLabels = 101;

let addSettingPromises = [];
for (let i = 0; i < expectedNumberOfLabels; i++) {
addSettingPromises.push(
client.addConfigurationSetting({
key,
value: `value for ${i}`,
label: i.toString(),
}),
);

if (i !== 0 && i % 10 === 0) {
await Promise.all(addSettingPromises);
addSettingPromises = [];
}
}
await Promise.all(addSettingPromises);

try {
const pageIterator = client.checkConfigurationSettings({ keyFilter: key }).byPage();

// Get first page
const firstPage = await pageIterator.next();
assert.isFalse(firstPage.done);
assert.isDefined(firstPage.value.etag, "first page etag should be present");
assert.equal(firstPage.value.items.length, 0, "items should be empty for HEAD request");
assert.equal(firstPage.value._response.status, 200);
const firstPageEtag = firstPage.value.etag;

// Get second page
const secondPage = await pageIterator.next();
assert.isFalse(secondPage.done);
assert.isDefined(secondPage.value.etag, "second page etag should be present");
assert.equal(secondPage.value.items.length, 0, "items should be empty for HEAD request");
assert.equal(secondPage.value._response.status, 200);
const secondPageEtag = secondPage.value.etag;

// Verify that each page has a different etag
assert.notEqual(
firstPageEtag,
secondPageEtag,
"different pages should have different etags",
);
} finally {
// Clean up all created settings
for (let i = 0; i < expectedNumberOfLabels; i++) {
await client.deleteConfigurationSetting({ key, label: i.toString() });
}
}
});
});

describe("listConfigSettings", () => {
let key1: string;
let key2: string;
Expand Down Expand Up @@ -1589,7 +1754,7 @@ describe("AppConfigurationClient", () => {
});

// Skipping all "accepts operation options flaky tests" https://github.com/Azure/azure-sdk-for-js/issues/26447
it.skip("accepts operation options", async () => {
it.skip("accepts operation options", async () => {
const key = recorder.variable(
`setConfigTestNA`,
`setConfigTestNA${Math.floor(Math.random() * 1000)}`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,15 @@ describe("snippets", () => {
const allSettingsWithLabel = client.listConfigurationSettings({ labelFilter: "MyLabel" });
});

it("CheckConfigurationSettings", async () => {
// The endpoint for your App Configuration resource
const endpoint = "https://example.azconfig.io";
const credential = new DefaultAzureCredential();
const client = new AppConfigurationClient(endpoint, credential);
// @ts-preserve-whitespace
const pageIterator = client.checkConfigurationSettings({ keyFilter: "MyKey" }).byPage();
});

it("ListConfigurationSettingsForSnashots", async () => {
// The endpoint for your App Configuration resource
const endpoint = "https://example.azconfig.io";
Expand Down