Skip to content

Commit 6d80582

Browse files
[App Configuration] - Head request support (#36959)
### Packages impacted by this PR ### Issues associated with this PR ### Describe the problem that is addressed by this PR ### What are the possible designs available to address the problem? If there are more than one possible design, why was the one in this PR chosen? ### Are there test cases added in this PR? _(If not, why?)_ ### Provide a list of related PRs _(if any)_ ### Command used to generate this PR:**_(Applicable only to SDK release request PRs)_ ### Checklists - [ ] Added impacted package name to the issue description - [ ] Does this PR needs any fixes in the SDK Generator?** _(If so, create an Issue in the [Autorest/typescript](https://github.com/Azure/autorest.typescript) repository and link it here)_ - [ ] Added a changelog (if necessary)
1 parent df97bd7 commit 6d80582

File tree

6 files changed

+292
-2
lines changed

6 files changed

+292
-2
lines changed

sdk/appconfiguration/app-configuration/assets.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@
22
"AssetsRepo": "Azure/azure-sdk-assets",
33
"AssetsRepoPrefixPath": "js",
44
"TagPrefix": "js/appconfiguration/app-configuration",
5-
"Tag": "js/appconfiguration/app-configuration_b28373e403"
5+
"Tag": "js/appconfiguration/app-configuration_0282ec10b3"
66
}

