using System.Text.RegularExpressions;
namespace ApiRegistrationTool;
///
/// All data extracted from a single FastEndpoints .cs file plus its supporting files.
///
public sealed class ScannedEndpoint
{
// ── Source location ───────────────────────────────────────────────────
public string FilePath { get; set; } = string.Empty;
public string Module { get; set; } = string.Empty; // e.g. MM, PayRoll, QMS
public string Entity { get; set; } = string.Empty; // e.g. Allocation, Leave
public string ClassName { get; set; } = string.Empty; // e.g. GetAllocation
public string Namespace { get; set; } = string.Empty;
// ── HTTP surface ──────────────────────────────────────────────────────
public string HttpMethod { get; set; } = "GET";
public string Route { get; set; } = string.Empty; // /Leave/SaveLeave
public bool AllowAnonymous { get; set; }
public bool RequiresAuth { get; set; }
// ── Request ───────────────────────────────────────────────────────────
/// All [QueryParam] or [FromBody] parameters with names + C# types
public List AllParams { get; set; } = new();
/// DTO type name from [FromBody] (e.g. LeaveDTO)
public string? RequestBodyType { get; set; }
/// Full path to the request DTO .cs file
public string? RequestBodyFile { get; set; }
// ── Response ──────────────────────────────────────────────────────────
/// The DTO type the BLL method returns (e.g. LeaveDTO, string)
public string? ResponseDtoType { get; set; }
/// Full path to the response DTO .cs file (may be same as RequestBodyFile)
public string? ResponseDtoFile { get; set; }
// ── BLL info (for description generation) ─────────────────────────────
public string? BllFile { get; set; }
/// Short description extracted from BLL method body (first 10 meaningful lines)
public string? BllMethodDescription { get; set; }
/// BLL method name called from this endpoint (e.g. SaveLeave)
public string? BllMethodName { get; set; }
// ── Full source text ──────────────────────────────────────────────────
public string SourceText { get; set; } = string.Empty;
}
public sealed class EndpointParam
{
public string Name { get; set; } = string.Empty;
public string CsType { get; set; } = string.Empty;
public string Location { get; set; } = "Query"; // Query / Body / Header / Path
public bool Required { get; set; } = true;
}
///
/// Scans all *SL/Endpoints/**/*.cs files, parses every FastEndpoints endpoint,
/// resolves its request DTO, response DTO, and BLL method description.
///
public sealed class EndpointScanner
{
// ── Compiled regex ────────────────────────────────────────────────────
private static readonly Regex RxNamespace = new(@"namespace\s+([\w\.]+)", RegexOptions.Compiled);
private static readonly Regex RxClass = new(@"public\s+(?:sealed\s+)?class\s+(\w+)", RegexOptions.Compiled);
private static readonly Regex RxRoute = new(@"(?:^|\s|\(|\.)(Get|Post|Put|Patch|Delete)\s*\(\s*""([^""]+)""", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.Multiline);
private static readonly Regex RxQueryParam = new(@"\[property:\s*QueryParam\]\s+([\w\?\[\]<>]+)\s+(\w+)", RegexOptions.Compiled);
private static readonly Regex RxFromBody = new(@"\[property:\s*FromBody\]\s+([\w\?\[\]<>]+)\s+(\w+)", RegexOptions.Compiled);
private static readonly Regex RxFromHeader = new(@"\[property:\s*FromHeader\]\s+([\w\?\[\]<>]+)\s+(\w+)", RegexOptions.Compiled);
private static readonly Regex RxAnonymous = new(@"AllowAnonymous\s*\(", RegexOptions.Compiled);
private static readonly Regex RxAuthorize = new(@"RequireAuthorization\s*\(|\.Roles\s*\(", RegexOptions.Compiled);
private static readonly Regex RxBllCall = new(@"_\w+\s*\.\s*(\w+)\s*\(", RegexOptions.Compiled);
private static readonly Regex RxBllInterface= new(@"I(\w+BLL)\b", RegexOptions.Compiled);
private static readonly Regex RxBllReturn = new(@"Task<([^>]+)>\s+\w+\s*\([^)]*\)", RegexOptions.Compiled);
// Match "public async Task MethodName(" or "public Task"
private static readonly Regex RxBllMethod = new(@"public\s+(?:async\s+)?Task<([^>]+)>\s+(\w+)\s*\(", RegexOptions.Compiled);
private readonly string _solutionRoot;
private readonly List _includeModules;
private readonly List _excludeModules;
// Cache of all DTO files indexed by class name (built once per scan)
private Dictionary? _dtoFileIndex;
// Cache of all BLL files indexed by class name (built once per scan)
private Dictionary? _bllFileIndex;
public EndpointScanner(string solutionRoot, List includeModules, List excludeModules)
{
_solutionRoot = solutionRoot;
_includeModules = includeModules.Select(m => m.ToUpperInvariant()).ToList();
_excludeModules = excludeModules.Select(m => m.ToUpperInvariant()).ToList();
}
// ─────────────────────────────────────────────────────────────────────
/// Scan + return all endpoints, deduplicated by Route+Method.
// ─────────────────────────────────────────────────────────────────────
public List Scan()
{
// Build DTO + BLL indexes once (avoid per-endpoint directory scanning)
Console.WriteLine(" [SCAN] Building DTO/BLL file index ...");
_dtoFileIndex = BuildDtoIndex(_solutionRoot);
_bllFileIndex = BuildBllIndex(_solutionRoot);
Console.WriteLine($" [SCAN] DTO index: {_dtoFileIndex.Count} classes, BLL index: {_bllFileIndex.Count} files.");
var slDirs = Directory
.GetDirectories(_solutionRoot, "*SL", SearchOption.AllDirectories)
.Where(d => !IsObjBin(d))
.ToList();
var results = new List();
var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
foreach (var slDir in slDirs)
{
// Find Endpoints folder (case-insensitive)
var endpointsDir = FindEndpointsDir(slDir);
if (endpointsDir == null) continue;
string module = DeriveModule(Path.GetFileName(slDir));
string moduleUpper = module.ToUpperInvariant();
if (_includeModules.Count > 0 && !_includeModules.Contains(moduleUpper)) continue;
if (_excludeModules.Contains(moduleUpper)) continue;
foreach (var filePath in Directory.GetFiles(endpointsDir, "*.cs", SearchOption.AllDirectories))
{
var ep = ParseFile(filePath, module, _solutionRoot);
if (ep == null) continue;
string key = $"{ep.HttpMethod}:{ep.Route}".ToUpperInvariant();
if (!seen.Add(key)) continue;
results.Add(ep);
}
}
return results;
}
// ─────────────────────────────────────────────────────────────────────
private ScannedEndpoint? ParseFile(string filePath, string module, string solutionRoot)
{
string text;
try { text = File.ReadAllText(filePath); }
catch { return null; }
var routeMatch = RxRoute.Match(text);
if (!routeMatch.Success) return null;
string httpMethod = routeMatch.Groups[1].Value.ToUpperInvariant();
string route = "/" + routeMatch.Groups[2].Value.TrimStart('/');
string className = RxClass.Match(text).Groups[1].Value;
if (string.IsNullOrWhiteSpace(className)) className = Path.GetFileNameWithoutExtension(filePath);
string ns = RxNamespace.Match(text).Groups[1].Value;
string entity = InferEntity(ns, filePath);
// ── All parameters ────────────────────────────────────────────────
var allParams = new List();
// [FromHeader] Login — always present, skip it (not a real API param)
foreach (Match m in RxQueryParam.Matches(text))
{
string pType = m.Groups[1].Value.TrimEnd('?');
string pName = m.Groups[2].Value;
if (pName.Equals("Login", StringComparison.OrdinalIgnoreCase)) continue;
allParams.Add(new EndpointParam
{
Name = pName,
CsType = pType,
Location = "Query",
Required = !m.Groups[1].Value.EndsWith('?')
});
}
string? requestBodyType = null;
string? requestBodyFile = null;
foreach (Match m in RxFromBody.Matches(text))
{
string pType = m.Groups[1].Value.TrimEnd('?');
string pName = m.Groups[2].Value;
requestBodyType ??= pType;
requestBodyFile ??= FindDtoFile(pType);
allParams.Add(new EndpointParam
{
Name = pName,
CsType = pType,
Location = "Body",
Required = true
});
}
// ── BLL method called ─────────────────────────────────────────────
string? bllMethod = null;
var bllCallMatch = RxBllCall.Match(text);
if (bllCallMatch.Success) bllMethod = bllCallMatch.Groups[1].Value;
// ── Resolve BLL file ──────────────────────────────────────────────
string? bllInterfaceName = RxBllInterface.Match(text).Groups[1].Value;
string? bllFile = string.IsNullOrWhiteSpace(bllInterfaceName)
? null
: FindBllFile(solutionRoot, module, entity, bllInterfaceName, bllMethod);
// ── Response DTO from BLL return type ─────────────────────────────
string? responseDtoType = null;
string? responseDtoFile = null;
if (bllFile != null && bllMethod != null)
{
(responseDtoType, responseDtoFile) = ResolveResponseDto(bllFile, bllMethod);
}
// Fall back: for Save endpoints, the response DTO is often the same as request DTO
if (responseDtoType == null && requestBodyType != null &&
httpMethod is "POST" or "PUT")
{
responseDtoType = requestBodyType;
responseDtoFile = requestBodyFile;
}
// Fall back: for Get endpoints, response is entity DTO
if (responseDtoType == null)
{
string entityDtoType = entity + "DTO";
string? entityDtoFile = FindDtoFile(entityDtoType);
if (entityDtoFile != null) { responseDtoType = entityDtoType; responseDtoFile = entityDtoFile; }
}
// ── BLL description ───────────────────────────────────────────────
string? bllDesc = null;
if (bllFile != null && bllMethod != null)
bllDesc = ExtractBllMethodDesc(bllFile, bllMethod);
return new ScannedEndpoint
{
FilePath = filePath,
Module = module,
Entity = entity,
ClassName = className,
Namespace = ns,
HttpMethod = httpMethod,
Route = route,
AllowAnonymous = RxAnonymous.IsMatch(text),
RequiresAuth = RxAuthorize.IsMatch(text),
AllParams = allParams,
RequestBodyType = requestBodyType,
RequestBodyFile = requestBodyFile,
ResponseDtoType = responseDtoType,
ResponseDtoFile = responseDtoFile,
BllFile = bllFile,
BllMethodName = bllMethod,
BllMethodDescription = bllDesc,
SourceText = text
};
}
// ── Build a flat index: className (lower) → full file path ────────────
private static Dictionary BuildDtoIndex(string solutionRoot)
{
var index = new Dictionary(StringComparer.OrdinalIgnoreCase);
// Prefer *DAL/DTO folders (most DTOs live here)
var searchDirs = new List();
searchDirs.AddRange(
Directory.GetDirectories(solutionRoot, "*DAL", SearchOption.AllDirectories)
.Where(d => !IsObjBin(d)));
searchDirs.AddRange(
Directory.GetDirectories(solutionRoot, "*BLL", SearchOption.AllDirectories)
.Where(d => !IsObjBin(d)));
var classRegex = new Regex(@"public\s+(?:sealed\s+|abstract\s+)?(?:class|record)\s+(\w+)", RegexOptions.Compiled);
foreach (var dir in searchDirs)
{
foreach (var f in Directory.GetFiles(dir, "*.cs", SearchOption.AllDirectories))
{
try
{
string text = File.ReadAllText(f);
foreach (Match m in classRegex.Matches(text))
{
string name = m.Groups[1].Value;
if (!index.ContainsKey(name)) index[name] = f;
}
}
catch { /* skip */ }
}
}
return index;
}
// ── Find DTO file from the pre-built index ────────────────────────────
private string? FindDtoFile(string typeName)
{
if (string.IsNullOrWhiteSpace(typeName)) return null;
_dtoFileIndex!.TryGetValue(typeName, out var path);
return path;
}
// ── Find BLL file: O(1) lookup from pre-built index ──────────────────
private string? FindBllFile(
string solutionRoot, string module, string entity,
string bllInterfaceName, string? bllMethod)
{
// Try exact BLL class name first (e.g. LeaveBLL)
if (_bllFileIndex!.TryGetValue(bllInterfaceName, out var path)) return path;
// Try {Entity}BLL
if (_bllFileIndex.TryGetValue(entity + "BLL", out path)) return path;
return null;
}
// ── Build BLL file index: fileNameWithoutExtension → fullPath ─────────
private static Dictionary BuildBllIndex(string solutionRoot)
{
var index = new Dictionary(StringComparer.OrdinalIgnoreCase);
foreach (var dir in Directory.GetDirectories(solutionRoot, "*BLL", SearchOption.AllDirectories)
.Where(d => !IsObjBin(d)))
{
foreach (var f in Directory.GetFiles(dir, "*BLL.cs", SearchOption.AllDirectories)
.Where(f => !IsObjBin(f)))
{
string key = Path.GetFileNameWithoutExtension(f);
if (!index.ContainsKey(key)) index[key] = f;
}
}
return index;
}
// ── Extract response DTO type from BLL method return type ─────────────
private (string? type, string? file) ResolveResponseDto(string bllFile, string bllMethod)
{
try
{
string text = File.ReadAllText(bllFile);
foreach (Match m in RxBllMethod.Matches(text))
{
string returnType = m.Groups[1].Value.Trim();
string methodName = m.Groups[2].Value;
if (!methodName.Equals(bllMethod, StringComparison.OrdinalIgnoreCase)) continue;
// Skip generic wrappers like "string", "bool", "object"
if (returnType is "string" or "bool" or "object" or "int" or "void") continue;
// Strip List<> wrapper → get inner type
string dtoType = returnType;
var listMatch = Regex.Match(returnType, @"List<(\w+)>");
if (listMatch.Success) dtoType = listMatch.Groups[1].Value;
if (dtoType.EndsWith("DTO", StringComparison.OrdinalIgnoreCase) ||
dtoType.EndsWith("Result", StringComparison.OrdinalIgnoreCase))
{
_dtoFileIndex!.TryGetValue(dtoType, out var dtoFile);
return (dtoType, dtoFile);
}
}
}
catch { /* ignore */ }
return (null, null);
}
// ── Extract first 10 meaningful lines from the BLL method body ─────────
private static string? ExtractBllMethodDesc(string bllFile, string bllMethod)
{
try
{
var lines = File.ReadAllLines(bllFile);
bool inMethod = false;
int braces = 0;
var desc = new List();
for (int i = 0; i < lines.Length; i++)
{
string line = lines[i].Trim();
if (!inMethod)
{
if (line.Contains($" {bllMethod}(") || line.Contains($".{bllMethod}("))
inMethod = true;
continue;
}
braces += line.Count(c => c == '{') - line.Count(c => c == '}');
if (braces <= 0 && inMethod) break;
// Collect meaningful comment-like lines or validation/operation lines
if (!string.IsNullOrWhiteSpace(line) &&
!line.StartsWith("//") &&
!line.StartsWith("{") &&
!line.StartsWith("}") &&
desc.Count < 10)
{
desc.Add(line);
}
}
return desc.Count > 0 ? string.Join(" | ", desc.Take(5)) : null;
}
catch { return null; }
}
// ── Infer entity name from namespace or folder ────────────────────────
private static string InferEntity(string ns, string filePath)
{
// Namespace: MMSL.EndPoints.Leave → Leave
// PayRollSL.Endpoints.Leave → Leave
var parts = ns.Split('.');
for (int i = parts.Length - 1; i >= 0; i--)
{
string p = parts[i];
if (p.Length > 2 && !p.EndsWith("SL", StringComparison.OrdinalIgnoreCase)
&& !p.Equals("EndPoints", StringComparison.OrdinalIgnoreCase)
&& !p.Equals("Endpoints", StringComparison.OrdinalIgnoreCase))
return p;
}
// Fallback: parent folder name
return Path.GetFileName(Path.GetDirectoryName(filePath) ?? "") ?? "Unknown";
}
// ── Derive module from SL folder name (MMSL→MM, PayRollSL→PayRoll) ────
private static string DeriveModule(string slFolderName)
{
if (slFolderName.EndsWith("SL", StringComparison.OrdinalIgnoreCase))
return slFolderName[..^2];
return slFolderName;
}
private static string? FindEndpointsDir(string slDir)
{
var subs = Directory.GetDirectories(slDir);
return subs.FirstOrDefault(d =>
Path.GetFileName(d).Equals("Endpoints", StringComparison.OrdinalIgnoreCase) ||
Path.GetFileName(d).Equals("EndPoints", StringComparison.OrdinalIgnoreCase));
}
private static bool IsObjBin(string path) =>
path.Contains(Path.DirectorySeparatorChar + "obj" + Path.DirectorySeparatorChar) ||
path.Contains(Path.DirectorySeparatorChar + "bin" + Path.DirectorySeparatorChar);
}