diff --git a/NEW/src/JdeScoping.Client/Services/ApiClientBase.cs b/NEW/src/JdeScoping.Client/Services/ApiClientBase.cs new file mode 100644 index 0000000..ca1eba7 --- /dev/null +++ b/NEW/src/JdeScoping.Client/Services/ApiClientBase.cs @@ -0,0 +1,166 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using JdeScoping.Core.ApiContracts.Results; + +namespace JdeScoping.Client.Services; + +/// +/// Base class for API clients with shared HTTP execution logic. +/// +public abstract class ApiClientBase +{ + protected readonly HttpClient HttpClient; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + protected ApiClientBase(HttpClient httpClient) + { + HttpClient = httpClient; + } + + protected async Task> GetAsync(string route, CancellationToken ct = default) + { + return await ExecuteAsync(() => HttpClient.GetAsync(route, ct)); + } + + protected async Task> PostAsync(string route, TBody body, CancellationToken ct = default) + { + return await ExecuteAsync(() => HttpClient.PostAsJsonAsync(route, body, ct)); + } + + protected async Task> PostAsync(string route, CancellationToken ct = default) + { + return await ExecuteAsync(() => HttpClient.PostAsync(route, null, ct)); + } + + protected async Task> GetBytesAsync(string route, CancellationToken ct = default) + { + try + { + var response = await HttpClient.GetAsync(route, ct); + return await MapResponseToBytesAsync(response); + } + catch (Exception ex) + { + return new ApiError(ex.Message); + } + } + + protected async Task> PostForBytesAsync(string route, TBody body, CancellationToken ct = default) + { + try + { + var response = await HttpClient.PostAsJsonAsync(route, body, ct); + return await MapResponseToBytesAsync(response); + } + catch (Exception ex) + { + return new ApiError(ex.Message); + } + } + + protected async Task> PostMultipartAsync( + string route, + Stream fileStream, + string fileName, + CancellationToken ct = default) + { + try + { + using var content = new MultipartFormDataContent(); + using var streamContent = new StreamContent(fileStream); + content.Add(streamContent, "file", fileName); + + var response = await HttpClient.PostAsync(route, content, ct); + return await MapResponseAsync(response); + } + catch (Exception ex) + { + return new ApiError(ex.Message); + } + } + + private async Task> ExecuteAsync(Func> request) + { + try + { + var response = await request(); + return await MapResponseAsync(response); + } + catch (Exception ex) + { + return new ApiError(ex.Message); + } + } + + private async Task> MapResponseAsync(HttpResponseMessage response) + { + return response.StatusCode switch + { + HttpStatusCode.OK or HttpStatusCode.Created => + await response.Content.ReadFromJsonAsync(JsonOptions) + is T value ? value : new ApiError("Invalid response format"), + + HttpStatusCode.NoContent => + typeof(T) == typeof(Unit) + ? (ApiResult)(object)(ApiResult)new Unit() + : new ApiError("Unexpected empty response"), + + HttpStatusCode.NotFound => new NotFound(), + HttpStatusCode.Unauthorized => new Unauthorized(), + HttpStatusCode.Forbidden => new Forbidden(), + + HttpStatusCode.BadRequest => await ParseValidationErrorAsync(response), + + _ => new ApiError( + await response.Content.ReadAsStringAsync(), + (int)response.StatusCode) + }; + } + + private async Task> MapResponseToBytesAsync(HttpResponseMessage response) + { + return response.StatusCode switch + { + HttpStatusCode.OK => await response.Content.ReadAsByteArrayAsync(), + HttpStatusCode.NotFound => new NotFound(), + HttpStatusCode.Unauthorized => new Unauthorized(), + HttpStatusCode.Forbidden => new Forbidden(), + _ => new ApiError( + await response.Content.ReadAsStringAsync(), + (int)response.StatusCode) + }; + } + + private static async Task> ParseValidationErrorAsync(HttpResponseMessage response) + { + try + { + var content = await response.Content.ReadAsStringAsync(); + var problemDetails = JsonSerializer.Deserialize(content, JsonOptions); + + if (problemDetails?.Errors is { } errors) + { + return new ValidationError(errors); + } + + return new ApiError(content, (int)response.StatusCode); + } + catch + { + return new ApiError("Validation error", (int)response.StatusCode); + } + } + + /// + /// Matches ASP.NET Core ValidationProblemDetails structure. + /// + private sealed class ValidationProblemDetails + { + public Dictionary? Errors { get; set; } + } +} diff --git a/NEW/tests/JdeScoping.Client.Tests/Services/ApiClientBaseTests.cs b/NEW/tests/JdeScoping.Client.Tests/Services/ApiClientBaseTests.cs index de45b2d..efc5f98 100644 --- a/NEW/tests/JdeScoping.Client.Tests/Services/ApiClientBaseTests.cs +++ b/NEW/tests/JdeScoping.Client.Tests/Services/ApiClientBaseTests.cs @@ -117,4 +117,106 @@ public class ApiClientBaseTests result.IsError.ShouldBeTrue(); result.Error.StatusCode.ShouldBe(500); } + + // Edge cases + + [Fact] + public async Task GetAsync_Returns200_EmptyBody_MapsToApiError() + { + // Arrange + _mockHttp.When("/api/test") + .Respond(HttpStatusCode.OK, "application/json", ""); + + // Act + var result = await _client.GetAsync("/api/test"); + + // Assert + result.IsError.ShouldBeTrue(); + } + + [Fact] + public async Task GetAsync_Returns200_InvalidJson_MapsToApiError() + { + // Arrange + _mockHttp.When("/api/test") + .Respond(HttpStatusCode.OK, "application/json", "not valid json {{{"); + + // Act + var result = await _client.GetAsync("/api/test"); + + // Assert + result.IsError.ShouldBeTrue(); + } + + [Fact] + public async Task GetAsync_Returns204_ForUnitType_MapsToSuccess() + { + // Arrange + _mockHttp.When("/api/test") + .Respond(HttpStatusCode.NoContent); + + // Act + var result = await _client.GetAsync("/api/test"); + + // Assert + result.IsSuccess.ShouldBeTrue(); + } + + [Fact] + public async Task GetAsync_Returns204_ForNonUnitType_MapsToApiError() + { + // Arrange + _mockHttp.When("/api/test") + .Respond(HttpStatusCode.NoContent); + + // Act + var result = await _client.GetAsync("/api/test"); + + // Assert + result.IsError.ShouldBeTrue(); + } + + [Fact] + public async Task GetAsync_NetworkException_MapsToApiError() + { + // Arrange + _mockHttp.When("/api/test") + .Throw(new HttpRequestException("Network failure")); + + // Act + var result = await _client.GetAsync("/api/test"); + + // Assert + result.IsError.ShouldBeTrue(); + result.Error.Message.ShouldContain("Network failure"); + } + + [Fact] + public async Task GetAsync_Timeout_MapsToApiError() + { + // Arrange + _mockHttp.When("/api/test") + .Throw(new TaskCanceledException("Request timeout")); + + // Act + var result = await _client.GetAsync("/api/test"); + + // Assert + result.IsError.ShouldBeTrue(); + } + + [Fact] + public async Task GetAsync_Returns400_WithoutValidationFormat_MapsToApiError() + { + // Arrange - Bad request without standard validation problem format + _mockHttp.When("/api/test") + .Respond(HttpStatusCode.BadRequest, "text/plain", "Bad request"); + + // Act + var result = await _client.GetAsync("/api/test"); + + // Assert + result.IsError.ShouldBeTrue(); + result.Error.StatusCode.ShouldBe(400); + } }