26ff8d9b4f
Set up repository with legacy .NET Framework 4.8 source (OLD/), new .NET 10 Blazor solution (NEW/), OpenSpec specifications, documentation, and project configuration.
35 KiB
35 KiB
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:
[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:
[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:
[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):
[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:
[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:
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:
// 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
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:
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
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
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
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)
{
"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)
{
"Auth": {
"UseFakeAuth": true
}
}
Cookie Authentication Configuration
Program.cs Setup
// 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
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)
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
public class StatusUpdate
{
public string Message { get; set; } = string.Empty;
public DateTime Timestamp { get; set; }
}
SearchUpdate
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
public class LoginRequest
{
[Required]
public string Username { get; set; } = string.Empty;
[Required]
public string Password { get; set; } = string.Empty;
}
FileUploadResult
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
// 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
<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>