fix(data-access): correct self-referential SQL in WorkCenter filter
The WHERE clause was comparing Code to itself instead of the aliased table reference, which would always be true.
This commit is contained in:
@@ -0,0 +1,781 @@
|
||||
# API Client Contracts Design
|
||||
|
||||
**Date:** 2026-01-06
|
||||
**Status:** Approved (Revised after Codex review)
|
||||
|
||||
## Purpose
|
||||
|
||||
Define shared API contracts in `JdeScoping.Core` that ensure compile-time safety for:
|
||||
- URL routes
|
||||
- Request parameters
|
||||
- Return types
|
||||
|
||||
## Design Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|----------|--------|-----------|
|
||||
| Contract location | Core project | Both Api and Client already reference Core |
|
||||
| Route handling | `ApiRoutes` static class with constants | Usable in `[HttpGet]` attributes AND client code |
|
||||
| Result type | `OneOf<T, NotFound, ValidationError, Unauthorized, Forbidden, ApiError>` | Explicit error handling, preserves context |
|
||||
| Controller returns | `ActionResult<T>` with `ApiResult` mapper | Proper HTTP status codes |
|
||||
| Client returns | `ApiResult<T>` | Type-safe discriminated union |
|
||||
| Error types | Mix (empty markers + detailed types) | Minimum info needed per case |
|
||||
| CancellationToken | Optional with default (`ct = default`) | Clean call sites, Blazor-friendly |
|
||||
| File contracts | Separate server/client interfaces | `IFormFile` vs `Stream` incompatibility |
|
||||
|
||||
## Project Structure
|
||||
|
||||
### New Files in Core
|
||||
|
||||
```
|
||||
JdeScoping.Core/
|
||||
├── ApiContracts/
|
||||
│ ├── ApiRoutes.cs # Shared route constants
|
||||
│ ├── ISearchApiClient.cs # Client contract
|
||||
│ ├── ILookupApiClient.cs
|
||||
│ ├── IAuthApiClient.cs
|
||||
│ └── IFileApiClient.cs
|
||||
├── ApiContracts/Results/
|
||||
│ ├── ApiResult.cs
|
||||
│ ├── NotFound.cs
|
||||
│ ├── Unauthorized.cs
|
||||
│ ├── Forbidden.cs
|
||||
│ ├── ValidationError.cs
|
||||
│ ├── ApiError.cs
|
||||
│ └── Unit.cs
|
||||
```
|
||||
|
||||
### New Dependency in Core
|
||||
|
||||
```xml
|
||||
<PackageReference Include="OneOf" Version="3.0.271" />
|
||||
```
|
||||
|
||||
## Route Constants
|
||||
|
||||
### ApiRoutes.cs
|
||||
|
||||
Using constants allows usage in both `[HttpGet]` attributes and client code:
|
||||
|
||||
```csharp
|
||||
namespace JdeScoping.Core.ApiContracts;
|
||||
|
||||
/// <summary>
|
||||
/// Shared API route constants. Use in controller attributes and client implementations.
|
||||
/// </summary>
|
||||
public static class ApiRoutes
|
||||
{
|
||||
public static class Search
|
||||
{
|
||||
public const string Base = "api/search";
|
||||
public const string Queue = "api/search/queue";
|
||||
public const string ById = "api/search/{id:int}";
|
||||
public const string Copy = "api/search/{id:int}/copy";
|
||||
public const string Results = "api/search/{id:int}/results";
|
||||
|
||||
// Client route builders (handle parameter substitution)
|
||||
public static string GetById(int id) => $"api/search/{id}";
|
||||
public static string GetCopy(int id) => $"api/search/{id}/copy";
|
||||
public static string GetResults(int id) => $"api/search/{id}/results";
|
||||
}
|
||||
|
||||
public static class Lookup
|
||||
{
|
||||
public const string Items = "api/lookup/items";
|
||||
public const string ProfitCenters = "api/lookup/profit-centers";
|
||||
public const string WorkCenters = "api/lookup/work-centers";
|
||||
public const string Operators = "api/lookup/operators";
|
||||
|
||||
// Client route builders (handle URL encoding)
|
||||
public static string FindItems(string query) => $"{Items}?q={Uri.EscapeDataString(query)}";
|
||||
public static string FindProfitCenters(string query) => $"{ProfitCenters}?q={Uri.EscapeDataString(query)}";
|
||||
public static string FindWorkCenters(string query) => $"{WorkCenters}?q={Uri.EscapeDataString(query)}";
|
||||
public static string FindOperators(string query) => $"{Operators}?q={Uri.EscapeDataString(query)}";
|
||||
}
|
||||
|
||||
public static class Auth
|
||||
{
|
||||
public const string Base = "api/auth";
|
||||
public const string PublicKey = "api/auth/public-key";
|
||||
public const string Login = "api/auth/login";
|
||||
public const string Logout = "api/auth/logout";
|
||||
public const string Me = "api/auth/me";
|
||||
}
|
||||
|
||||
public static class FileIO
|
||||
{
|
||||
public const string Base = "api/fileio";
|
||||
|
||||
// Downloads
|
||||
public const string DownloadWorkOrders = "api/fileio/workorders/download";
|
||||
public const string DownloadItems = "api/fileio/items/download";
|
||||
public const string DownloadComponentLots = "api/fileio/componentlots/download";
|
||||
public const string DownloadPartOperations = "api/fileio/partoperations/download";
|
||||
|
||||
// Uploads
|
||||
public const string UploadWorkOrders = "api/fileio/workorders/upload";
|
||||
public const string UploadItems = "api/fileio/items/upload";
|
||||
public const string UploadComponentLots = "api/fileio/componentlots/upload";
|
||||
public const string UploadPartOperations = "api/fileio/partoperations/upload";
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Result Types
|
||||
|
||||
### ApiResult.cs
|
||||
|
||||
```csharp
|
||||
using OneOf;
|
||||
|
||||
namespace JdeScoping.Core.ApiContracts.Results;
|
||||
|
||||
/// <summary>
|
||||
/// Standard API result type for client-side operations.
|
||||
/// </summary>
|
||||
[GenerateOneOf]
|
||||
public partial class ApiResult<T> : OneOfBase<T, NotFound, ValidationError, Unauthorized, Forbidden, ApiError>
|
||||
{
|
||||
public bool IsSuccess => IsT0;
|
||||
public bool IsNotFound => IsT1;
|
||||
public bool IsValidationError => IsT2;
|
||||
public bool IsUnauthorized => IsT3;
|
||||
public bool IsForbidden => IsT4;
|
||||
public bool IsError => IsT5;
|
||||
|
||||
public T Value => AsT0;
|
||||
public ValidationError ValidationError => AsT2;
|
||||
public ApiError Error => AsT5;
|
||||
}
|
||||
```
|
||||
|
||||
### Error Types
|
||||
|
||||
```csharp
|
||||
namespace JdeScoping.Core.ApiContracts.Results;
|
||||
|
||||
/// <summary>Resource not found (404).</summary>
|
||||
public readonly record struct NotFound;
|
||||
|
||||
/// <summary>Authentication required (401).</summary>
|
||||
public readonly record struct Unauthorized;
|
||||
|
||||
/// <summary>Access denied (403).</summary>
|
||||
public readonly record struct Forbidden;
|
||||
|
||||
/// <summary>
|
||||
/// Validation failed (400) with field-level errors.
|
||||
/// Maps to ASP.NET Core ProblemDetails format.
|
||||
/// </summary>
|
||||
public readonly record struct ValidationError(IReadOnlyDictionary<string, string[]> FieldErrors)
|
||||
{
|
||||
public static ValidationError FromProblemDetails(Dictionary<string, string[]> errors)
|
||||
=> new(errors);
|
||||
}
|
||||
|
||||
/// <summary>General API error.</summary>
|
||||
public readonly record struct ApiError(string Message, int? StatusCode = null);
|
||||
|
||||
/// <summary>Empty success type for void operations.</summary>
|
||||
public readonly record struct Unit;
|
||||
```
|
||||
|
||||
## Client Interface Definitions
|
||||
|
||||
Client interfaces define the contract for HTTP client implementations. They return `ApiResult<T>`.
|
||||
|
||||
### ISearchApiClient.cs
|
||||
|
||||
```csharp
|
||||
using JdeScoping.Core.ApiContracts.Results;
|
||||
using JdeScoping.Core.ViewModels;
|
||||
|
||||
namespace JdeScoping.Core.ApiContracts;
|
||||
|
||||
/// <summary>
|
||||
/// Client contract for search API operations.
|
||||
/// </summary>
|
||||
public interface ISearchApiClient
|
||||
{
|
||||
Task<ApiResult<IReadOnlyList<SearchViewModel>>> GetUserSearchesAsync(CancellationToken ct = default);
|
||||
Task<ApiResult<IReadOnlyList<SearchViewModel>>> GetQueuedSearchesAsync(CancellationToken ct = default);
|
||||
Task<ApiResult<SearchViewModel>> GetSearchAsync(int id, CancellationToken ct = default);
|
||||
Task<ApiResult<SearchViewModel>> CopySearchAsync(int id, CancellationToken ct = default);
|
||||
Task<ApiResult<int>> CreateSearchAsync(SearchViewModel search, CancellationToken ct = default);
|
||||
Task<ApiResult<byte[]>> GetResultsAsync(int id, CancellationToken ct = default);
|
||||
}
|
||||
```
|
||||
|
||||
### ILookupApiClient.cs
|
||||
|
||||
```csharp
|
||||
using JdeScoping.Core.ApiContracts.Results;
|
||||
using JdeScoping.Core.ViewModels;
|
||||
|
||||
namespace JdeScoping.Core.ApiContracts;
|
||||
|
||||
/// <summary>
|
||||
/// Client contract for lookup/autocomplete API operations.
|
||||
/// </summary>
|
||||
public interface ILookupApiClient
|
||||
{
|
||||
Task<ApiResult<IReadOnlyList<ItemViewModel>>> FindItemsAsync(string query, CancellationToken ct = default);
|
||||
Task<ApiResult<IReadOnlyList<ProfitCenterViewModel>>> FindProfitCentersAsync(string query, CancellationToken ct = default);
|
||||
Task<ApiResult<IReadOnlyList<WorkCenterViewModel>>> FindWorkCentersAsync(string query, CancellationToken ct = default);
|
||||
Task<ApiResult<IReadOnlyList<JdeUserViewModel>>> FindOperatorsAsync(string query, CancellationToken ct = default);
|
||||
}
|
||||
```
|
||||
|
||||
### IAuthApiClient.cs
|
||||
|
||||
```csharp
|
||||
using JdeScoping.Core.ApiContracts.Results;
|
||||
using JdeScoping.Core.Models;
|
||||
using JdeScoping.Core.Models.Auth;
|
||||
|
||||
namespace JdeScoping.Core.ApiContracts;
|
||||
|
||||
/// <summary>
|
||||
/// Client contract for authentication API operations.
|
||||
/// </summary>
|
||||
public interface IAuthApiClient
|
||||
{
|
||||
Task<ApiResult<PublicKeyResponse>> GetPublicKeyAsync(CancellationToken ct = default);
|
||||
Task<ApiResult<LoginResultModel>> LoginAsync(EncryptedLoginRequest request, CancellationToken ct = default);
|
||||
Task<ApiResult<Unit>> LogoutAsync(CancellationToken ct = default);
|
||||
Task<ApiResult<UserInfo>> GetCurrentUserAsync(CancellationToken ct = default);
|
||||
}
|
||||
```
|
||||
|
||||
### IFileApiClient.cs
|
||||
|
||||
```csharp
|
||||
using JdeScoping.Core.ApiContracts.Results;
|
||||
using JdeScoping.Core.ViewModels;
|
||||
|
||||
namespace JdeScoping.Core.ApiContracts;
|
||||
|
||||
/// <summary>
|
||||
/// Client contract for file upload/download API operations.
|
||||
/// Note: Uses Stream for client-side; controllers use IFormFile.
|
||||
/// </summary>
|
||||
public interface IFileApiClient
|
||||
{
|
||||
// Downloads (POST with existing data, returns Excel bytes)
|
||||
Task<ApiResult<byte[]>> DownloadWorkOrdersTemplateAsync(IReadOnlyList<WorkOrderViewModel>? existingData = null, CancellationToken ct = default);
|
||||
Task<ApiResult<byte[]>> DownloadItemsTemplateAsync(IReadOnlyList<ItemViewModel>? existingData = null, CancellationToken ct = default);
|
||||
Task<ApiResult<byte[]>> DownloadComponentLotsTemplateAsync(IReadOnlyList<LotViewModel>? existingData = null, CancellationToken ct = default);
|
||||
Task<ApiResult<byte[]>> DownloadPartOperationsTemplateAsync(IReadOnlyList<PartOperationViewModel>? existingData = null, CancellationToken ct = default);
|
||||
|
||||
// Uploads (multipart form, returns parsed data)
|
||||
Task<ApiResult<IReadOnlyList<WorkOrderViewModel>>> UploadWorkOrdersAsync(Stream fileStream, string fileName, CancellationToken ct = default);
|
||||
Task<ApiResult<IReadOnlyList<ItemViewModel>>> UploadItemsAsync(Stream fileStream, string fileName, CancellationToken ct = default);
|
||||
Task<ApiResult<IReadOnlyList<LotViewModel>>> UploadComponentLotsAsync(Stream fileStream, string fileName, CancellationToken ct = default);
|
||||
Task<ApiResult<IReadOnlyList<PartOperationViewModel>>> UploadPartOperationsAsync(Stream fileStream, string fileName, CancellationToken ct = default);
|
||||
}
|
||||
```
|
||||
|
||||
## Controller Implementation
|
||||
|
||||
Controllers use `ApiRoutes` constants in attributes and return `ActionResult<T>`. A helper extension converts `ApiResult` to proper HTTP responses.
|
||||
|
||||
### ApiResultExtensions.cs (in Api project)
|
||||
|
||||
```csharp
|
||||
using JdeScoping.Core.ApiContracts.Results;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace JdeScoping.Api.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Converts ApiResult to ActionResult with proper HTTP status codes.
|
||||
/// </summary>
|
||||
public static class ApiResultExtensions
|
||||
{
|
||||
public static ActionResult<T> ToActionResult<T>(this ApiResult<T> result)
|
||||
{
|
||||
return result.Match<ActionResult<T>>(
|
||||
success => new OkObjectResult(success),
|
||||
notFound => new NotFoundResult(),
|
||||
validation => new BadRequestObjectResult(new ValidationProblemDetails(
|
||||
validation.FieldErrors.ToDictionary(k => k.Key, v => v.Value))),
|
||||
unauthorized => new UnauthorizedResult(),
|
||||
forbidden => new ForbidResult(),
|
||||
error => new ObjectResult(new ProblemDetails
|
||||
{
|
||||
Status = error.StatusCode ?? 500,
|
||||
Detail = error.Message
|
||||
}) { StatusCode = error.StatusCode ?? 500 }
|
||||
);
|
||||
}
|
||||
|
||||
public static ActionResult<T> ToCreatedResult<T>(this ApiResult<T> result, string actionName, Func<T, object> routeValues)
|
||||
{
|
||||
return result.Match<ActionResult<T>>(
|
||||
success => new CreatedAtActionResult(actionName, null, routeValues(success), success),
|
||||
notFound => new NotFoundResult(),
|
||||
validation => new BadRequestObjectResult(new ValidationProblemDetails(
|
||||
validation.FieldErrors.ToDictionary(k => k.Key, v => v.Value))),
|
||||
unauthorized => new UnauthorizedResult(),
|
||||
forbidden => new ForbidResult(),
|
||||
error => new ObjectResult(new ProblemDetails
|
||||
{
|
||||
Status = error.StatusCode ?? 500,
|
||||
Detail = error.Message
|
||||
}) { StatusCode = error.StatusCode ?? 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### SearchController.cs
|
||||
|
||||
```csharp
|
||||
using JdeScoping.Api.Extensions;
|
||||
using JdeScoping.Core.ApiContracts;
|
||||
using JdeScoping.Core.ApiContracts.Results;
|
||||
using JdeScoping.Core.Interfaces;
|
||||
using JdeScoping.Core.ViewModels;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace JdeScoping.Api.Controllers;
|
||||
|
||||
[Route(ApiRoutes.Search.Base)]
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
public class SearchController : ApiControllerBase
|
||||
{
|
||||
private readonly ILotFinderRepository _repository;
|
||||
|
||||
public SearchController(ILotFinderRepository repository)
|
||||
{
|
||||
_repository = repository;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(IEnumerable<SearchViewModel>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<IReadOnlyList<SearchViewModel>>> GetSearches(CancellationToken ct)
|
||||
{
|
||||
var searches = await _repository.GetUserSearchesAsync(CurrentUserName!, ct);
|
||||
var viewModels = searches
|
||||
.OrderByDescending(s => s.StartDt)
|
||||
.Select(s => new SearchViewModel(s))
|
||||
.ToList();
|
||||
|
||||
return Ok(viewModels);
|
||||
}
|
||||
|
||||
[HttpGet("queue")]
|
||||
[ProducesResponseType(typeof(IEnumerable<SearchViewModel>), StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<IReadOnlyList<SearchViewModel>>> GetQueuedSearches(CancellationToken ct)
|
||||
{
|
||||
var searches = await _repository.GetQueuedSearchesAsync(ct);
|
||||
var viewModels = searches.Select(s => new SearchViewModel(s)).ToList();
|
||||
return Ok(viewModels);
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}")]
|
||||
[ProducesResponseType(typeof(SearchViewModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<SearchViewModel>> GetSearch(int id, CancellationToken ct)
|
||||
{
|
||||
var search = await _repository.GetSearchAsync(id, ct);
|
||||
if (search is null)
|
||||
return NotFound();
|
||||
|
||||
return Ok(new SearchViewModel(search));
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}/copy")]
|
||||
[ProducesResponseType(typeof(SearchViewModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<SearchViewModel>> CopySearch(int id, CancellationToken ct)
|
||||
{
|
||||
var original = await _repository.GetSearchAsync(id, ct);
|
||||
if (original is null)
|
||||
return NotFound();
|
||||
|
||||
var copy = new Search
|
||||
{
|
||||
Id = 0,
|
||||
UserName = CurrentUserName!,
|
||||
Name = original.Name,
|
||||
Status = SearchStatus.New,
|
||||
CriteriaJson = original.CriteriaJson
|
||||
};
|
||||
|
||||
return Ok(new SearchViewModel(copy));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(int), StatusCodes.Status201Created)]
|
||||
public async Task<ActionResult<int>> CreateSearch(
|
||||
[FromBody] SearchViewModel viewModel,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var search = viewModel.ToEntity();
|
||||
search.UserName = CurrentUserName!;
|
||||
|
||||
var searchId = await _repository.SubmitSearchAsync(search, ct);
|
||||
|
||||
return CreatedAtAction(nameof(GetSearch), new { id = searchId }, searchId);
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}/results")]
|
||||
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetResults(int id, CancellationToken ct)
|
||||
{
|
||||
var data = await _repository.GetSearchResultsAsync(id, ct);
|
||||
if (data is null || data.Length == 0)
|
||||
return NotFound();
|
||||
|
||||
return File(
|
||||
data,
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"search_results.xlsx");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Client Implementation
|
||||
|
||||
### ApiClientBase.cs
|
||||
|
||||
Shared HTTP execution logic with status code mapping:
|
||||
|
||||
```csharp
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using JdeScoping.Core.ApiContracts.Results;
|
||||
|
||||
namespace JdeScoping.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for API clients with shared HTTP execution logic.
|
||||
/// </summary>
|
||||
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<ApiResult<T>> GetAsync<T>(string route, CancellationToken ct = default)
|
||||
{
|
||||
return await ExecuteAsync<T>(() => HttpClient.GetAsync(route, ct));
|
||||
}
|
||||
|
||||
protected async Task<ApiResult<T>> PostAsync<T, TBody>(string route, TBody body, CancellationToken ct = default)
|
||||
{
|
||||
return await ExecuteAsync<T>(() => HttpClient.PostAsJsonAsync(route, body, ct));
|
||||
}
|
||||
|
||||
protected async Task<ApiResult<T>> PostAsync<T>(string route, CancellationToken ct = default)
|
||||
{
|
||||
return await ExecuteAsync<T>(() => HttpClient.PostAsync(route, null, ct));
|
||||
}
|
||||
|
||||
protected async Task<ApiResult<byte[]>> 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<ApiResult<T>> PostMultipartAsync<T>(
|
||||
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<T>(response);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ApiError(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ApiResult<T>> ExecuteAsync<T>(Func<Task<HttpResponseMessage>> request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await request();
|
||||
return await MapResponseAsync<T>(response);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ApiError(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ApiResult<T>> MapResponseAsync<T>(HttpResponseMessage response)
|
||||
{
|
||||
return response.StatusCode switch
|
||||
{
|
||||
HttpStatusCode.OK or HttpStatusCode.Created =>
|
||||
await response.Content.ReadFromJsonAsync<T>(JsonOptions)
|
||||
is T value ? value : new ApiError("Invalid response format"),
|
||||
|
||||
HttpStatusCode.NoContent =>
|
||||
typeof(T) == typeof(Unit) ? (ApiResult<T>)(object)new Unit() : new ApiError("Unexpected empty response"),
|
||||
|
||||
HttpStatusCode.NotFound => new NotFound(),
|
||||
HttpStatusCode.Unauthorized => new Unauthorized(),
|
||||
HttpStatusCode.Forbidden => new Forbidden(),
|
||||
|
||||
HttpStatusCode.BadRequest => await ParseValidationErrorAsync<T>(response),
|
||||
|
||||
_ => new ApiError(
|
||||
await response.Content.ReadAsStringAsync(),
|
||||
(int)response.StatusCode)
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<ApiResult<byte[]>> 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<ApiResult<T>> ParseValidationErrorAsync<T>(HttpResponseMessage response)
|
||||
{
|
||||
try
|
||||
{
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
var problemDetails = JsonSerializer.Deserialize<ValidationProblemDetails>(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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Matches ASP.NET Core ValidationProblemDetails structure.
|
||||
/// </summary>
|
||||
private sealed class ValidationProblemDetails
|
||||
{
|
||||
public Dictionary<string, string[]>? Errors { get; set; }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### SearchApiClient.cs
|
||||
|
||||
```csharp
|
||||
using JdeScoping.Core.ApiContracts;
|
||||
using JdeScoping.Core.ApiContracts.Results;
|
||||
using JdeScoping.Core.ViewModels;
|
||||
|
||||
namespace JdeScoping.Client.Services;
|
||||
|
||||
public class SearchApiClient : ApiClientBase, ISearchApiClient
|
||||
{
|
||||
public SearchApiClient(HttpClient httpClient) : base(httpClient) { }
|
||||
|
||||
public Task<ApiResult<IReadOnlyList<SearchViewModel>>> GetUserSearchesAsync(CancellationToken ct = default)
|
||||
=> GetAsync<IReadOnlyList<SearchViewModel>>(ApiRoutes.Search.Base, ct);
|
||||
|
||||
public Task<ApiResult<IReadOnlyList<SearchViewModel>>> GetQueuedSearchesAsync(CancellationToken ct = default)
|
||||
=> GetAsync<IReadOnlyList<SearchViewModel>>(ApiRoutes.Search.Queue, ct);
|
||||
|
||||
public Task<ApiResult<SearchViewModel>> GetSearchAsync(int id, CancellationToken ct = default)
|
||||
=> GetAsync<SearchViewModel>(ApiRoutes.Search.GetById(id), ct);
|
||||
|
||||
public Task<ApiResult<SearchViewModel>> CopySearchAsync(int id, CancellationToken ct = default)
|
||||
=> GetAsync<SearchViewModel>(ApiRoutes.Search.GetCopy(id), ct);
|
||||
|
||||
public Task<ApiResult<int>> CreateSearchAsync(SearchViewModel search, CancellationToken ct = default)
|
||||
=> PostAsync<int, SearchViewModel>(ApiRoutes.Search.Base, search, ct);
|
||||
|
||||
public Task<ApiResult<byte[]>> GetResultsAsync(int id, CancellationToken ct = default)
|
||||
=> GetBytesAsync(ApiRoutes.Search.GetResults(id), ct);
|
||||
}
|
||||
```
|
||||
|
||||
### LookupApiClient.cs
|
||||
|
||||
```csharp
|
||||
using JdeScoping.Core.ApiContracts;
|
||||
using JdeScoping.Core.ApiContracts.Results;
|
||||
using JdeScoping.Core.ViewModels;
|
||||
|
||||
namespace JdeScoping.Client.Services;
|
||||
|
||||
public class LookupApiClient : ApiClientBase, ILookupApiClient
|
||||
{
|
||||
public LookupApiClient(HttpClient httpClient) : base(httpClient) { }
|
||||
|
||||
public Task<ApiResult<IReadOnlyList<ItemViewModel>>> FindItemsAsync(string query, CancellationToken ct = default)
|
||||
=> GetAsync<IReadOnlyList<ItemViewModel>>(ApiRoutes.Lookup.FindItems(query), ct);
|
||||
|
||||
public Task<ApiResult<IReadOnlyList<ProfitCenterViewModel>>> FindProfitCentersAsync(string query, CancellationToken ct = default)
|
||||
=> GetAsync<IReadOnlyList<ProfitCenterViewModel>>(ApiRoutes.Lookup.FindProfitCenters(query), ct);
|
||||
|
||||
public Task<ApiResult<IReadOnlyList<WorkCenterViewModel>>> FindWorkCentersAsync(string query, CancellationToken ct = default)
|
||||
=> GetAsync<IReadOnlyList<WorkCenterViewModel>>(ApiRoutes.Lookup.FindWorkCenters(query), ct);
|
||||
|
||||
public Task<ApiResult<IReadOnlyList<JdeUserViewModel>>> FindOperatorsAsync(string query, CancellationToken ct = default)
|
||||
=> GetAsync<IReadOnlyList<JdeUserViewModel>>(ApiRoutes.Lookup.FindOperators(query), ct);
|
||||
}
|
||||
```
|
||||
|
||||
## Blazor Component Usage
|
||||
|
||||
```csharp
|
||||
@page "/searches"
|
||||
@implements IDisposable
|
||||
@inject ISearchApiClient SearchApi
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<h3>My Searches</h3>
|
||||
|
||||
@if (_loading)
|
||||
{
|
||||
<p>Loading...</p>
|
||||
}
|
||||
else if (_result.IsSuccess)
|
||||
{
|
||||
<SearchList Items="_result.Value" />
|
||||
}
|
||||
else if (_result.IsNotFound)
|
||||
{
|
||||
<p>No searches found</p>
|
||||
}
|
||||
else if (_result.IsUnauthorized)
|
||||
{
|
||||
// Redirect handled in OnInitializedAsync
|
||||
}
|
||||
else if (_result.IsForbidden)
|
||||
{
|
||||
<p>Access denied</p>
|
||||
}
|
||||
else if (_result.IsValidationError)
|
||||
{
|
||||
<ValidationErrors Errors="_result.ValidationError.FieldErrors" />
|
||||
}
|
||||
else if (_result.IsError)
|
||||
{
|
||||
<p class="error">@_result.Error.Message</p>
|
||||
}
|
||||
|
||||
@code {
|
||||
private CancellationTokenSource _cts = new();
|
||||
private bool _loading = true;
|
||||
private ApiResult<IReadOnlyList<SearchViewModel>> _result = new ApiError("Not loaded");
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_result = await SearchApi.GetUserSearchesAsync(_cts.Token);
|
||||
_loading = false;
|
||||
|
||||
if (_result.IsUnauthorized)
|
||||
{
|
||||
NavigationManager.NavigateTo("/login");
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => _cts.Cancel();
|
||||
}
|
||||
```
|
||||
|
||||
## DI Registration
|
||||
|
||||
### Client Program.cs
|
||||
|
||||
```csharp
|
||||
builder.Services.AddScoped<ISearchApiClient, SearchApiClient>();
|
||||
builder.Services.AddScoped<ILookupApiClient, LookupApiClient>();
|
||||
builder.Services.AddScoped<IAuthApiClient, AuthApiClient>();
|
||||
builder.Services.AddScoped<IFileApiClient, FileApiClient>();
|
||||
```
|
||||
|
||||
## Migration Path
|
||||
|
||||
1. **Add OneOf package** to Core
|
||||
2. **Create ApiRoutes.cs** with route constants
|
||||
3. **Create result types** in `Core/ApiContracts/Results/`
|
||||
4. **Create client interfaces** (`ISearchApiClient`, etc.)
|
||||
5. **Create ApiClientBase** with shared HTTP logic
|
||||
6. **Update controllers** to use `ApiRoutes` constants in attributes
|
||||
7. **Create client implementations** (`SearchApiClient`, etc.)
|
||||
8. **Update DI registration** to use new clients
|
||||
9. **Update Blazor components** to use `ApiResult` pattern
|
||||
10. **Delete old services** (`SearchService.cs`, `ISearchService.cs`, etc.)
|
||||
|
||||
## Design Notes
|
||||
|
||||
### Why Route Constants Instead of Static Abstract Methods
|
||||
|
||||
Static abstract interface members cannot be used in attribute parameters (attributes require compile-time constants). Using `ApiRoutes` constants allows:
|
||||
- Controller: `[Route(ApiRoutes.Search.Base)]`
|
||||
- Client: `GetAsync<T>(ApiRoutes.Search.GetById(id))`
|
||||
|
||||
Both reference the same source of truth.
|
||||
|
||||
### Why Separate Client Interfaces (Not Shared with Controllers)
|
||||
|
||||
Controllers return `ActionResult<T>` for proper HTTP semantics. Clients return `ApiResult<T>` for type-safe error handling. Sharing an interface would require either:
|
||||
- Controllers returning `ApiResult<T>` (breaks HTTP status codes)
|
||||
- Complex generic constraints
|
||||
|
||||
Separate interfaces are cleaner and more idiomatic for each context.
|
||||
|
||||
### File Endpoint Considerations
|
||||
|
||||
- Controllers use `IFormFile` for uploads (ASP.NET Core binding)
|
||||
- Clients use `Stream` (HttpClient multipart)
|
||||
- `byte[]` for downloads is acceptable for current file sizes
|
||||
- Future: Consider streaming for very large exports
|
||||
|
||||
### Validation Error Format
|
||||
|
||||
Both sides use ASP.NET Core's `ValidationProblemDetails` format:
|
||||
```json
|
||||
{
|
||||
"errors": {
|
||||
"FieldName": ["Error message 1", "Error message 2"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Client parses this into `ValidationError(IReadOnlyDictionary<string, string[]>)`.
|
||||
Reference in New Issue
Block a user