sdk/appconfiguration/app-configuration/review/app-configuration-node.api.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export class AppConfigurationClient {
3131
archiveSnapshot(name: string, options?: UpdateSnapshotOptions): Promise<UpdateSnapshotResponse>;
3232
beginCreateSnapshot(snapshot: SnapshotInfo, options?: CreateSnapshotOptions): Promise<SimplePollerLike<OperationState<CreateSnapshotResponse>, CreateSnapshotResponse>>;
3333
beginCreateSnapshotAndWait(snapshot: SnapshotInfo, options?: CreateSnapshotOptions): Promise<CreateSnapshotResponse>;
34+
checkConfigurationSettings(options?: CheckConfigurationSettingsOptions): PagedAsyncIterableIterator<ConfigurationSetting, ListConfigurationSettingPage, PageSettings>;
3435
deleteConfigurationSetting(id: ConfigurationSettingId, options?: DeleteConfigurationSettingOptions): Promise<DeleteConfigurationSettingResponse>;
3536
getConfigurationSetting(id: ConfigurationSettingId, options?: GetConfigurationSettingOptions): Promise<GetConfigurationSettingResponse>;
3637
getSnapshot(name: string, options?: GetSnapshotOptions): Promise<GetSnapshotResponse>;
@@ -51,6 +52,11 @@ export interface AppConfigurationClientOptions extends CommonClientOptions {
5152
audience?: string;
5253
}
5354

55+
// @public
56+
export interface CheckConfigurationSettingsOptions extends OperationOptions, ListSettingsOptions {
57+
pageEtags?: string[];
58+
}
59+
5460
// @public
5561
export type ConfigurationSetting<T extends string | FeatureFlagValue | SecretReferenceValue | SnapshotReferenceValue = string> = ConfigurationSettingParam<T> & {
5662
isReadOnly: boolean;

sdk/appconfiguration/app-configuration/src/appConfigurationClient.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
type AddConfigurationSettingParam,
1010
type AddConfigurationSettingResponse,
1111
type AppConfigurationClientOptions,
12+
type CheckConfigurationSettingsOptions,
1213
type ConfigurationSetting,
1314
type ConfigurationSettingId,
1415
type CreateSnapshotOptions,
@@ -44,9 +45,11 @@ import type {
4445
AppConfigurationGetKeyValuesHeaders,
4546
AppConfigurationGetRevisionsHeaders,
4647
AppConfigurationGetSnapshotsHeaders,
48+
AppConfigurationCheckKeyValuesHeaders,
4749
GetKeyValuesResponse,
4850
GetRevisionsResponse,
4951
GetSnapshotsResponse,
52+
CheckKeyValuesResponse,
5053
ConfigurationSnapshot,
5154
GetLabelsResponse,
5255
AppConfigurationGetLabelsHeaders,
@@ -447,6 +450,81 @@ export class AppConfigurationClient {
447450
return getPagedAsyncIterator(pagedResult);
448451
}
449452

453+
/**
454+
* Checks settings from the Azure App Configuration service using a HEAD request, returning only headers without the response body.
455+
* This is useful for efficiently checking if settings have changed by comparing ETags.
456+
*
457+
* Example code:
458+
* ```ts snippet:CheckConfigurationSettings
459+
* import { DefaultAzureCredential } from "@azure/identity";
460+
* import { AppConfigurationClient } from "@azure/app-configuration";
461+
*
462+
* // The endpoint for your App Configuration resource
463+
* const endpoint = "https://example.azconfig.io";
464+
* const credential = new DefaultAzureCredential();
465+
* const client = new AppConfigurationClient(endpoint, credential);
466+
*
467+
* const pageIterator = client.checkConfigurationSettings({ keyFilter: "MyKey" }).byPage();
468+
* ```
469+
* @param options - Optional parameters for the request.
470+
*/
471+
checkConfigurationSettings(
472+
options: CheckConfigurationSettingsOptions = {},
473+
): PagedAsyncIterableIterator<ConfigurationSetting, ListConfigurationSettingPage, PageSettings> {
474+
const pageEtags = options.pageEtags ? [...options.pageEtags] : undefined;
475+
delete options.pageEtags;
476+
const pagedResult: PagedResult<ListConfigurationSettingPage, PageSettings, string | undefined> =
477+
{
478+
firstPageLink: undefined,
479+
getPage: async (pageLink: string | undefined) => {
480+
const etag = pageEtags?.shift();
481+
try {
482+
const response = await this.checkConfigurationSettingsRequest(
483+
{ ...options, etag },
484+
pageLink,
485+
);
486+
const link = response._response?.headers?.get("link");
487+
const continuationToken = link ? extractAfterTokenFromLinkHeader(link) : undefined;
488+
const currentResponse: ListConfigurationSettingPage = {
489+
...response,
490+
etag: response._response?.headers?.get("etag"),
491+
items: [],
492+
continuationToken: continuationToken,
493+
_response: response._response,
494+
};
495+
return {
496+
page: currentResponse,
497+
nextPageLink: currentResponse.continuationToken,
498+
};
499+
} catch (error) {
500+
const err = error as RestError;
501+
502+
const link = err.response?.headers?.get("link");
503+
const continuationToken = link ? extractAfterTokenFromLinkHeader(link) : undefined;
504+
505+
if (err.statusCode === 304) {
506+
err.message = `Status 304: No updates for this page`;
507+
logger.info(
508+
`[checkConfigurationSettings] No updates for this page. The current etag for the page is ${etag}`,
509+
);
510+
return {
511+
page: {
512+
items: [],
513+
etag,
514+
_response: { ...err.response, status: 304 },
515+
} as unknown as ListConfigurationSettingPage,
516+
nextPageLink: continuationToken,
517+
};
518+
}
519+
520+
throw err;
521+
}
522+
},
523+
toElements: (page) => page.items,
524+
};
525+
return getPagedAsyncIterator(pagedResult);
526+
}
527+
450528
/**
451529
* Lists settings from the Azure App Configuration service for snapshots based on name, optionally
452530
* filtered by key names, labels and accept datetime.
@@ -580,6 +658,28 @@ export class AppConfigurationClient {
580658
);
581659
}
582660

661+
private async checkConfigurationSettingsRequest(
662+
options: SendConfigurationSettingsOptions & PageSettings = {},
663+
pageLink: string | undefined,
664+
): Promise<CheckKeyValuesResponse & HttpResponseField<AppConfigurationCheckKeyValuesHeaders>> {
665+
return tracingClient.withSpan(
666+
"AppConfigurationClient.checkConfigurationSettings",
667+
options,
668+
async (updatedOptions) => {
669+
const response = await this.client.checkKeyValues({
670+
...updatedOptions,
671+
...formatAcceptDateTime(options),
672+
...formatConfigurationSettingsFiltersAndSelect(options),
673+
...checkAndFormatIfAndIfNoneMatch({ etag: options.etag }, { onlyIfChanged: true }),
674+
after: pageLink,
675+
});
676+
677+
return response as CheckKeyValuesResponse &
678+
HttpResponseField<AppConfigurationCheckKeyValuesHeaders>;
679+
},
680+
);
681+
}
682+
583683
/**
584684
* Lists revisions of a set of keys, optionally filtered by key names,
585685
* labels and accept datetime.

sdk/appconfiguration/app-configuration/src/models.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,16 @@ export interface ListConfigurationSettingsOptions extends OperationOptions, List
360360
pageEtags?: string[];
361361
}
362362

363+
/**
364+
* Options for checkConfigurationSettings that allow for filtering based on keys, labels and other fields.
365+
*/
366+
export interface CheckConfigurationSettingsOptions extends OperationOptions, ListSettingsOptions {
367+
/**
368+
* Etags list for page
369+
*/
370+
pageEtags?: string[];
371+
}
372+
363373
/**
364374
* Options for listLabels
365375
*/

sdk/appconfiguration/app-configuration/test/public/index.spec.ts

Lines changed: 166 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1151,6 +1151,171 @@ describe("AppConfigurationClient", () => {
11511151
});
11521152
});
11531153

1154+
describe("checkConfigurationSettings", () => {
1155+
it("returns empty items with valid response structure", async () => {
1156+
const key = recorder.variable(
1157+
"checkConfigSetting-emptyItems",
1158+
`checkConfigSetting-emptyItems${Math.floor(Math.random() * 100000)}`,
1159+
);
1160+
1161+
await client.addConfigurationSetting({
1162+
key,
1163+
value: "[A] production value",
1164+
});
1165+
1166+
try {
1167+
const pageIterator = client.checkConfigurationSettings({ keyFilter: key }).byPage();
1168+
1169+
const firstPage = await pageIterator.next();
1170+
assert.isFalse(firstPage.done);
1171+
assert.isDefined(firstPage.value);
1172+
assert.equal(firstPage.value.items.length, 0, "items should be empty for HEAD request");
1173+
assert.isDefined(firstPage.value.etag, "etag should be present");
1174+
assert.equal(firstPage.value._response.status, 200);
1175+
assert.isDefined(firstPage.value._response.headers.get("x-ms-date"));
1176+
} finally {
1177+
await deleteKeyCompletely([key], client);
1178+
}
1179+
});
1180+
1181+
it("returns 304 when using valid etag and no changes occurred", async () => {
1182+
const key = recorder.variable(
1183+
"checkConfigSetting-304",
1184+
`checkConfigSetting-304${Math.floor(Math.random() * 100000)}`,
1185+
);
1186+
1187+
await client.addConfigurationSetting({
1188+
key,
1189+
value: "[A] production value",
1190+
});
1191+
1192+
try {
1193+
// First call to get the etag
1194+
const pageIterator1 = client.checkConfigurationSettings({ keyFilter: key }).byPage();
1195+
const firstPage1 = await pageIterator1.next();
1196+
const etag = firstPage1.value.etag;
1197+
1198+
assert.isDefined(etag);
1199+
const etags: string[] = [etag!];
1200+
1201+
// Second call with the same etag - should return 304
1202+
const pageIterator2 = client
1203+
.checkConfigurationSettings({ keyFilter: key, pageEtags: etags })
1204+
.byPage();
1205+
const firstPage2 = await pageIterator2.next();
1206+
1207+
assert.isFalse(firstPage2.done);
1208+
assert.equal(firstPage2.value.items.length, 0);
1209+
assert.equal(firstPage2.value._response.status, 304, "should return 304 Not Modified");
1210+
assert.equal(firstPage2.value.etag, etag, "etag should be the same");
1211+
assert.isDefined(firstPage2.value._response.headers.get("x-ms-date"));
1212+
} finally {
1213+
await deleteKeyCompletely([key], client);
1214+
}
1215+
});
1216+
1217+
it("returns 200 when using etag but changes were made", async () => {
1218+
const key = recorder.variable(
1219+
"checkConfigSetting-200",
1220+
`checkConfigSetting-200${Math.floor(Math.random() * 100000)}`,
1221+
);
1222+
1223+
await client.addConfigurationSetting({
1224+
key,
1225+
value: "[A] production value",
1226+
});
1227+
1228+
try {
1229+
// First call to get the etag
1230+
const pageIterator1 = client.checkConfigurationSettings({ keyFilter: key }).byPage();
1231+
const firstPage1 = await pageIterator1.next();
1232+
const etag = firstPage1.value.etag;
1233+
1234+
assert.isDefined(etag);
1235+
const etags: string[] = [etag!];
1236+
1237+
// Make a change
1238+
await client.setConfigurationSetting({
1239+
key,
1240+
value: "[A] modified value",
1241+
});
1242+
1243+
// Second call with the old etag - should return 200 because content changed
1244+
const pageIterator2 = client
1245+
.checkConfigurationSettings({ keyFilter: key, pageEtags: etags })
1246+
.byPage();
1247+
const firstPage2 = await pageIterator2.next();
1248+
1249+
assert.isFalse(firstPage2.done);
1250+
assert.equal(firstPage2.value.items.length, 0);
1251+
assert.equal(firstPage2.value._response.status, 200, "should return 200 with changes");
1252+
assert.notEqual(firstPage2.value.etag, etag, "etag should be different");
1253+
} finally {
1254+
await deleteKeyCompletely([key], client);
1255+
}
1256+
});
1257+
1258+
// Skip in live mode to avoid throttling (429) when creating 100+ settings
1259+
it("returns different etags for different pages", { skip: isLiveMode() }, async () => {
1260+
const key = recorder.variable(
1261+
"checkConfigSetting-multiPage",
1262+
`checkConfigSetting-multiPage${Math.floor(Math.random() * 100000)}`,
1263+
);
1264+
1265+
// Create 101 settings to ensure we have at least 2 pages (page size is 100)
1266+
const expectedNumberOfLabels = 101;
1267+
1268+
let addSettingPromises = [];
1269+
for (let i = 0; i < expectedNumberOfLabels; i++) {
1270+
addSettingPromises.push(
1271+
client.addConfigurationSetting({
1272+
key,
1273+
value: `value for ${i}`,
1274+
label: i.toString(),
1275+
}),
1276+
);
1277+
1278+
if (i !== 0 && i % 10 === 0) {
1279+
await Promise.all(addSettingPromises);
1280+
addSettingPromises = [];
1281+
}
1282+
}
1283+
await Promise.all(addSettingPromises);
1284+
1285+
try {
1286+
const pageIterator = client.checkConfigurationSettings({ keyFilter: key }).byPage();
1287+
1288+
// Get first page
1289+
const firstPage = await pageIterator.next();
1290+
assert.isFalse(firstPage.done);
1291+
assert.isDefined(firstPage.value.etag, "first page etag should be present");
1292+
assert.equal(firstPage.value.items.length, 0, "items should be empty for HEAD request");
1293+
assert.equal(firstPage.value._response.status, 200);
1294+
const firstPageEtag = firstPage.value.etag;
1295+
1296+
// Get second page
1297+
const secondPage = await pageIterator.next();
1298+
assert.isFalse(secondPage.done);
1299+
assert.isDefined(secondPage.value.etag, "second page etag should be present");
1300+
assert.equal(secondPage.value.items.length, 0, "items should be empty for HEAD request");
1301+
assert.equal(secondPage.value._response.status, 200);
1302+
const secondPageEtag = secondPage.value.etag;
1303+
1304+
// Verify that each page has a different etag
1305+
assert.notEqual(
1306+
firstPageEtag,
1307+
secondPageEtag,
1308+
"different pages should have different etags",
1309+
);
1310+
} finally {
1311+
// Clean up all created settings
1312+
for (let i = 0; i < expectedNumberOfLabels; i++) {
1313+
await client.deleteConfigurationSetting({ key, label: i.toString() });
1314+
}
1315+
}
1316+
});
1317+
});
1318+
11541319
describe("listConfigSettings", () => {
11551320
let key1: string;
11561321
let key2: string;
@@ -1589,7 +1754,7 @@ describe("AppConfigurationClient", () => {
15891754
});
15901755

15911756
// Skipping all "accepts operation options flaky tests" https://github.com/Azure/azure-sdk-for-js/issues/26447
1592-
it.skip("accepts operation options", async () => {
1757+
it.skip("accepts operation options", async () => {
15931758
const key = recorder.variable(
15941759
`setConfigTestNA`,
15951760
`setConfigTestNA${Math.floor(Math.random() * 1000)}`,

sdk/appconfiguration/app-configuration/test/snippets.spec.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,15 @@ describe("snippets", () => {
223223
const allSettingsWithLabel = client.listConfigurationSettings({ labelFilter: "MyLabel" });
224224
});
225225

226+
it("CheckConfigurationSettings", async () => {
227+
// The endpoint for your App Configuration resource
228+
const endpoint = "https://example.azconfig.io";
229+
const credential = new DefaultAzureCredential();
230+
const client = new AppConfigurationClient(endpoint, credential);
231+
// @ts-preserve-whitespace
232+
const pageIterator = client.checkConfigurationSettings({ keyFilter: "MyKey" }).byPage();
233+
});
234+
226235
it("ListConfigurationSettingsForSnashots", async () => {
227236
// The endpoint for your App Configuration resource
228237
const endpoint = "https://example.azconfig.io";

0 commit comments

Comments
 (0)