Files
jdescopingtool/PLANS/2026-01-06-api-client-contracts-design.md
T
Joseph Doherty d4135e8ad3 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.
2026-01-06 14:12:07 -05:00

27 KiB

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

<PackageReference Include="OneOf" Version="3.0.271" />

Route Constants

ApiRoutes.cs

Using constants allows usage in both [HttpGet] attributes and client code:

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

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

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

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

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

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

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)

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

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:

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

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

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

@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

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:

{
  "errors": {
    "FieldName": ["Error message 1", "Error message 2"]
  }
}

Client parses this into ValidationError(IReadOnlyDictionary<string, string[]>).