From 49072cddd178fca456197c8495e4d0b489e9c05a Mon Sep 17 00:00:00 2001 From: hamed <31099988+H-Ashrafi@users.noreply.github.com> Date: Wed, 13 May 2026 15:28:48 +1000 Subject: [PATCH] fix: return empty/null on 204 No Content and empty response bodies Get* methods that returned collections (GetComputersAsync, GetProductsAsync, GetWebhooksAsync, GetWebhookDeliveriesAsync) called ReadFromJsonAsync unconditionally and threw System.Text.Json.JsonException ("The input does not contain any JSON tokens") when the server returned HTTP 204 No Content or HTTP 200 with an empty body. The /api/computer/all endpoint returns 204 for receipts with zero attached computers, which crashed consumers that called GetComputersAsync. Centralize the guard in two helpers (ReadJsonOrDefaultAsync, ReadJsonOrEmptyAsync) and route every affected Get* method through them so future endpoints inherit the fix. Add an xUnit test project (LicenseManagement.Client.Tests) covering 204 and empty-body cases for the collection-returning Get* methods and the single-item GetComputerAsync. Bumps package version to 1.2.1 (patch). Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 8 + LicenseManagement.Client.csproj | 10 +- LicenseManagementClient.cs | 64 ++++--- .../EmptyResponseTests.cs | 177 ++++++++++++++++++ .../LicenseManagement.Client.Tests.csproj | 25 +++ 5 files changed, 253 insertions(+), 31 deletions(-) create mode 100644 Tests/LicenseManagement.Client.Tests/EmptyResponseTests.cs create mode 100644 Tests/LicenseManagement.Client.Tests/LicenseManagement.Client.Tests.csproj 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