# 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` | Explicit error handling, preserves context | | Controller returns | `ActionResult` with `ApiResult` mapper | Proper HTTP status codes | | Client returns | `ApiResult` | 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 ``` ## Route Constants ### ApiRoutes.cs Using constants allows usage in both `[HttpGet]` attributes and client code: ```csharp namespace JdeScoping.Core.ApiContracts; /// /// Shared API route constants. Use in controller attributes and client implementations. /// 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; /// /// Standard API result type for client-side operations. /// [GenerateOneOf] public partial class ApiResult : OneOfBase { 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; /// Resource not found (404). public readonly record struct NotFound; /// Authentication required (401). public readonly record struct Unauthorized; /// Access denied (403). public readonly record struct Forbidden; /// /// Validation failed (400) with field-level errors. /// Maps to ASP.NET Core ProblemDetails format. /// public readonly record struct ValidationError(IReadOnlyDictionary FieldErrors) { public static ValidationError FromProblemDetails(Dictionary errors) => new(errors); } /// General API error. public readonly record struct ApiError(string Message, int? StatusCode = null); /// Empty success type for void operations. public readonly record struct Unit; ``` ## Client Interface Definitions Client interfaces define the contract for HTTP client implementations. They return `ApiResult`. ### ISearchApiClient.cs ```csharp using JdeScoping.Core.ApiContracts.Results; using JdeScoping.Core.ViewModels; namespace JdeScoping.Core.ApiContracts; /// /// Client contract for search API operations. /// public interface ISearchApiClient { Task>> GetUserSearchesAsync(CancellationToken ct = default); Task>> GetQueuedSearchesAsync(CancellationToken ct = default); Task> GetSearchAsync(int id, CancellationToken ct = default); Task> CopySearchAsync(int id, CancellationToken ct = default); Task> CreateSearchAsync(SearchViewModel search, CancellationToken ct = default); Task> GetResultsAsync(int id, CancellationToken ct = default); } ``` ### ILookupApiClient.cs ```csharp using JdeScoping.Core.ApiContracts.Results; using JdeScoping.Core.ViewModels; namespace JdeScoping.Core.ApiContracts; /// /// Client contract for lookup/autocomplete API operations. /// public interface ILookupApiClient { Task>> FindItemsAsync(string query, CancellationToken ct = default); Task>> FindProfitCentersAsync(string query, CancellationToken ct = default); Task>> FindWorkCentersAsync(string query, CancellationToken ct = default); Task>> 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; /// /// Client contract for authentication API operations. /// public interface IAuthApiClient { Task> GetPublicKeyAsync(CancellationToken ct = default); Task> LoginAsync(EncryptedLoginRequest request, CancellationToken ct = default); Task> LogoutAsync(CancellationToken ct = default); Task> GetCurrentUserAsync(CancellationToken ct = default); } ``` ### IFileApiClient.cs ```csharp using JdeScoping.Core.ApiContracts.Results; using JdeScoping.Core.ViewModels; namespace JdeScoping.Core.ApiContracts; /// /// Client contract for file upload/download API operations. /// Note: Uses Stream for client-side; controllers use IFormFile. /// public interface IFileApiClient { // Downloads (POST with existing data, returns Excel bytes) Task> DownloadWorkOrdersTemplateAsync(IReadOnlyList? existingData = null, CancellationToken ct = default); Task> DownloadItemsTemplateAsync(IReadOnlyList? existingData = null, CancellationToken ct = default); Task> DownloadComponentLotsTemplateAsync(IReadOnlyList? existingData = null, CancellationToken ct = default); Task> DownloadPartOperationsTemplateAsync(IReadOnlyList? existingData = null, CancellationToken ct = default); // Uploads (multipart form, returns parsed data) Task>> UploadWorkOrdersAsync(Stream fileStream, string fileName, CancellationToken ct = default); Task>> UploadItemsAsync(Stream fileStream, string fileName, CancellationToken ct = default); Task>> UploadComponentLotsAsync(Stream fileStream, string fileName, CancellationToken ct = default); Task>> UploadPartOperationsAsync(Stream fileStream, string fileName, CancellationToken ct = default); } ``` ## Controller Implementation Controllers use `ApiRoutes` constants in attributes and return `ActionResult`. 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; /// /// Converts ApiResult to ActionResult with proper HTTP status codes. /// public static class ApiResultExtensions { public static ActionResult ToActionResult(this ApiResult result) { return result.Match>( 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 ToCreatedResult(this ApiResult result, string actionName, Func routeValues) { return result.Match>( 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), StatusCodes.Status200OK)] public async Task>> 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), StatusCodes.Status200OK)] public async Task>> 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> 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> 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> 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 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; /// /// 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> 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)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; } } } ``` ### 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>> GetUserSearchesAsync(CancellationToken ct = default) => GetAsync>(ApiRoutes.Search.Base, ct); public Task>> GetQueuedSearchesAsync(CancellationToken ct = default) => GetAsync>(ApiRoutes.Search.Queue, ct); public Task> GetSearchAsync(int id, CancellationToken ct = default) => GetAsync(ApiRoutes.Search.GetById(id), ct); public Task> CopySearchAsync(int id, CancellationToken ct = default) => GetAsync(ApiRoutes.Search.GetCopy(id), ct); public Task> CreateSearchAsync(SearchViewModel search, CancellationToken ct = default) => PostAsync(ApiRoutes.Search.Base, search, ct); public Task> 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>> FindItemsAsync(string query, CancellationToken ct = default) => GetAsync>(ApiRoutes.Lookup.FindItems(query), ct); public Task>> FindProfitCentersAsync(string query, CancellationToken ct = default) => GetAsync>(ApiRoutes.Lookup.FindProfitCenters(query), ct); public Task>> FindWorkCentersAsync(string query, CancellationToken ct = default) => GetAsync>(ApiRoutes.Lookup.FindWorkCenters(query), ct); public Task>> FindOperatorsAsync(string query, CancellationToken ct = default) => GetAsync>(ApiRoutes.Lookup.FindOperators(query), ct); } ``` ## Blazor Component Usage ```csharp @page "/searches" @implements IDisposable @inject ISearchApiClient SearchApi @inject NavigationManager NavigationManager

My Searches

@if (_loading) {

Loading...

} else if (_result.IsSuccess) { } else if (_result.IsNotFound) {

No searches found

} else if (_result.IsUnauthorized) { // Redirect handled in OnInitializedAsync } else if (_result.IsForbidden) {

Access denied

} else if (_result.IsValidationError) { } else if (_result.IsError) {

@_result.Error.Message

} @code { private CancellationTokenSource _cts = new(); private bool _loading = true; private ApiResult> _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(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); ``` ## 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(ApiRoutes.Search.GetById(id))` Both reference the same source of truth. ### Why Separate Client Interfaces (Not Shared with Controllers) Controllers return `ActionResult` for proper HTTP semantics. Clients return `ApiResult` for type-safe error handling. Sharing an interface would require either: - Controllers returning `ApiResult` (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)`.