diff --git a/CHANGELOG.md b/CHANGELOG.md index ff0d7c3..9493eb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.2.1] - 2026-05-13 + +### Fixed + +- `GetComputersAsync`, `GetProductsAsync`, `GetWebhooksAsync`, and `GetWebhookDeliveriesAsync` no longer throw `System.Text.Json.JsonException` ("The input does not contain any JSON tokens") when the server returns HTTP 204 No Content or HTTP 200 with an empty body. They now return an empty sequence. +- The same empty-body guard was applied to the single-item `GetLicenseAsync`, `GetReceiptAsync`, `GetProductAsync`, `GetComputerAsync`, and `GetWebhookAsync` methods, which now return `null` consistently on 204 or 200-with-empty-body responses. +- Deserialization is centralized in new `ReadJsonOrDefaultAsync` / `ReadJsonOrEmptyAsync` helpers so future endpoints inherit the guard automatically. + ## [1.2.0] - 2025-12-17 ### Added diff --git a/LicenseManagement.Client.csproj b/LicenseManagement.Client.csproj index 64c4e11..c8b5953 100644 --- a/LicenseManagement.Client.csproj +++ b/LicenseManagement.Client.csproj @@ -8,7 +8,7 @@ LicenseManagement.Client - 1.2.0 + 1.2.1 Hymma Hymma License Management Client SDK @@ -27,6 +27,14 @@ + + + + + + + + diff --git a/LicenseManagementClient.cs b/LicenseManagementClient.cs index 986039a..e9f4601 100644 --- a/LicenseManagementClient.cs +++ b/LicenseManagementClient.cs @@ -51,12 +51,8 @@ private void ConfigureHttpClient() public async Task GetLicenseAsync(string productId, string computerId, CancellationToken cancellationToken = default) { var response = await _httpClient.GetAsync($"license?product={Uri.EscapeDataString(productId)}&computer={Uri.EscapeDataString(computerId)}", cancellationToken); - - if (response.StatusCode == HttpStatusCode.NoContent) - return null; - await EnsureSuccessAsync(response); - return await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken); + return await ReadJsonOrDefaultAsync(response, cancellationToken); } /// @@ -91,12 +87,8 @@ public async Task UpdateLicenseAsync(UpdateLicenseRequest request, CancellationT public async Task GetReceiptAsync(string code, CancellationToken cancellationToken = default) { var response = await _httpClient.GetAsync($"receipt?code={Uri.EscapeDataString(code)}", cancellationToken); - - if (response.StatusCode == HttpStatusCode.NoContent) - return null; - await EnsureSuccessAsync(response); - return await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken); + return await ReadJsonOrDefaultAsync(response, cancellationToken); } /// @@ -184,12 +176,8 @@ await CreateReceiptAsync(new CreateReceiptRequest public async Task> GetReceiptsAsync(string buyerEmail, string productId, CancellationToken cancellationToken = default) { var response = await _httpClient.GetAsync($"receipt/all?buyerEmail={Uri.EscapeDataString(buyerEmail)}&product={Uri.EscapeDataString(productId)}", cancellationToken); - - if (response.StatusCode == HttpStatusCode.NoContent) - return Enumerable.Empty(); - await EnsureSuccessAsync(response); - return (await response.Content.ReadFromJsonAsync>(JsonOptions, cancellationToken)) ?? Enumerable.Empty(); + return await ReadJsonOrEmptyAsync(response, cancellationToken); } #endregion @@ -200,12 +188,8 @@ public async Task> GetReceiptsAsync(string buyerEmail, stri public async Task GetProductAsync(string productId, CancellationToken cancellationToken = default) { var response = await _httpClient.GetAsync($"product?product={Uri.EscapeDataString(productId)}", cancellationToken); - - if (response.StatusCode == HttpStatusCode.NoContent) - return null; - await EnsureSuccessAsync(response); - return await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken); + return await ReadJsonOrDefaultAsync(response, cancellationToken); } /// @@ -213,7 +197,7 @@ public async Task> GetProductsAsync(CancellationToken cance { var response = await _httpClient.GetAsync("product/all", cancellationToken); await EnsureSuccessAsync(response); - return (await response.Content.ReadFromJsonAsync>(JsonOptions, cancellationToken))!; + return await ReadJsonOrEmptyAsync(response, cancellationToken); } /// @@ -241,12 +225,8 @@ public async Task CreateProductAsync(CreateProductRequest request, Canc public async Task GetComputerAsync(string macAddress, CancellationToken cancellationToken = default) { var response = await _httpClient.GetAsync($"computer?macAddress={Uri.EscapeDataString(macAddress)}", cancellationToken); - - if (response.StatusCode == HttpStatusCode.NoContent) - return null; - await EnsureSuccessAsync(response); - return await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken); + return await ReadJsonOrDefaultAsync(response, cancellationToken); } /// @@ -271,7 +251,7 @@ public async Task> GetComputersAsync(string receiptCode, C { var response = await _httpClient.GetAsync($"computer/all?receiptCode={Uri.EscapeDataString(receiptCode)}", cancellationToken); await EnsureSuccessAsync(response); - return (await response.Content.ReadFromJsonAsync>(JsonOptions, cancellationToken))!; + return await ReadJsonOrEmptyAsync(response, cancellationToken); } #endregion @@ -299,7 +279,7 @@ public async Task> GetWebhooksAsync(CancellationToken cance { var response = await _httpClient.GetAsync("webhook", cancellationToken); await EnsureSuccessAsync(response); - return (await response.Content.ReadFromJsonAsync>(JsonOptions, cancellationToken))!; + return await ReadJsonOrEmptyAsync(response, cancellationToken); } /// @@ -311,7 +291,7 @@ public async Task> GetWebhooksAsync(CancellationToken cance return null; await EnsureSuccessAsync(response); - return await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken); + return await ReadJsonOrDefaultAsync(response, cancellationToken); } /// @@ -369,7 +349,7 @@ public async Task> GetWebhookDeliveriesAsync(string var response = await _httpClient.GetAsync(url, cancellationToken); await EnsureSuccessAsync(response); - return (await response.Content.ReadFromJsonAsync>(JsonOptions, cancellationToken))!; + return await ReadJsonOrEmptyAsync(response, cancellationToken); } /// @@ -430,6 +410,30 @@ public async Task TestWebhookAsync(string webhookId, CancellationToken cancellat #region Helpers + /// + /// Deserializes a JSON response body, returning default(T) when the response is 204 No Content + /// or has an empty body. Guards against System.Text.Json throwing on empty input. + /// + private static async Task ReadJsonOrDefaultAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + if (response.StatusCode == HttpStatusCode.NoContent || response.Content.Headers.ContentLength == 0) + return default; + + return await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken); + } + + /// + /// Deserializes a JSON array response body, returning an empty sequence when the response is + /// 204 No Content, has an empty body, or deserializes to null. + /// + private static async Task> ReadJsonOrEmptyAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + if (response.StatusCode == HttpStatusCode.NoContent || response.Content.Headers.ContentLength == 0) + return Enumerable.Empty(); + + return (await response.Content.ReadFromJsonAsync>(JsonOptions, cancellationToken)) ?? Enumerable.Empty(); + } + /// /// Sends a PATCH request with JSON body (cross-platform compatible). /// diff --git a/Tests/LicenseManagement.Client.Tests/EmptyResponseTests.cs b/Tests/LicenseManagement.Client.Tests/EmptyResponseTests.cs new file mode 100644 index 0000000..c14d479 --- /dev/null +++ b/Tests/LicenseManagement.Client.Tests/EmptyResponseTests.cs @@ -0,0 +1,177 @@ +using System.Net; +using System.Text; +using LicenseManagement.Client; +using LicenseManagement.Client.Models; +using Microsoft.Extensions.Options; + +namespace LicenseManagement.Client.Tests; + +/// +/// Regression tests for the bug where Get* collection methods threw +/// System.Text.Json.JsonException on HTTP 204 No Content or 200 with empty body. +/// +public class EmptyResponseTests +{ + private static LicenseManagementClient CreateClient(Func responder) + { + var handler = new StubHandler(responder); + var httpClient = new HttpClient(handler); + var options = Options.Create(new LicenseManagementClientOptions + { + BaseUrl = "https://example.test", + ApiKey = "test-key", + TimeoutSeconds = 30 + }); + return new LicenseManagementClient(httpClient, options); + } + + private static HttpResponseMessage NoContent() => new(HttpStatusCode.NoContent); + + private static HttpResponseMessage OkEmpty() + { + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(string.Empty, Encoding.UTF8, "application/json") + }; + return response; + } + + [Fact] + public async Task GetComputersAsync_Returns_Empty_On_204_NoContent() + { + var client = CreateClient(_ => NoContent()); + + var result = await client.GetComputersAsync("RCP_123"); + + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public async Task GetComputersAsync_Returns_Empty_On_200_With_Empty_Body() + { + var client = CreateClient(_ => OkEmpty()); + + var result = await client.GetComputersAsync("RCP_123"); + + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public async Task GetComputersAsync_Returns_Items_On_200_With_Json_Array() + { + var json = "[{\"id\":\"PC_1\",\"macAddress\":\"AA:BB:CC:DD:EE:FF\",\"name\":\"Dev box\"}]"; + var client = CreateClient(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }); + + var result = (await client.GetComputersAsync("RCP_123")).ToList(); + + Assert.Single(result); + Assert.Equal("PC_1", result[0].Id); + } + + [Fact] + public async Task GetProductsAsync_Returns_Empty_On_204_NoContent() + { + var client = CreateClient(_ => NoContent()); + + var result = await client.GetProductsAsync(); + + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public async Task GetProductsAsync_Returns_Empty_On_200_With_Empty_Body() + { + var client = CreateClient(_ => OkEmpty()); + + var result = await client.GetProductsAsync(); + + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public async Task GetReceiptsAsync_Returns_Empty_On_204_NoContent() + { + var client = CreateClient(_ => NoContent()); + + var result = await client.GetReceiptsAsync("buyer@example.com", "PRD_1"); + + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public async Task GetReceiptsAsync_Returns_Empty_On_200_With_Empty_Body() + { + var client = CreateClient(_ => OkEmpty()); + + var result = await client.GetReceiptsAsync("buyer@example.com", "PRD_1"); + + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public async Task GetWebhooksAsync_Returns_Empty_On_204_NoContent() + { + var client = CreateClient(_ => NoContent()); + + var result = await client.GetWebhooksAsync(); + + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public async Task GetWebhooksAsync_Returns_Empty_On_200_With_Empty_Body() + { + var client = CreateClient(_ => OkEmpty()); + + var result = await client.GetWebhooksAsync(); + + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public async Task GetWebhookDeliveriesAsync_Returns_Empty_On_204_NoContent() + { + var client = CreateClient(_ => NoContent()); + + var result = await client.GetWebhookDeliveriesAsync("WH_1"); + + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public async Task GetComputerAsync_Returns_Null_On_200_With_Empty_Body() + { + var client = CreateClient(_ => OkEmpty()); + + var result = await client.GetComputerAsync("AA:BB:CC:DD:EE:FF"); + + Assert.Null(result); + } + + private sealed class StubHandler : HttpMessageHandler + { + private readonly Func _responder; + + public StubHandler(Func responder) + { + _responder = responder; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return Task.FromResult(_responder(request)); + } + } +} diff --git a/Tests/LicenseManagement.Client.Tests/LicenseManagement.Client.Tests.csproj b/Tests/LicenseManagement.Client.Tests/LicenseManagement.Client.Tests.csproj new file mode 100644 index 0000000..2df8556 --- /dev/null +++ b/Tests/LicenseManagement.Client.Tests/LicenseManagement.Client.Tests.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + \ No newline at end of file