diff --git a/sdk/appconfiguration/app-configuration/assets.json b/sdk/appconfiguration/app-configuration/assets.json index 684d6485f98d..6568549128a0 100644 --- a/sdk/appconfiguration/app-configuration/assets.json +++ b/sdk/appconfiguration/app-configuration/assets.json @@ -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" } diff --git a/sdk/appconfiguration/app-configuration/review/app-configuration-node.api.md b/sdk/appconfiguration/app-configuration/review/app-configuration-node.api.md index 02483bb17fb2..81aad7078e17 100644 --- a/sdk/appconfiguration/app-configuration/review/app-configuration-node.api.md +++ b/sdk/appconfiguration/app-configuration/review/app-configuration-node.api.md @@ -31,6 +31,7 @@ export class AppConfigurationClient { archiveSnapshot(name: string, options?: UpdateSnapshotOptions): Promise; beginCreateSnapshot(snapshot: SnapshotInfo, options?: CreateSnapshotOptions): Promise, CreateSnapshotResponse>>; beginCreateSnapshotAndWait(snapshot: SnapshotInfo, options?: CreateSnapshotOptions): Promise; + checkConfigurationSettings(options?: CheckConfigurationSettingsOptions): PagedAsyncIterableIterator; deleteConfigurationSetting(id: ConfigurationSettingId, options?: DeleteConfigurationSettingOptions): Promise; getConfigurationSetting(id: ConfigurationSettingId, options?: GetConfigurationSettingOptions): Promise; getSnapshot(name: string, options?: GetSnapshotOptions): Promise; @@ -51,6 +52,11 @@ export interface AppConfigurationClientOptions extends CommonClientOptions { audience?: string; } +// @public +export interface CheckConfigurationSettingsOptions extends OperationOptions, ListSettingsOptions { + pageEtags?: string[]; +} + // @public export type ConfigurationSetting = ConfigurationSettingParam & { isReadOnly: boolean; diff --git a/sdk/appconfiguration/app-configuration/src/appConfigurationClient.ts b/sdk/appconfiguration/app-configuration/src/appConfigurationClient.ts index 9cdac8860c42..66194041088e 100644 --- a/sdk/appconfiguration/app-configuration/src/appConfigurationClient.ts +++ b/sdk/appconfiguration/app-configuration/src/appConfigurationClient.ts @@ -9,6 +9,7 @@ import { type AddConfigurationSettingParam, type AddConfigurationSettingResponse, type AppConfigurationClientOptions, + type CheckConfigurationSettingsOptions, type ConfigurationSetting, type ConfigurationSettingId, type CreateSnapshotOptions, @@ -44,9 +45,11 @@ import type { AppConfigurationGetKeyValuesHeaders, AppConfigurationGetRevisionsHeaders, AppConfigurationGetSnapshotsHeaders, + AppConfigurationCheckKeyValuesHeaders, GetKeyValuesResponse, GetRevisionsResponse, GetSnapshotsResponse, + CheckKeyValuesResponse, ConfigurationSnapshot, GetLabelsResponse, AppConfigurationGetLabelsHeaders, @@ -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 { + const pageEtags = options.pageEtags ? [...options.pageEtags] : undefined; + delete options.pageEtags; + const pagedResult: PagedResult = + { + 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. @@ -580,6 +658,28 @@ export class AppConfigurationClient { ); } + private async checkConfigurationSettingsRequest( + options: SendConfigurationSettingsOptions & PageSettings = {}, + pageLink: string | undefined, + ): Promise> { + 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; + }, + ); + } + /** * Lists revisions of a set of keys, optionally filtered by key names, * labels and accept datetime. diff --git a/sdk/appconfiguration/app-configuration/src/models.ts b/sdk/appconfiguration/app-configuration/src/models.ts index 3681d233de0f..722f3e6d7ae5 100644 --- a/sdk/appconfiguration/app-configuration/src/models.ts +++ b/sdk/appconfiguration/app-configuration/src/models.ts @@ -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 */ diff --git a/sdk/appconfiguration/app-configuration/test/public/index.spec.ts b/sdk/appconfiguration/app-configuration/test/public/index.spec.ts index 9e6f8dd7e035..2ff86f6d3880 100644 --- a/sdk/appconfiguration/app-configuration/test/public/index.spec.ts +++ b/sdk/appconfiguration/app-configuration/test/public/index.spec.ts @@ -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; @@ -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)}`, diff --git a/sdk/appconfiguration/app-configuration/test/snippets.spec.ts b/sdk/appconfiguration/app-configuration/test/snippets.spec.ts index 015bdb3ef804..297b4982640d 100644 --- a/sdk/appconfiguration/app-configuration/test/snippets.spec.ts +++ b/sdk/appconfiguration/app-configuration/test/snippets.spec.ts @@ -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";