diff --git a/.github/skills/connection-setup/SKILL.md b/.github/skills/connection-setup/SKILL.md index a98be2b..ccd0c23 100644 --- a/.github/skills/connection-setup/SKILL.md +++ b/.github/skills/connection-setup/SKILL.md @@ -65,10 +65,10 @@ Remove-Item $tempFile -ErrorAction SilentlyContinue ### Step 2: Create Connection -Supported SDK connector names: `azureblob`, `azureloganalytics`, `mq`, `office365`, `office365users`, `onedriveforbusiness`, `sharepointonline`, `smtp`, `teams`, `msgraphgroupsanduser` (and any `Microsoft.Web/connections` connector name). +Supported SDK connector names: `azureblob`, `kusto`, `mq`, `msgraphgroupsanduser`, `office365`, `office365users`, `onedriveforbusiness`, `sharepointonline`, `smtp`, `teams` (and any `Microsoft.Web/connections` connector name). ```powershell -$connectorName = "" # e.g., "mq", "azureloganalytics", "office365", "office365users", "onedriveforbusiness", "sharepointonline", "smtp", "teams", "msgraphgroupsanduser" +$connectorName = "" # e.g., "azureblob", "kusto", "mq", "msgraphgroupsanduser", "office365", "office365users", "onedriveforbusiness", "sharepointonline", "smtp", "teams" $connectionName = "" # e.g., "office365-test", "sharepoint-test" $gwId = "/subscriptions/$subscriptionId/resourceGroups/$resourceGroup/providers/Microsoft.Web/connectorGateways/$gatewayName" diff --git a/CHANGELOG.md b/CHANGELOG.md index f948c5b..1925994 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Removed + +- Azure Log Analytics (`azureloganalytics`) connector removed — the connector and all its user-facing operations are deprecated by Microsoft (see [connector docs](https://learn.microsoft.com/en-us/connectors/azureloganalytics/)). Microsoft recommends the [Azure Monitor Logs](https://learn.microsoft.com/en-us/connectors/azuremonitorlogs/) connector as a replacement; this SDK does not yet include a generated client for it. + ## [0.8.0-preview.1] - 2026-04-30 ### Added - Office 365 Users (`office365users`) generated typed client for user profile lookups, manager/reports chain, user search, and trending documents (#75) -- Azure Log Analytics (`azureloganalytics`) generated typed client for workspace discovery and query schema operations (#74) +- Azure Log Analytics (`azureloganalytics`) generated typed client for workspace discovery and query schema operations (#74) *(removed in next release — connector deprecated by Microsoft)* - SMTP (`smtp`) generated typed client for sending email via SMTP connectors (#76) - Azure Blob Storage (`azureblob`) generated typed client with file and container operations (#80) - IBM MQ (`mq`) generated typed client for messaging queue operations (#81) diff --git a/README.md b/README.md index 569671c..585c5dd 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,6 @@ var categories = await client.GetOutlookCategoryNamesAsync(); | Connector | Status | Validated Operations | |-----------|--------|----------------------| -| Azure Log Analytics | ✅ Validated | ListSubscriptions, ListResourceGroups, ListWorkspaceNames, ListArmQueryResultsSchema | | IBM MQ | 🔄 SDK Generated | SendAsync, ReadAsync, ReadAllAsync, ReceiveAsync, ReceiveAllAsync, DeleteAsync, DeleteAllAsync | | Office365 | ✅ Validated | SendEmail, GetOutlookCategoryNames, ExportEmail, CalendarPostItem | | Office365 Users | ✅ Validated | MyProfile, UserProfile, Manager, DirectReports, SearchUser | diff --git a/ROADMAP.md b/ROADMAP.md index f423ed4..7ae2579 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -54,7 +54,7 @@ Azure-native connectors (some may overlap with existing Functions bindings). |----------|-----------|------------|--------------|-------|--------| | 3.0 | **Azure Data Explorer (Kusto)** | — | — | KQL queries, control commands, chart rendering, MCP server | ✅ Complete | | 3.1 | **Azure Blob Storage** | #8/#15 | — | May overlap with Functions binding | ⬜ Evaluate | -| 3.2 | **Azure Log Analytics** | #7 | — | Query/ingest operations | ⬜ Not started | +| 3.2 | ~~Azure Log Analytics~~ | #7 | — | Connector fully deprecated by Microsoft. Replacement is Azure Monitor Logs (no SDK client yet). | ❌ Deprecated | | 3.3 | **Event Hubs** | #14 | — | May overlap with Functions binding | ⬜ Evaluate | | 3.4 | **Service Bus** | #4/#13 | — | Functions already supports | ⚠️ Skip (native) | diff --git a/src/Microsoft.Azure.Connectors.Sdk/Generated/AzureloganalyticsExtensions.cs b/src/Microsoft.Azure.Connectors.Sdk/Generated/AzureloganalyticsExtensions.cs deleted file mode 100644 index 9eaff3a..0000000 --- a/src/Microsoft.Azure.Connectors.Sdk/Generated/AzureloganalyticsExtensions.cs +++ /dev/null @@ -1,393 +0,0 @@ -// AzureloganalyticsExtensions.cs - Auto-generated DirectClient SDK -// Do not edit this file directly. - -#nullable disable -#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member - -using System; -using System.Collections.Generic; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Threading; -using System.Threading.Tasks; -using Azure.Core; -using Azure.Identity; -using Microsoft.Azure.Connectors.Sdk; - -namespace Microsoft.Azure.Connectors.DirectClient.Azureloganalytics; - -#region Types - -/// -/// Response for List subscriptions -/// -public class SubscriptionListResult : IPageable -{ - /// The subscriptions. - [JsonPropertyName("value")] - public List Value { get; set; } - - /// The URL to get the next set of results. - [JsonPropertyName("nextLink")] - public string NextLink { get; set; } -} - -/// -/// Item in The subscriptions. -/// -public class Subscription -{ - /// The fully qualified Id. For example, /subscriptions/00000000-0000-0000-0000-000000000000. - [JsonPropertyName("id")] - public string Id { get; set; } - - /// The subscription Id. - [JsonPropertyName("subscriptionId")] - public string SubscriptionId { get; set; } - - /// The tenant Id. - [JsonPropertyName("tenantId")] - public string TenantId { get; set; } - - /// The subscription display name. - [JsonPropertyName("displayName")] - public string DisplayName { get; set; } - - /// The subscription state. - [JsonPropertyName("state")] - public string State { get; set; } - - /// The authorization source of the request. Valid values are one or more combinations of Legacy, RoleBased, Bypassed, Direct and Management. For example, 'Legacy, RoleBased'. - [JsonPropertyName("authorizationSource")] - public string AuthorizationSource { get; set; } -} - -/// -/// Response for List resource groups -/// -public class ResourceGroupListResult : IPageable -{ - /// The list of resource groups. - [JsonPropertyName("value")] - public List Value { get; set; } - - /// The URL to get the next set of results. - [JsonPropertyName("nextLink")] - public string NextLink { get; set; } -} - -/// -/// Item in The list of resource groups. -/// -public class ResourceGroup -{ - /// The ID of the resource group (e.g. /subscriptions/XXX/resourceGroups/YYY). - [JsonPropertyName("id")] - public string Id { get; set; } - - /// The Name of the resource group. - [JsonPropertyName("name")] - public string Name { get; set; } - - /// Id of the resource that manages this resource group. - [JsonPropertyName("managedBy")] - public string ManagedBy { get; set; } - - /// The tags attached to the resource group. - [JsonPropertyName("tags")] - public object Tags { get; set; } -} - -/// -/// Response for Get query schema -/// -public class ObjectEntity -{ - /// - /// Arbitrary properties. This type has no static schema; any JSON properties will be captured here. - /// - [JsonExtensionData] - public Dictionary AdditionalProperties { get; set; } = new(); -} - -#endregion Types - -#region Client - -/// -/// Exception thrown when azureloganalytics connector operations fail. -/// -public class AzureloganalyticsConnectorException : Exception -{ - private const int MaxResponseBodyLength = 2000; - - public string Operation { get; } - public int StatusCode { get; } - public string ResponseBody { get; } - - public AzureloganalyticsConnectorException(string operation, int statusCode, string responseBody) - : base($"{operation} failed with status {statusCode}: {TruncateBody(responseBody)}") - { - this.Operation = operation; - this.StatusCode = statusCode; - this.ResponseBody = responseBody; - } - - private static string TruncateBody(string body) - { - if (string.IsNullOrEmpty(body) || body.Length <= MaxResponseBodyLength) - return body; - return body.Substring(0, MaxResponseBodyLength) + "...[truncated]"; - } -} - -/// -/// Typed client for azureloganalytics connector. -/// -public class AzureloganalyticsClient : IDisposable -{ - private static readonly string[] ApiHubScopes = ["https://apihub.azure.com/.default"]; - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - PropertyNameCaseInsensitive = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - }; - - private readonly string _connectionRuntimeUrl; - private readonly HttpClient _httpClient; - private readonly bool _ownsHttpClient; - private readonly bool _ownsCredential; - private readonly TokenCredential _credential; - private AccessToken? _cachedToken; - - /// - /// Creates a new AzureloganalyticsClient. - /// - /// The connection runtime URL from Azure Portal. - /// Optional credential. Defaults to . - /// Optional . A new one will be created if not provided. - public AzureloganalyticsClient(string connectionRuntimeUrl, TokenCredential credential = null, HttpClient httpClient = null) - { - this._connectionRuntimeUrl = connectionRuntimeUrl?.TrimEnd('/') - ?? throw new ArgumentNullException(nameof(connectionRuntimeUrl)); - this._credential = credential ?? new DefaultAzureCredential(); - this._ownsCredential = credential == null; - this._ownsHttpClient = httpClient == null; - this._httpClient = httpClient ?? new HttpClient(); - } - - /// - /// Creates a new AzureloganalyticsClient with managed identity support. - /// - /// The connection runtime URL from Azure Portal. - /// The client ID for user-assigned managed identity. Use null for system-assigned identity with . - /// Optional . A new one will be created if not provided. - public AzureloganalyticsClient(string connectionRuntimeUrl, string managedIdentityClientId, HttpClient httpClient = null) - : this(connectionRuntimeUrl, CreateManagedIdentityCredential(managedIdentityClientId), httpClient) - { - } - - private static TokenCredential CreateManagedIdentityCredential(string managedIdentityClientId) - { - if (string.IsNullOrEmpty(managedIdentityClientId)) - { - return new ManagedIdentityCredential(ManagedIdentityId.SystemAssigned); - } - - return new ManagedIdentityCredential(ManagedIdentityId.FromUserAssignedClientId(managedIdentityClientId)); - } - - private async Task GetTokenAsync(CancellationToken cancellationToken) - { - if (this._cachedToken.HasValue && this._cachedToken.Value.ExpiresOn > DateTimeOffset.UtcNow.AddMinutes(5)) - { - return this._cachedToken.Value.Token; - } - - this._cachedToken = await this._credential.GetTokenAsync( - new TokenRequestContext(ApiHubScopes), cancellationToken); - return this._cachedToken.Value.Token; - } - - private string ResolveUrl(string path) - { - if (Uri.IsWellFormedUriString(path, UriKind.Absolute)) - { - var baseUri = new Uri(this._connectionRuntimeUrl); - var nextUri = new Uri(path); - if (!string.Equals(baseUri.Scheme, nextUri.Scheme, StringComparison.OrdinalIgnoreCase) || - !string.Equals(baseUri.Host, nextUri.Host, StringComparison.OrdinalIgnoreCase) || - baseUri.Port != nextUri.Port) - { - throw new InvalidOperationException( - $"NextLink URI '{nextUri.Scheme}://{nextUri.Host}:{nextUri.Port}' does not match connection URI '{baseUri.Scheme}://{baseUri.Host}:{baseUri.Port}'. " + - "Refusing to send credentials to an unexpected host."); - } - - return path; - } - - return $"{this._connectionRuntimeUrl}{path}"; - } - - private async Task CallConnectorAsync( - HttpMethod method, - string path, - object body = null, - CancellationToken cancellationToken = default) - { - var token = await this.GetTokenAsync(cancellationToken); - var url = this.ResolveUrl(path); - var operation = $"{method} {path}"; - - using var request = new HttpRequestMessage(method, url); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); - - if (body != null) - { - var json = JsonSerializer.Serialize(body, JsonOptions); - request.Content = new StringContent(json, Encoding.UTF8, "application/json"); - } - - using var response = await this._httpClient.SendAsync(request, cancellationToken); - - if (!response.IsSuccessStatusCode) - { - var errorBody = await response.Content.ReadAsStringAsync(cancellationToken); - throw new AzureloganalyticsConnectorException(operation, (int)response.StatusCode, errorBody); - } - - if (typeof(TResponse) == typeof(byte[])) - { - var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken); - return (TResponse)(object)bytes; - } - - var responseBody = await response.Content.ReadAsStringAsync(cancellationToken); - - if (string.IsNullOrEmpty(responseBody)) - return default; - - return JsonSerializer.Deserialize(responseBody, JsonOptions); - } - - private async Task CallConnectorAsync( - HttpMethod method, - string path, - object body = null, - CancellationToken cancellationToken = default) - { - var token = await this.GetTokenAsync(cancellationToken); - var url = this.ResolveUrl(path); - var operation = $"{method} {path}"; - - using var request = new HttpRequestMessage(method, url); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); - - if (body != null) - { - var json = JsonSerializer.Serialize(body, JsonOptions); - request.Content = new StringContent(json, Encoding.UTF8, "application/json"); - } - - using var response = await this._httpClient.SendAsync(request, cancellationToken); - - if (!response.IsSuccessStatusCode) - { - var responseBody = await response.Content.ReadAsStringAsync(cancellationToken); - throw new AzureloganalyticsConnectorException(operation, (int)response.StatusCode, responseBody); - } - } - - /// - /// List subscriptions - /// - /// Discovery method used to populate dynamic parameter values at design time. - /// An async enumerable of items across all pages. - public ConnectorPageable ListSubscriptionsAsync() - { - var path = $"/listSubscriptions"; - return new ConnectorPageable( - cancellationToken => this.CallConnectorAsync(HttpMethod.Get, path, cancellationToken: cancellationToken), - (nextLink, cancellationToken) => this.CallConnectorAsync(HttpMethod.Get, nextLink, cancellationToken: cancellationToken)); - } - - /// - /// List resource groups - /// - /// Discovery method used to populate dynamic parameter values at design time. - /// Subscription - /// An async enumerable of items across all pages. - public ConnectorPageable ListResourceGroupsAsync([DynamicValues("ListSubscriptions")] string subscription) - { - var queryParams = new List(); - if (subscription != default) - queryParams.Add($"subscriptions={Uri.EscapeDataString(subscription.ToString())}"); - var path = $"/listResourceGroupName" + (queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : ""); - return new ConnectorPageable( - cancellationToken => this.CallConnectorAsync(HttpMethod.Get, path, cancellationToken: cancellationToken), - (nextLink, cancellationToken) => this.CallConnectorAsync(HttpMethod.Get, nextLink, cancellationToken: cancellationToken)); - } - - /// - /// List workspaces - /// - /// Discovery method used to populate dynamic parameter values at design time. - /// Subscription - /// Resource Group - /// An async enumerable of items across all pages. - public ConnectorPageable ListWorkspaceNamesAsync([DynamicValues("ListSubscriptions")] string subscription, [DynamicValues("ListResourceGroups")] string resourceGroup) - { - var queryParams = new List(); - if (subscription != default) - queryParams.Add($"subscriptions={Uri.EscapeDataString(subscription.ToString())}"); - if (resourceGroup != default) - queryParams.Add($"resourcegroups={Uri.EscapeDataString(resourceGroup.ToString())}"); - var path = $"/listWorkspaceNames" + (queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : ""); - return new ConnectorPageable( - cancellationToken => this.CallConnectorAsync(HttpMethod.Get, path, cancellationToken: cancellationToken), - (nextLink, cancellationToken) => this.CallConnectorAsync(HttpMethod.Get, nextLink, cancellationToken: cancellationToken)); - } - - /// - /// Get query schema - /// - /// Discovery method used to populate dynamic parameter values at design time. - /// The request body. - /// Subscription - /// Resource Group - /// Workspaces Name - /// Cancellation token. - /// The Get query schema response. - public async Task ListArmQueryResultsSchemaAsync(string input, [DynamicValues("ListSubscriptions")] string subscription, [DynamicValues("ListResourceGroups")] string resourceGroup, [DynamicValues("ListWorkspaceNames")] string workspacesName, CancellationToken cancellationToken = default) - { - var queryParams = new List(); - if (subscription != default) - queryParams.Add($"subscriptions={Uri.EscapeDataString(subscription.ToString())}"); - if (resourceGroup != default) - queryParams.Add($"resourcegroups={Uri.EscapeDataString(resourceGroup.ToString())}"); - if (workspacesName != default) - queryParams.Add($"workspaces={Uri.EscapeDataString(workspacesName.ToString())}"); - var path = $"/omsQuerySchema" + (queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : ""); - return await this.CallConnectorAsync(HttpMethod.Post, path, input, cancellationToken); - } - - public void Dispose() - { - if (this._ownsHttpClient) - { - this._httpClient?.Dispose(); - } - - if (this._ownsCredential && this._credential is IDisposable disposableCredential) - { - disposableCredential.Dispose(); - } - } -} - -#endregion Client diff --git a/src/Microsoft.Azure.Connectors.Sdk/Generated/ConnectorNames.cs b/src/Microsoft.Azure.Connectors.Sdk/Generated/ConnectorNames.cs index d611206..a54b19b 100644 --- a/src/Microsoft.Azure.Connectors.Sdk/Generated/ConnectorNames.cs +++ b/src/Microsoft.Azure.Connectors.Sdk/Generated/ConnectorNames.cs @@ -21,11 +21,6 @@ public static class ConnectorNames /// public const string Azureblob = "azureblob"; - /// - /// The azureloganalytics connector. - /// - public const string Azureloganalytics = "azureloganalytics"; - /// /// The kusto connector. /// diff --git a/src/Microsoft.Azure.Connectors.Sdk/Generated/ManagedConnectors.cs b/src/Microsoft.Azure.Connectors.Sdk/Generated/ManagedConnectors.cs index d5795c0..8593faf 100644 --- a/src/Microsoft.Azure.Connectors.Sdk/Generated/ManagedConnectors.cs +++ b/src/Microsoft.Azure.Connectors.Sdk/Generated/ManagedConnectors.cs @@ -3,8 +3,6 @@ // // using Microsoft.Azure.Connectors.DirectClient.Azureblob; // var client = new AzureblobClient(connectionRuntimeUrl); -// using Microsoft.Azure.Connectors.DirectClient.Azureloganalytics; -// var client = new AzureloganalyticsClient(connectionRuntimeUrl); // using Microsoft.Azure.Connectors.DirectClient.Kusto; // var client = new KustoClient(connectionRuntimeUrl); // using Microsoft.Azure.Connectors.DirectClient.Mq; @@ -38,7 +36,6 @@ public static class DirectClientConnectors /// public static readonly string[] AvailableConnectors = [ "azureblob", - "azureloganalytics", "kusto", "mq", "msgraphgroupsanduser", diff --git a/tests/Microsoft.Azure.Connectors.Sdk.Tests/AzureloganalyticsClientTests.cs b/tests/Microsoft.Azure.Connectors.Sdk.Tests/AzureloganalyticsClientTests.cs deleted file mode 100644 index 2fb3cec..0000000 --- a/tests/Microsoft.Azure.Connectors.Sdk.Tests/AzureloganalyticsClientTests.cs +++ /dev/null @@ -1,530 +0,0 @@ -//------------------------------------------------------------ -// Copyright (c) Microsoft Corporation. All rights reserved. -//------------------------------------------------------------ - -using System; -using System.Collections.Generic; -using System.Net; -using System.Net.Http; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using global::Azure.Core; -using Microsoft.Azure.Connectors.DirectClient.Azureloganalytics; -using Microsoft.VisualStudio.TestTools.UnitTesting; -using Moq; -using Moq.Protected; - -namespace Microsoft.Azure.Connectors.Sdk.Tests -{ - /// - /// Tests for the generated AzureloganalyticsClient class. - /// - [TestClass] - public class AzureloganalyticsClientTests - { - [TestMethod] - public void Constructor_WithValidConnectionRuntimeUrl_ShouldCreateInstance() - { - // Arrange & Act - using var client = new AzureloganalyticsClient("https://test.azure.com/connection"); - - // Assert - Assert.IsNotNull(client); - } - - [TestMethod] - public void Constructor_WithNullConnectionRuntimeUrl_ShouldThrowArgumentNullException() - { - // Arrange & Act & Assert - Assert.ThrowsExactly(() => new AzureloganalyticsClient(null!)); - } - - [TestMethod] - public void Dispose_ShouldNotThrow() - { - // Arrange - var client = new AzureloganalyticsClient("https://test.azure.com/connection"); - - // Act & Assert - should not throw - client.Dispose(); - } - - [TestMethod] - public async Task ListSubscriptionsAsync_WithMockedResponse_ReturnsExpectedResult() - { - // Arrange - var mockHandler = new Mock(); - var expectedResponse = new SubscriptionListResult - { - Value = new List - { - new Subscription - { - SubscriptionId = "00000000-0000-0000-0000-000000000001", - DisplayName = "Test Subscription", - State = "Enabled", - TenantId = "tenant-1" - } - } - }; - - using var responseMessage = new HttpResponseMessage - { - Content = new StringContent(JsonSerializer.Serialize(expectedResponse)) - }; - - mockHandler.Protected() - .Setup>( - "SendAsync", - ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(responseMessage) - .Callback(() => { }) - .Verifiable(); - - var mockCredential = new Mock(); - mockCredential - .Setup(credential => credential.GetTokenAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(new AccessToken("mock-token", DateTimeOffset.UtcNow.AddHours(1))); - - var httpClient = new HttpClient(mockHandler.Object); - - using var client = new AzureloganalyticsClient( - connectionRuntimeUrl: "https://test.azure.com/connection", - credential: mockCredential.Object, - httpClient: httpClient); - - // Act - var items = new List(); - await foreach (var subscription in client.ListSubscriptionsAsync()) - { - items.Add(subscription); - } - - // Assert - Assert.AreEqual(1, items.Count); - Assert.AreEqual("00000000-0000-0000-0000-000000000001", items[0].SubscriptionId); - Assert.AreEqual("Test Subscription", items[0].DisplayName); - } - - [TestMethod] - public async Task ListResourceGroupsAsync_WithMockedResponse_ReturnsExpectedResult() - { - // Arrange - var mockHandler = new Mock(); - var expectedResponse = new ResourceGroupListResult - { - Value = new List - { - new ResourceGroup - { - Id = "/subscriptions/sub1/resourceGroups/rg1", - Name = "rg1" - } - } - }; - - using var responseMessage = new HttpResponseMessage - { - Content = new StringContent(JsonSerializer.Serialize(expectedResponse)) - }; - - mockHandler.Protected() - .Setup>( - "SendAsync", - ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(responseMessage) - .Callback(() => { }) - .Verifiable(); - - var mockCredential = new Mock(); - mockCredential - .Setup(credential => credential.GetTokenAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(new AccessToken("mock-token", DateTimeOffset.UtcNow.AddHours(1))); - - var httpClient = new HttpClient(mockHandler.Object); - - using var client = new AzureloganalyticsClient( - connectionRuntimeUrl: "https://test.azure.com/connection", - credential: mockCredential.Object, - httpClient: httpClient); - - // Act - var items = new List(); - await foreach (var rg in client.ListResourceGroupsAsync(subscription: "sub1")) - { - items.Add(rg); - } - - // Assert - Assert.AreEqual(1, items.Count); - Assert.AreEqual("rg1", items[0].Name); - } - - [TestMethod] - public async Task ListWorkspaceNamesAsync_WithMockedResponse_ReturnsExpectedResult() - { - // Arrange - var mockHandler = new Mock(); - var expectedResponse = new ResourceGroupListResult - { - Value = new List - { - new ResourceGroup - { - Id = "/subscriptions/sub1/resourceGroups/rg1/providers/Microsoft.OperationalInsights/workspaces/ws1", - Name = "ws1" - } - } - }; - - using var responseMessage = new HttpResponseMessage - { - Content = new StringContent(JsonSerializer.Serialize(expectedResponse)) - }; - - mockHandler.Protected() - .Setup>( - "SendAsync", - ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(responseMessage) - .Callback(() => { }) - .Verifiable(); - - var mockCredential = new Mock(); - mockCredential - .Setup(credential => credential.GetTokenAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(new AccessToken("mock-token", DateTimeOffset.UtcNow.AddHours(1))); - - var httpClient = new HttpClient(mockHandler.Object); - - using var client = new AzureloganalyticsClient( - connectionRuntimeUrl: "https://test.azure.com/connection", - credential: mockCredential.Object, - httpClient: httpClient); - - // Act - var items = new List(); - await foreach (var ws in client.ListWorkspaceNamesAsync(subscription: "sub1", resourceGroup: "rg1")) - { - items.Add(ws); - } - - // Assert - Assert.AreEqual(1, items.Count); - Assert.AreEqual("ws1", items[0].Name); - } - - [TestMethod] - public async Task ListArmQueryResultsSchemaAsync_WithMockedResponse_ReturnsExpectedResult() - { - // Arrange - var mockHandler = new Mock(); - var expectedResponse = new ObjectEntity - { - AdditionalProperties = new Dictionary - { - ["columns"] = JsonSerializer.SerializeToElement(new[] { "TimeGenerated", "Computer" }) - } - }; - - using var responseMessage = new HttpResponseMessage - { - Content = new StringContent(JsonSerializer.Serialize(expectedResponse)) - }; - - mockHandler.Protected() - .Setup>( - "SendAsync", - ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(responseMessage) - .Callback(() => { }) - .Verifiable(); - - var mockCredential = new Mock(); - mockCredential - .Setup(credential => credential.GetTokenAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(new AccessToken("mock-token", DateTimeOffset.UtcNow.AddHours(1))); - - var httpClient = new HttpClient(mockHandler.Object); - - using var client = new AzureloganalyticsClient( - connectionRuntimeUrl: "https://test.azure.com/connection", - credential: mockCredential.Object, - httpClient: httpClient); - - // Act - var result = await client - .ListArmQueryResultsSchemaAsync( - input: "Heartbeat | take 10", - subscription: "sub1", - resourceGroup: "rg1", - workspacesName: "myworkspace", - cancellationToken: CancellationToken.None) - .ConfigureAwait(continueOnCapturedContext: false); - - // Assert - Assert.IsNotNull(result); - Assert.IsTrue(result.AdditionalProperties.ContainsKey("columns")); - } - - [TestMethod] - public async Task ListArmQueryResultsSchemaAsync_WithErrorResponse_ThrowsConnectorException() - { - // Arrange - var mockHandler = new Mock(); - - using var responseMessage = new HttpResponseMessage - { - StatusCode = HttpStatusCode.BadRequest, - Content = new StringContent("{\"error\": \"Query syntax error\"}") - }; - - mockHandler.Protected() - .Setup>( - "SendAsync", - ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(responseMessage) - .Callback(() => { }) - .Verifiable(); - - var mockCredential = new Mock(); - mockCredential - .Setup(credential => credential.GetTokenAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(new AccessToken("mock-token", DateTimeOffset.UtcNow.AddHours(1))); - - var httpClient = new HttpClient(mockHandler.Object); - - using var client = new AzureloganalyticsClient( - connectionRuntimeUrl: "https://test.azure.com/connection", - credential: mockCredential.Object, - httpClient: httpClient); - - // Act & Assert - var exception = await Assert - .ThrowsExactlyAsync(async () => - await client - .ListArmQueryResultsSchemaAsync( - input: "invalid query |||", - subscription: "sub1", - resourceGroup: "rg1", - workspacesName: "myworkspace", - cancellationToken: CancellationToken.None) - .ConfigureAwait(continueOnCapturedContext: false)) - .ConfigureAwait(continueOnCapturedContext: false); - - Assert.AreEqual(400, exception.StatusCode); - Assert.IsTrue(exception.ResponseBody.Contains("Query syntax error", StringComparison.Ordinal)); - } - - [TestMethod] - public void AzureloganalyticsConnectorException_ShouldContainExpectedProperties() - { - // Arrange & Act - var exception = new AzureloganalyticsConnectorException( - operation: "POST /omsQuerySchema", - statusCode: 403, - responseBody: "Access denied"); - - // Assert - Assert.AreEqual(403, exception.StatusCode); - Assert.AreEqual("Access denied", exception.ResponseBody); - Assert.IsTrue(exception.Message.Contains("POST /omsQuerySchema", StringComparison.Ordinal)); - Assert.IsTrue(exception.Message.Contains("403", StringComparison.Ordinal)); - } - - [TestMethod] - public void Subscription_JsonSerialization_RoundTrips() - { - // Arrange - var subscription = new Subscription - { - Id = "/subscriptions/00000000-0000-0000-0000-000000000001", - SubscriptionId = "00000000-0000-0000-0000-000000000001", - TenantId = "tenant-1", - DisplayName = "Test Subscription", - State = "Enabled", - AuthorizationSource = "RoleBased" - }; - - // Act - var json = JsonSerializer.Serialize(subscription); - var deserialized = JsonSerializer.Deserialize(json); - - // Assert - Assert.IsNotNull(deserialized); - Assert.AreEqual("00000000-0000-0000-0000-000000000001", deserialized!.SubscriptionId); - Assert.AreEqual("Test Subscription", deserialized.DisplayName); - Assert.AreEqual("Enabled", deserialized.State); - Assert.AreEqual("tenant-1", deserialized.TenantId); - Assert.AreEqual("RoleBased", deserialized.AuthorizationSource); - } - - [TestMethod] - public void ResourceGroup_JsonSerialization_RoundTrips() - { - // Arrange - var resourceGroup = new ResourceGroup - { - Id = "/subscriptions/sub1/resourceGroups/rg1", - Name = "rg1", - ManagedBy = "someone" - }; - - // Act - var json = JsonSerializer.Serialize(resourceGroup); - var deserialized = JsonSerializer.Deserialize(json); - - // Assert - Assert.IsNotNull(deserialized); - Assert.AreEqual("rg1", deserialized!.Name); - Assert.AreEqual("/subscriptions/sub1/resourceGroups/rg1", deserialized.Id); - Assert.AreEqual("someone", deserialized.ManagedBy); - } - - [TestMethod] - public void ObjectEntity_DynamicSchema_SerializesAdditionalProperties() - { - // Arrange - var entity = new ObjectEntity - { - AdditionalProperties = new Dictionary - { - ["columns"] = JsonSerializer.SerializeToElement(new[] { "TimeGenerated", "Computer" }), - ["rowCount"] = JsonSerializer.SerializeToElement(100) - } - }; - - // Act - var json = JsonSerializer.Serialize(entity); - - // Assert - Assert.IsTrue(json.Contains("columns", StringComparison.Ordinal)); - Assert.IsTrue(json.Contains("TimeGenerated", StringComparison.Ordinal)); - Assert.IsTrue(json.Contains("100", StringComparison.Ordinal)); - } - - [TestMethod] - public void ObjectEntity_DynamicSchema_DeserializesArbitraryProperties() - { - // Arrange - var json = """{"columns":["TimeGenerated","Computer"],"rowCount":55}"""; - - // Act - var result = JsonSerializer.Deserialize(json); - - // Assert - Assert.IsNotNull(result!.AdditionalProperties); - Assert.AreEqual(2, result.AdditionalProperties!.Count); - Assert.AreEqual(55, result.AdditionalProperties["rowCount"].GetInt32()); - } - - [TestMethod] - public void Dispose_WithInjectedHttpClient_ShouldNotDisposeIt() - { - // Arrange - var httpClient = new HttpClient(); - var mockCredential = new Mock(); - - var client = new AzureloganalyticsClient( - connectionRuntimeUrl: "https://test.azure.com/connection", - credential: mockCredential.Object, - httpClient: httpClient); - - // Act - client.Dispose(); - - // Assert - injected HttpClient should still be usable (not disposed) - httpClient.DefaultRequestHeaders.Add("X-Test-Header", "TestValue"); - Assert.IsTrue(httpClient.DefaultRequestHeaders.Contains("X-Test-Header")); - } - - [TestMethod] - public void Dispose_WithInternallyCreatedHttpClient_ShouldDisposeIt() - { - // Arrange - no httpClient provided, so client creates its own - var mockCredential = new Mock(); - var client = new AzureloganalyticsClient( - connectionRuntimeUrl: "https://test.azure.com/connection", - credential: mockCredential.Object); - - // Act - client.Dispose(); - - // Assert - calling Dispose again should not throw (idempotent) - client.Dispose(); - } - - [TestMethod] - public async Task ListSubscriptionsAsync_PaginatesMultiplePages() - { - // Arrange - var callCount = 0; - var mockHandler = new Mock(); - - var page1 = new SubscriptionListResult - { - Value = new List - { - new Subscription { SubscriptionId = "sub-1", DisplayName = "First" } - }, - NextLink = "https://test.azure.com/connection/listSubscriptions?$skipToken=page2" - }; - - var page2 = new SubscriptionListResult - { - Value = new List - { - new Subscription { SubscriptionId = "sub-2", DisplayName = "Second" } - }, - NextLink = null - }; - - mockHandler.Protected() - .Setup>( - "SendAsync", - ItExpr.IsAny(), - ItExpr.IsAny()) - .ReturnsAsync(() => - { - callCount++; - var page = callCount == 1 ? page1 : page2; - return new HttpResponseMessage - { - Content = new StringContent(JsonSerializer.Serialize(page)) - }; - }) - .Verifiable(); - - var mockCredential = new Mock(); - mockCredential - .Setup(credential => credential.GetTokenAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(new AccessToken("mock-token", DateTimeOffset.UtcNow.AddHours(1))); - - var httpClient = new HttpClient(mockHandler.Object); - - using var client = new AzureloganalyticsClient( - connectionRuntimeUrl: "https://test.azure.com/connection", - credential: mockCredential.Object, - httpClient: httpClient); - - // Act - var items = new List(); - await foreach (var subscription in client.ListSubscriptionsAsync()) - { - items.Add(subscription); - } - - // Assert - Assert.AreEqual(2, items.Count); - Assert.AreEqual("sub-1", items[0].SubscriptionId); - Assert.AreEqual("sub-2", items[1].SubscriptionId); - Assert.AreEqual(2, callCount); - } - } -}