Files
jdescopingtool/openspec/changes/archive/2026-01-01-implement-web-api/design.md
T
Joseph Doherty 26ff8d9b4f Initial commit: JDE Scoping Tool migration project
Set up repository with legacy .NET Framework 4.8 source (OLD/),
new .NET 10 Blazor solution (NEW/), OpenSpec specifications,
documentation, and project configuration.
2026-01-02 07:43:29 -05:00

1087 lines
35 KiB
Markdown

# Web API Design
## Overview
This document describes the architecture and implementation approach for the Web API layer, including ASP.NET Core 10 controllers, SignalR hub, LDAP authentication, and cookie-based session management.
## Architecture
### High-Level Component Diagram
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Blazor WebAssembly Client │
│ - HTTP API calls (fetch) │
│ - SignalR HubConnection │
└────────────────────────────────────┬────────────────────────────────────────┘
│ HTTPS
┌───────────────────────────┼───────────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ AuthController │ │ SearchController│ │ StatusHub │
│ /api/auth/* │ │ /api/search/* │ │ /hubs/status │
│ [AllowAnonymous]│ │ [Authorize] │ │ Real-time │
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ IAuthService │ │ ILotFinder │ │ IHubContext │
│ (LDAP/Fake) │ │ Repository │ │ <StatusHub> │
└─────────────────┘ └─────────────────┘ └─────────────────┘
```
### Project Structure
```
NEW/src/JdeScoping.Api/
├── Controllers/
│ ├── ApiControllerBase.cs # Base controller with CurrentUser
│ ├── AuthController.cs # Authentication endpoints
│ ├── SearchController.cs # Search management endpoints
│ ├── LookupController.cs # Autocomplete lookup endpoints
│ └── FileController.cs # File upload/download endpoints
├── Hubs/
│ └── StatusHub.cs # SignalR hub for real-time updates
├── Services/
│ ├── IAuthService.cs # Authentication service interface
│ ├── LdapAuthService.cs # LDAP authentication implementation
│ └── FakeAuthService.cs # Development mode auth bypass
├── Models/
│ ├── LoginRequest.cs # Login request DTO
│ ├── AuthResult.cs # Authentication result record
│ └── FileUploadResult.cs # File upload response DTO
├── Security/
│ ├── UserIdentity.cs # Claims-based user identity
│ └── ClaimsPrincipalExtensions.cs # Extension methods for claims
├── Configuration/
│ ├── LdapOptions.cs # LDAP configuration options
│ └── AuthOptions.cs # Authentication configuration
├── ServiceCollectionExtensions.cs # DI registration
└── JdeScoping.Api.csproj
```
## Controller Architecture
### ApiControllerBase
Base controller providing access to current user context:
```csharp
[ApiController]
public abstract class ApiControllerBase : ControllerBase
{
/// <summary>
/// Gets the current authenticated user from claims.
/// Returns null if not authenticated.
/// </summary>
protected UserInfo? CurrentUser => User.Identity?.IsAuthenticated == true
? User.ToUserInfo()
: null;
/// <summary>
/// Gets the current username from claims.
/// </summary>
protected string? CurrentUserName => User.FindFirstValue(ClaimTypes.Name);
}
```
### AuthController
Authentication endpoints for Blazor WASM client:
```csharp
[Route("api/auth")]
[ApiController]
public class AuthController : ApiControllerBase
{
private readonly IAuthService _authService;
private readonly ILogger<AuthController> _logger;
[HttpPost("login")]
[AllowAnonymous]
public async Task<ActionResult<UserInfo>> Login(
[FromBody] LoginRequest request,
CancellationToken ct)
{
var result = await _authService.AuthenticateAsync(
request.Username, request.Password, ct);
if (!result.Success)
{
return Unauthorized(new { message = result.ErrorMessage });
}
// Sign out existing session
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
// Create claims identity
var claims = new List<Claim>
{
new(ClaimTypes.Name, result.User!.Username),
new(ClaimTypes.GivenName, result.User.FirstName),
new(ClaimTypes.Surname, result.User.LastName),
new(ClaimTypes.Email, result.User.EmailAddress),
new("title", result.User.Title),
new("dn", result.User.DN)
};
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
var principal = new ClaimsPrincipal(identity);
// Sign in with non-persistent cookie
await HttpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
principal,
new AuthenticationProperties { IsPersistent = false });
return Ok(result.User);
}
[HttpPost("logout")]
[Authorize]
public async Task<IActionResult> Logout()
{
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return Ok();
}
[HttpGet("me")]
[Authorize]
public ActionResult<UserInfo> GetCurrentUser()
{
return Ok(CurrentUser);
}
}
```
### SearchController
Search management with proper authorization:
```csharp
[Route("api/search")]
[ApiController]
[Authorize]
public class SearchController : ApiControllerBase
{
private readonly ILotFinderRepository _repository;
private readonly IHubContext<StatusHub> _hubContext;
private readonly ILogger<SearchController> _logger;
[HttpGet]
public async Task<ActionResult<IEnumerable<SearchViewModel>>> GetSearches(
CancellationToken ct)
{
var searches = await _repository.GetUserSearchesAsync(CurrentUserName!, ct);
return Ok(searches.OrderByDescending(s => s.StartDT));
}
[HttpGet("queue")]
public async Task<ActionResult<IEnumerable<SearchViewModel>>> GetQueuedSearches(
CancellationToken ct)
{
var searches = await _repository.GetQueuedSearchesAsync(ct);
return Ok(searches);
}
[HttpGet("{id:int}")]
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));
}
[HttpPost("{id:int}/copy")]
public async Task<ActionResult<int>> CopySearch(int id, CancellationToken ct)
{
var original = await _repository.GetSearchAsync(id, ct);
if (original is null) return NotFound();
var copy = original with
{
ID = 0,
Status = SearchStatus.New,
UserName = CurrentUserName!,
SubmitDT = null,
StartDT = null,
EndDT = null
};
var newId = await _repository.SubmitSearchAsync(copy, ct);
return CreatedAtAction(nameof(GetSearch), new { id = newId }, newId);
}
[HttpPost]
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);
// Publish to SignalR (best-effort, swallow exceptions)
try
{
var searchUpdate = new SearchUpdate
{
ID = searchId,
UserName = CurrentUserName!,
Name = search.Name,
Status = search.Status,
Timestamp = DateTime.UtcNow
};
await _hubContext.Clients.All.SendAsync("searchUpdate", searchUpdate, ct);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to publish search update to SignalR");
}
return CreatedAtAction(nameof(GetSearch), new { id = searchId }, searchId);
}
[HttpGet("{id:int}/results")]
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");
}
}
```
### LookupController
Public autocomplete endpoints (no authorization):
```csharp
[Route("api/lookup")]
[ApiController]
public class LookupController : ApiControllerBase
{
private readonly ILotFinderRepository _repository;
[HttpGet("items")]
public async Task<ActionResult<IEnumerable<ItemViewModel>>> FindItems(
[FromQuery] string q,
CancellationToken ct)
{
var items = await _repository.SearchItemsAsync(q, ct);
return Ok(items.OrderBy(i => i.ItemNumber).Select(i => i.ToViewModel()));
}
[HttpGet("profit-centers")]
public async Task<ActionResult<IEnumerable<ProfitCenterViewModel>>> FindProfitCenters(
[FromQuery] string q,
CancellationToken ct)
{
var centers = await _repository.SearchProfitCentersAsync(q, ct);
return Ok(centers.OrderBy(pc => pc.Code).Select(pc => pc.ToViewModel()));
}
[HttpGet("work-centers")]
public async Task<ActionResult<IEnumerable<WorkCenterViewModel>>> FindWorkCenters(
[FromQuery] string q,
CancellationToken ct)
{
var centers = await _repository.SearchWorkCentersAsync(q, ct);
return Ok(centers.OrderBy(wc => wc.Code).Select(wc => wc.ToViewModel()));
}
[HttpGet("operators")]
public async Task<ActionResult<IEnumerable<UserViewModel>>> FindOperators(
[FromQuery] string q,
CancellationToken ct)
{
var users = await _repository.SearchUsersAsync(q, ct);
return Ok(users.OrderBy(u => u.FullName).Select(u => u.ToViewModel()));
}
}
```
### FileController
Excel file upload/download with caching:
```csharp
[Route("api/file")]
[ApiController]
public class FileController : ApiControllerBase
{
private const string ContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
private readonly ILotFinderRepository _repository;
private readonly IMemoryCache _cache;
private readonly ILogger<FileController> _logger;
[HttpPost("work-orders/upload")]
public async Task<ActionResult<FileUploadResult<WorkOrderViewModel>>> UploadWorkOrders(
IFormFile file,
CancellationToken ct)
{
if (file is null)
return Ok(new FileUploadResult<WorkOrderViewModel> { WasSuccessful = false, ErrorMessage = "No file uploaded" });
try
{
using var stream = file.OpenReadStream();
using var workbook = new XLWorkbook(stream);
var worksheet = workbook.Worksheet(1);
var workOrderNumbers = new List<long>();
var lastRow = worksheet.LastRowUsed()?.RowNumber() ?? 1;
for (int row = 2; row <= lastRow; row++)
{
var cellValue = worksheet.Cell(row, 1).GetString()?.Trim();
if (long.TryParse(cellValue, out var woNumber))
{
workOrderNumbers.Add(woNumber);
}
}
var workOrders = await _repository.LookupWorkOrdersAsync(workOrderNumbers, ct);
var viewModels = workOrders
.Select(wo => wo.ToViewModel())
.DistinctBy(wo => new { wo.WorkOrderNumber, wo.ItemNumber })
.OrderBy(wo => wo.WorkOrderNumber)
.ToArray();
return Ok(new FileUploadResult<WorkOrderViewModel>
{
WasSuccessful = true,
Data = viewModels
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to parse uploaded work order file");
return Ok(new FileUploadResult<WorkOrderViewModel>
{
WasSuccessful = false,
ErrorMessage = "Failed to parse uploaded file"
});
}
}
[HttpPost("work-orders/template")]
public ActionResult<string> GenerateWorkOrderTemplate([FromBody] List<long> workOrders)
{
var data = ExcelTemplateGenerator.Generate(workOrders ?? [], "Work Order Number");
var key = CacheData(data);
return Ok(key);
}
[HttpGet("work-orders/template/{key}")]
public IActionResult DownloadWorkOrderTemplate(string key)
{
return DownloadCachedFile(key, "work_order_template.xlsx");
}
// Similar methods for part-numbers, component-lots, part-operations...
private string CacheData(byte[] data)
{
var key = Guid.NewGuid().ToString("N").ToUpperInvariant();
_cache.Set(key, data, new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(1)
});
return key;
}
private IActionResult DownloadCachedFile(string key, string fileName)
{
if (!_cache.TryGetValue(key, out byte[]? data) || data is null)
{
return NotFound("Cached file not found or expired");
}
_cache.Remove(key);
return File(data, ContentType, fileName);
}
}
```
## SignalR StatusHub
Real-time updates using ASP.NET Core SignalR:
```csharp
public class StatusHub : Hub
{
private static StatusUpdate _cachedStatus = new()
{
Message = "Unknown",
Timestamp = DateTime.UtcNow
};
private readonly ILogger<StatusHub> _logger;
public StatusHub(ILogger<StatusHub> logger)
{
_logger = logger;
}
/// <summary>
/// Called by worker service to update status.
/// Caches the update and broadcasts to all clients.
/// </summary>
public async Task SetStatus(StatusUpdate statusUpdate)
{
_cachedStatus = statusUpdate;
await Clients.All.SendAsync("statusUpdate", statusUpdate);
}
/// <summary>
/// Called by clients to get initial cached status on connection.
/// </summary>
public StatusUpdate GetCachedStatus()
{
return _cachedStatus;
}
/// <summary>
/// Called by controllers/services to broadcast search updates.
/// </summary>
public async Task PublishSearchUpdate(SearchUpdate searchUpdate)
{
await Clients.All.SendAsync("searchUpdate", searchUpdate);
}
public override Task OnConnectedAsync()
{
_logger.LogInformation("Client {ConnectionId} connected to StatusHub", Context.ConnectionId);
return base.OnConnectedAsync();
}
public override Task OnDisconnectedAsync(Exception? exception)
{
_logger.LogInformation("Client {ConnectionId} disconnected from StatusHub", Context.ConnectionId);
return base.OnDisconnectedAsync(exception);
}
}
```
### Publishing from Controllers/Services
Use `IHubContext<StatusHub>` for publishing from outside the hub:
```csharp
// Inject IHubContext<StatusHub> into controller
private readonly IHubContext<StatusHub> _hubContext;
// Publish status update
await _hubContext.Clients.All.SendAsync("statusUpdate", statusUpdate);
// Publish search update
await _hubContext.Clients.All.SendAsync("searchUpdate", searchUpdate);
```
## LDAP Authentication
### IAuthService Interface
```csharp
public interface IAuthService
{
Task<AuthResult> AuthenticateAsync(
string username,
string password,
CancellationToken ct = default);
Task<UserInfo?> GetUserInfoAsync(
string username,
CancellationToken ct = default);
}
public record AuthResult(
bool Success,
UserInfo? User,
string? ErrorMessage);
```
### LdapAuthService Implementation
Using `System.DirectoryServices.Protocols` for cross-platform compatibility:
```csharp
public sealed class LdapAuthService : IAuthService
{
private const string LdapLookupFormat = "(sAMAccountName={0})";
private readonly LdapOptions _options;
private readonly ILogger<LdapAuthService> _logger;
public LdapAuthService(
IOptions<LdapOptions> options,
ILogger<LdapAuthService> logger)
{
_options = options.Value;
_logger = logger;
}
public async Task<AuthResult> AuthenticateAsync(
string username,
string password,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
return new AuthResult(false, null, "Username and password are required");
}
// Try each configured LDAP server
string? lastError = null;
foreach (var serverUrl in _options.ServerUrls)
{
try
{
ct.ThrowIfCancellationRequested();
// Attempt authentication
if (!await TryBindAsync(serverUrl, username, password, ct))
{
return new AuthResult(false, null, "Incorrect username or password");
}
// Verify group membership
if (!string.IsNullOrEmpty(_options.GroupDn))
{
if (!await IsInGroupAsync(serverUrl, username, password, _options.GroupDn, ct))
{
return new AuthResult(false, null, "User is not a member of the required security group");
}
}
// Lookup user info
var userInfo = await LookupUserAsync(serverUrl, username, password, ct);
if (userInfo is null)
{
return new AuthResult(false, null, "Failed to retrieve user information");
}
return new AuthResult(true, userInfo, null);
}
catch (LdapException ex)
{
_logger.LogWarning(ex, "LDAP authentication failed for server {Server}", serverUrl);
lastError = ex.Message;
continue; // Try next server
}
}
return new AuthResult(false, null, lastError ?? "Unable to connect to directory server");
}
private async Task<bool> TryBindAsync(
string serverUrl,
string username,
string password,
CancellationToken ct)
{
using var connection = CreateConnection(serverUrl);
var credential = new NetworkCredential(username, password);
connection.Credential = credential;
connection.AuthType = AuthType.Negotiate;
try
{
await Task.Run(() => connection.Bind(), ct);
return true;
}
catch (LdapException ex) when (ex.ErrorCode == 49) // Invalid credentials
{
return false;
}
}
private async Task<bool> IsInGroupAsync(
string serverUrl,
string username,
string password,
string groupDn,
CancellationToken ct)
{
using var connection = CreateConnection(serverUrl);
connection.Credential = new NetworkCredential(username, password);
connection.AuthType = AuthType.Negotiate;
await Task.Run(() => connection.Bind(), ct);
var searchRequest = new SearchRequest(
_options.SearchBase,
string.Format(LdapLookupFormat, username),
SearchScope.Subtree,
"memberOf");
var response = (SearchResponse)await Task.Run(
() => connection.SendRequest(searchRequest), ct);
foreach (SearchResultEntry entry in response.Entries)
{
var memberOf = entry.Attributes["memberOf"];
if (memberOf != null)
{
foreach (var group in memberOf.GetValues(typeof(string)))
{
if (string.Equals((string)group, groupDn, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
}
}
return false;
}
private async Task<UserInfo?> LookupUserAsync(
string serverUrl,
string username,
string password,
CancellationToken ct)
{
using var connection = CreateConnection(serverUrl);
connection.Credential = new NetworkCredential(username, password);
connection.AuthType = AuthType.Negotiate;
await Task.Run(() => connection.Bind(), ct);
var searchRequest = new SearchRequest(
_options.SearchBase,
string.Format(LdapLookupFormat, username),
SearchScope.Subtree,
"distinguishedName", "givenName", "sn", "mail", "title");
var response = (SearchResponse)await Task.Run(
() => connection.SendRequest(searchRequest), ct);
if (response.Entries.Count == 0) return null;
var entry = response.Entries[0];
return new UserInfo
{
DN = GetAttribute(entry, "distinguishedName"),
Username = username.ToLowerInvariant(),
FirstName = GetAttribute(entry, "givenName"),
LastName = GetAttribute(entry, "sn"),
EmailAddress = GetAttribute(entry, "mail"),
Title = GetAttribute(entry, "title")
};
}
private LdapConnection CreateConnection(string serverUrl)
{
var connection = new LdapConnection(serverUrl);
connection.SessionOptions.ProtocolVersion = 3;
connection.SessionOptions.SecureSocketLayer = false;
connection.Timeout = TimeSpan.FromSeconds(_options.ConnectionTimeoutSeconds);
return connection;
}
private static string GetAttribute(SearchResultEntry entry, string name)
{
var attr = entry.Attributes[name];
return attr?.Count > 0 ? (string)attr[0] : string.Empty;
}
public Task<UserInfo?> GetUserInfoAsync(string username, CancellationToken ct = default)
{
// Not implemented for LDAP - user info is only available during authentication
throw new NotSupportedException("GetUserInfoAsync requires password for LDAP lookup");
}
}
```
### FakeAuthService for Development
```csharp
public sealed class FakeAuthService : IAuthService
{
public Task<AuthResult> AuthenticateAsync(
string username,
string password,
CancellationToken ct = default)
{
// Accept any credentials in development mode
var user = new UserInfo
{
DN = $"CN={username},OU=Users,DC=example,DC=com",
Username = username.ToLowerInvariant(),
FirstName = "Dev",
LastName = "User",
EmailAddress = $"{username}@example.com",
Title = "Developer"
};
return Task.FromResult(new AuthResult(true, user, null));
}
public Task<UserInfo?> GetUserInfoAsync(string username, CancellationToken ct = default)
{
var user = new UserInfo
{
DN = $"CN={username},OU=Users,DC=example,DC=com",
Username = username.ToLowerInvariant(),
FirstName = "Dev",
LastName = "User",
EmailAddress = $"{username}@example.com",
Title = "Developer"
};
return Task.FromResult<UserInfo?>(user);
}
}
```
## Configuration Options
### LdapOptions
```csharp
public class LdapOptions
{
public const string SectionName = "Ldap";
/// <summary>
/// LDAP server URLs (supports multiple for failover).
/// Example: ["ldap.corp.example.com", "ldap2.corp.example.com"]
/// </summary>
public string[] ServerUrls { get; set; } = [];
/// <summary>
/// Distinguished name of required group for access.
/// Example: "CN=ScopingTool-Users,OU=Groups,DC=corp,DC=example,DC=com"
/// </summary>
public string GroupDn { get; set; } = string.Empty;
/// <summary>
/// LDAP search base for user lookups.
/// Example: "DC=corp,DC=example,DC=com"
/// </summary>
public string SearchBase { get; set; } = string.Empty;
/// <summary>
/// Connection timeout in seconds.
/// </summary>
public int ConnectionTimeoutSeconds { get; set; } = 30;
}
```
### AuthOptions
```csharp
public class AuthOptions
{
public const string SectionName = "Auth";
/// <summary>
/// Enable fake authentication for development.
/// When true, any credentials are accepted.
/// </summary>
public bool UseFakeAuth { get; set; } = false;
/// <summary>
/// Name of the authentication cookie.
/// </summary>
public string CookieName { get; set; } = "ScopingTool.Auth";
/// <summary>
/// Cookie expiration in minutes (default: 8 hours).
/// </summary>
public int CookieExpirationMinutes { get; set; } = 480;
/// <summary>
/// Optional list of usernames that bypass group check.
/// Use sparingly for admin/testing purposes.
/// </summary>
public string[] AdminBypassUsers { get; set; } = [];
}
```
### Configuration Example (appsettings.json)
```json
{
"Auth": {
"UseFakeAuth": false,
"CookieName": "ScopingTool.Auth",
"CookieExpirationMinutes": 480,
"AdminBypassUsers": []
},
"Ldap": {
"ServerUrls": ["ldap.corp.example.com"],
"GroupDn": "CN=ScopingTool-Users,OU=Groups,DC=corp,DC=example,DC=com",
"SearchBase": "DC=corp,DC=example,DC=com",
"ConnectionTimeoutSeconds": 30
}
}
```
### Development Configuration (appsettings.Development.json)
```json
{
"Auth": {
"UseFakeAuth": true
}
}
```
## Cookie Authentication Configuration
### Program.cs Setup
```csharp
// Configure authentication
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
var authOptions = builder.Configuration
.GetSection(AuthOptions.SectionName)
.Get<AuthOptions>() ?? new AuthOptions();
options.Cookie.Name = authOptions.CookieName;
options.Cookie.HttpOnly = true;
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
options.Cookie.SameSite = SameSiteMode.Lax;
options.ExpireTimeSpan = TimeSpan.FromMinutes(authOptions.CookieExpirationMinutes);
options.SlidingExpiration = true;
// Return 401 instead of redirect for API requests (Blazor WASM)
options.Events.OnRedirectToLogin = context =>
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return Task.CompletedTask;
};
options.Events.OnRedirectToAccessDenied = context =>
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
return Task.CompletedTask;
};
});
builder.Services.AddAuthorization();
```
## Service Registration
### AddWebApi Extension Method
```csharp
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddWebApi(
this IServiceCollection services,
IConfiguration configuration)
{
// Bind configuration options
services.Configure<LdapOptions>(configuration.GetSection(LdapOptions.SectionName));
services.Configure<AuthOptions>(configuration.GetSection(AuthOptions.SectionName));
// Register auth service based on configuration
var authOptions = configuration
.GetSection(AuthOptions.SectionName)
.Get<AuthOptions>() ?? new AuthOptions();
if (authOptions.UseFakeAuth)
{
services.AddSingleton<IAuthService, FakeAuthService>();
}
else
{
services.AddScoped<IAuthService, LdapAuthService>();
}
// Register memory cache for file downloads
services.AddMemoryCache();
// Configure SignalR
services.AddSignalR();
// Configure controllers
services.AddControllers()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
});
// Configure Swagger/OpenAPI
services.AddEndpointsApiExplorer();
services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo
{
Title = "JDE Scoping Tool API",
Version = "v1"
});
});
return services;
}
public static WebApplication UseWebApi(this WebApplication app)
{
// Use Swagger in development
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapHub<StatusHub>("/hubs/status");
return app;
}
}
```
## Data Models
### UserInfo (renamed from LDAPEntry)
```csharp
public class UserInfo
{
public string DN { get; set; } = string.Empty;
public string Username { get; set; } = string.Empty;
public string FirstName { get; set; } = string.Empty;
public string LastName { get; set; } = string.Empty;
public string DisplayName => string.IsNullOrWhiteSpace(FirstName) && string.IsNullOrWhiteSpace(LastName)
? Username
: $"{FirstName} {LastName}".Trim();
public string Title { get; set; } = string.Empty;
public string EmailAddress { get; set; } = string.Empty;
}
```
### StatusUpdate
```csharp
public class StatusUpdate
{
public string Message { get; set; } = string.Empty;
public DateTime Timestamp { get; set; }
}
```
### SearchUpdate
```csharp
public class SearchUpdate
{
public int ID { get; set; }
public string UserName { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
[JsonConverter(typeof(JsonStringEnumConverter))]
public SearchStatus Status { get; set; }
public DateTime? SubmitDT { get; set; }
public DateTime? StartDT { get; set; }
public DateTime? EndDT { get; set; }
public DateTime Timestamp { get; set; }
}
```
### LoginRequest
```csharp
public class LoginRequest
{
[Required]
public string Username { get; set; } = string.Empty;
[Required]
public string Password { get; set; } = string.Empty;
}
```
### FileUploadResult<T>
```csharp
public class FileUploadResult<T>
{
public bool WasSuccessful { get; set; }
public string? ErrorMessage { get; set; }
public T[]? Data { get; set; }
}
```
## Testing Strategy
### Unit Tests
- **Controller Tests**: Test action methods with mocked dependencies
- **Auth Service Tests**: Test LDAP connection handling (integration tests)
- **File Controller Tests**: Test upload parsing and caching
- **SignalR Hub Tests**: Test hub method invocation
### Mock Patterns
```csharp
// Controller test with NSubstitute
[Fact]
public async Task GetSearches_ReturnsUserSearches_OrderedByStartDtDescending()
{
// Arrange
var repository = Substitute.For<ILotFinderRepository>();
var hubContext = Substitute.For<IHubContext<StatusHub>>();
var logger = Substitute.For<ILogger<SearchController>>();
var searches = new[]
{
new Search { ID = 1, StartDT = DateTime.Now.AddHours(-2) },
new Search { ID = 2, StartDT = DateTime.Now }
};
repository.GetUserSearchesAsync("testuser", Arg.Any<CancellationToken>())
.Returns(searches);
var controller = new SearchController(repository, hubContext, logger);
controller.ControllerContext = CreateControllerContext("testuser");
// Act
var result = await controller.GetSearches(CancellationToken.None);
// Assert
var okResult = result.Result.ShouldBeOfType<OkObjectResult>();
var viewModels = okResult.Value.ShouldBeOfType<SearchViewModel[]>();
viewModels[0].ID.ShouldBe(2); // Most recent first
viewModels[1].ID.ShouldBe(1);
}
```
## NuGet Dependencies
```xml
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.Cookies" Version="2.2.*" />
<PackageReference Include="System.DirectoryServices.Protocols" Version="8.0.*" />
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.1.*" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.*" />
<PackageReference Include="ClosedXML" Version="0.102.*" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.*" />
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.*" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.*" />
</ItemGroup>
<ItemGroup Condition="'$(Configuration)' == 'Test'">
<PackageReference Include="xunit" Version="2.9.*" />
<PackageReference Include="Shouldly" Version="4.2.*" />
<PackageReference Include="NSubstitute" Version="5.1.*" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.*" />
</ItemGroup>
```