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);
+ }
}