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); }