using System.Security.Cryptography; using System.Text; using System.Text.RegularExpressions; using AmpDAL.DTO.RegisterApi; using AMPBLL.RegisterApi; using GB5Shared.DTO.Framework.Login; using Microsoft.Extensions.Logging; namespace ApiRegistrationTool; // ── Per-endpoint result row ─────────────────────────────────────────────────── public sealed class ApiSyncRow { public string Module { get; set; } = string.Empty; public string Entity { get; set; } = string.Empty; public string ClassName { get; set; } = string.Empty; public string HttpMethod { get; set; } = string.Empty; public string Route { get; set; } = string.Empty; /// NEW | UPDATED | EXISTS | SAVED | FAILED | SKIPPED public string Status { get; set; } = string.Empty; public string? Error { get; set; } public int? ApiId { get; set; } public bool HasRequestJson { get; set; } public bool HasResponseJson { get; set; } public int ParamCount { get; set; } } // ── Overall run summary ─────────────────────────────────────────────────────── public sealed class SyncResult { public int TotalScanned { get; set; } public int AlreadyRegistered { get; set; } public int Saved { get; set; } public int Updated { get; set; } public int Failed { get; set; } public int Skipped { get; set; } public List Rows { get; set; } = new(); public TimeSpan Elapsed { get; set; } } /// /// Core engine that loops over ALL scanned endpoints, builds a fully-populated /// for each, and then: /// • Calls for new endpoints (INSERT all 11 tables) /// • Calls for existing endpoints (UPDATE schema+description) /// /// Every field is auto-generated from the actual source code analysis: /// - ApiName / ApiKey → from ClassName /// - Description / AboutApi → from BLL method description /// - KeyFeatures → from HTTP method + route + all params /// - RequestJson → from request DTO properties (ALL fields, proper types) /// - ResponseJson → from response DTO + GB5 wrapper /// - OperationSummary → from BLL method flow description /// - Parameters → ALL query params stored in RequestJson + first in MAPIPARAMETER /// public sealed class ApiSyncEngine { private readonly IRegisterApiBLL _bll; private readonly ApiUpdateService _updater; private readonly ILogger _log; private readonly ToolConfig _cfg; private readonly ModulePrefixResolver _modulePrefixes; public ApiSyncEngine( IRegisterApiBLL bll, ApiUpdateService updater, ILogger log, ToolConfig cfg) { _bll = bll; _updater = updater; _log = log; _cfg = cfg; string gatewayPath = string.IsNullOrWhiteSpace(cfg.GatewayAppSettingsPath) ? Path.Combine(cfg.SolutionRoot, "GB5Build", "GB5Build", "appsettings.json") : cfg.GatewayAppSettingsPath; _modulePrefixes = ModulePrefixResolver.Load(gatewayPath); } // ═════════════════════════════════════════════════════════════════════ public async Task RunAsync( List scanned, CancellationToken ct) { var sw = System.Diagnostics.Stopwatch.StartNew(); var result = new SyncResult { TotalScanned = scanned.Count }; var login = BuildLogin(); // ── 1. Load all existing API registrations ──────────────────────── Console.WriteLine(" [DB] Loading existing registrations ..."); List existing; try { existing = await _bll.GetListAsync(login, ct); } catch (Exception ex) { _log.LogError(ex, "Failed to load existing APIs from DB."); throw; } // Build lookup: normalised route → RegisterApiDTO. Some routes have // duplicate MAPI rows in the DB (leftover from earlier bugs/manual // inserts) — keep the most recently modified row for each route // instead of crashing the whole run on a duplicate key. var duplicateRouteGroups = existing .Where(a => !string.IsNullOrWhiteSpace(a.ApiPath)) .GroupBy(a => NormRoute(a.ApiPath!), StringComparer.OrdinalIgnoreCase) .Where(g => g.Count() > 1) .ToList(); if (duplicateRouteGroups.Count > 0) { Console.ForegroundColor = ConsoleColor.Yellow; Console.WriteLine($" [WARN] {duplicateRouteGroups.Count} route(s) have duplicate MAPI rows in the DB — using the most recently modified row for each, ignoring the rest:"); foreach (var g in duplicateRouteGroups) Console.WriteLine($" {g.Key} → ApiIds: {string.Join(", ", g.Select(a => a.ApiId))}"); Console.ResetColor(); } var registeredByRoute = existing .Where(a => !string.IsNullOrWhiteSpace(a.ApiPath)) .GroupBy(a => NormRoute(a.ApiPath!), StringComparer.OrdinalIgnoreCase) .ToDictionary(g => g.Key, g => g.OrderByDescending(a => a.ModifiedOn).First(), StringComparer.OrdinalIgnoreCase); result.AlreadyRegistered = existing.Count; Console.WriteLine($" [DB] {existing.Count} APIs already registered."); // ── 2. Process every scanned endpoint (bounded concurrency) ──────── int limit = _cfg.MaxApis > 0 ? _cfg.MaxApis : int.MaxValue; int colWidth = scanned.Count.ToString().Length; var work = scanned.Take(limit).ToList(); if (_cfg.MaxApis > 0) Console.WriteLine($" [CFG] MaxApis={_cfg.MaxApis} — processing first {_cfg.MaxApis} endpoint(s) only.\n"); int maxConcurrency = Math.Max(1, _cfg.MaxConcurrency); Console.WriteLine($" [CFG] MaxConcurrency={maxConcurrency}\n"); // registeredByRoute, result, and console output are shared mutable // state touched from multiple concurrent tasks — every access to any // of them below is guarded by this single lock. var syncLock = new object(); int processed = 0; using var throttle = new SemaphoreSlim(maxConcurrency); var tasks = work.Select(async ep => { await throttle.WaitAsync(ct).ConfigureAwait(false); try { int n; lock (syncLock) { n = ++processed; } var row = new ApiSyncRow { Module = ep.Module, Entity = ep.Entity, ClassName = ep.ClassName, HttpMethod = ep.HttpMethod, Route = ep.Route }; // ── 3. Build the full RegisterApiSaveDTO ────────────────── RegisterApiSaveDTO dto; try { dto = BuildDto(ep); } catch (Exception ex) { row.Status = "FAILED"; row.Error = $"DTO build: {Truncate(ex.Message)}"; lock (syncLock) { result.Failed++; result.Rows.Add(row); PrintLine(n, colWidth, "FAIL", ep, ConsoleColor.Red); ReportMilestone(n); } return; } row.HasRequestJson = dto.RequestJson is not (null or "{}"); row.HasResponseJson = dto.ResponseJson is not null; row.ParamCount = ep.AllParams.Count; string normRoute = NormRoute(ep.Route); // ── DRY RUN ───────────────────────────────────────────────── if (_cfg.DryRun) { lock (syncLock) { bool exists = registeredByRoute.ContainsKey(normRoute); row.Status = exists ? "SKIPPED(EXISTS)" : "SKIPPED(NEW)"; result.Skipped++; result.Rows.Add(row); PrintLine(n, colWidth, "SKIP", ep, ConsoleColor.Yellow); ReportMilestone(n); } return; } // Check existence + (if unchanged) skip — done under the lock // since registeredByRoute is shared and may be written by // concurrent INSERTs below. RegisterApiDTO? existingApi; bool alreadySkipped = false; lock (syncLock) { registeredByRoute.TryGetValue(normRoute, out existingApi); if (existingApi != null && !string.IsNullOrEmpty(dto.ContentHash) && string.Equals(existingApi.ContentHash, dto.ContentHash, StringComparison.OrdinalIgnoreCase)) { row.Status = "SKIPPED(UNCHANGED)"; row.ApiId = existingApi.ApiId; result.Skipped++; result.Rows.Add(row); PrintLine(n, colWidth, $"SKIP ApiId={existingApi.ApiId}", ep, ConsoleColor.DarkGray); ReportMilestone(n); alreadySkipped = true; } } if (alreadySkipped) return; // ── INSERT path (new endpoint) ───────────────────────────── if (existingApi == null) { try { var saved = await _bll.SaveAsync(dto, login, ct).ConfigureAwait(false); row.Status = "SAVED"; row.ApiId = saved.ApiId; lock (syncLock) { result.Saved++; // Add to lookup so duplicate routes in the same run are caught registeredByRoute[normRoute] = new RegisterApiDTO { ApiId = saved.ApiId, ApiPath = ep.Route }; result.Rows.Add(row); PrintLine(n, colWidth, $"NEW ApiId={saved.ApiId}", ep, ConsoleColor.Green); ReportMilestone(n); } } catch (Exception ex) { row.Status = "FAILED"; row.Error = Truncate(ex.Message); lock (syncLock) { result.Failed++; result.Rows.Add(row); PrintLine(n, colWidth, "FAIL", ep, ConsoleColor.Red); ReportMilestone(n); } _log.LogWarning("Save failed [{Route}]: {Err}", ep.Route, ex.Message); } } // ── UPDATE path (already registered → refresh schema + desc) ─ else { try { int apiDomainId = await _bll.ResolveDomainIdAsync(dto, existingApi.ApiId, login, ct).ConfigureAwait(false); await _updater.UpdateAsync(dto, existingApi.ApiId, _cfg.TenantId, apiDomainId, login, ct).ConfigureAwait(false); await _bll.RepointCapabilityDomainAsync(existingApi.ApiId, apiDomainId, login, ct).ConfigureAwait(false); row.Status = "UPDATED"; row.ApiId = existingApi.ApiId; lock (syncLock) { result.Updated++; result.Rows.Add(row); PrintLine(n, colWidth, $"UPD ApiId={existingApi.ApiId}", ep, ConsoleColor.Cyan); ReportMilestone(n); } } catch (Exception ex) { row.Status = "FAILED"; row.Error = Truncate(ex.Message); lock (syncLock) { result.Failed++; result.Rows.Add(row); PrintLine(n, colWidth, "FAIL", ep, ConsoleColor.Red); ReportMilestone(n); } _log.LogWarning("Update failed [{Route}]: {Err}", ep.Route, ex.Message); } } } finally { throttle.Release(); } }); await Task.WhenAll(tasks).ConfigureAwait(false); // Local helper — must be called while holding syncLock. void ReportMilestone(int n) { if (n % _cfg.BatchSize == 0 && n < work.Count) { var elapsed = sw.Elapsed; var avgPerItem = elapsed / n; var eta = avgPerItem * (work.Count - n); Console.ForegroundColor = ConsoleColor.DarkGray; Console.WriteLine( $" ── {n}/{work.Count} processed │ " + $"saved={result.Saved} updated={result.Updated} failed={result.Failed} │ " + $"elapsed={elapsed:hh\\:mm\\:ss} │ ETA={eta:hh\\:mm\\:ss} ──"); Console.ResetColor(); } } // ── 4. Clean up domains orphaned by the module-domain consolidation ──── if (_cfg.CleanupOrphanedDomains && !_cfg.DryRun) { try { int removed = await _bll.CleanupOrphanedDomainsAsync(login, ct); Console.WriteLine($" [DB] Removed {removed} orphaned MAPIDOMAIN row(s) left over from the old per-API domain scheme."); } catch (Exception ex) { _log.LogWarning(ex, "Orphaned domain cleanup failed."); } } sw.Stop(); result.Elapsed = sw.Elapsed; return result; } // ═════════════════════════════════════════════════════════════════════ // DTO BUILDER — auto-generates ALL fields from source analysis // ═════════════════════════════════════════════════════════════════════ private RegisterApiSaveDTO BuildDto(ScannedEndpoint ep) { string title = ep.ClassName; string apiKey = Sanitize(title); if (string.IsNullOrWhiteSpace(apiKey)) apiKey = $"API_{title.ToUpperInvariant()}"; string httpVerb = ep.HttpMethod.ToUpperInvariant(); byte methodByte= HttpMethodByte(httpVerb); string module = ep.Module; string entity = ep.Entity; // ── Auto-generate description from multiple sources ─────────────── string description = BuildDescription(ep); string aboutApi = BuildAboutApi(ep); string keyFeatures = BuildKeyFeatures(ep); string useCases = BuildUseCases(ep); string quickStart = BuildQuickStart(ep); // MAPIOPERATION.SUMMARY and MAPIINTERFACE.INTERFACEDESCRIPTION are // fixed-width NVARCHAR columns — BLL method bodies can produce // arbitrarily long generated text, so clamp to the column size to // avoid "String or binary data would be truncated" SQL errors. string operationSummary = TruncateForColumn(BuildOperationSummary(ep), 500); string interfaceDesc = TruncateForColumn($"{httpVerb} {ep.Route}" + (ep.AllParams.Count > 0 ? " | Params: " + string.Join(", ", ep.AllParams.Select(p => $"{p.Name}({p.CsType})")) : ""), 1000); // ── Request JSON — from actual DTO properties ───────────────────── string requestJson; if (ep.RequestBodyFile != null) { // POST/PUT/PATCH: full DTO shape requestJson = DtoPropertyExtractor.BuildRequestJson(ep.RequestBodyFile); } else if (ep.AllParams.Count > 0) { // GET/DELETE: build from query params requestJson = DtoPropertyExtractor.BuildParamJson(ep.AllParams); } else { requestJson = "{}"; } // ── Response JSON — from response DTO + GB5 wrapper ─────────────── string responseJson = DtoPropertyExtractor.BuildResponseJson(entity, ep.ResponseDtoFile); // ── Parameters — first query param goes into MAPIPARAMETER ──────── // ALL params are encoded in RequestJson above var firstQueryParam = ep.AllParams.FirstOrDefault(p => p.Location == "Query"); // ── Gateway-relative path — module short-code prefix + route ────── string modulePrefix = _modulePrefixes.GetPrefix(module); string relativePath = modulePrefix + (ep.Route.StartsWith('/') ? ep.Route : "/" + ep.Route); // ── Content hash of the endpoint + its request/response DTO files — // used to detect drift on re-run (a DTO-only change must also // trigger an update, not just endpoint-file changes) ──────────── string contentHash = ComputeContentHash(ep); // ── Combined schema JSON (request + response contract in one document) ─ string schemaJson = $"{{\"request\":{requestJson},\"response\":{responseJson}}}"; return new RegisterApiSaveDTO { // ── MAPI ────────────────────────────────────────────────────── ApiKey = apiKey, ApiName = title, Description = description, AboutApi = aboutApi, KeyFeatures = keyFeatures, UseCases = useCases, QuickStart = quickStart, OwnerTeam = module, BusinessOwner = module, ApiKind = 0, // Internal ApiType = 0, // REST Criticality = DetermineCriticality(ep), LifecycleStatus = 1, // Active VisibilityLevel = 1, // Internal ApiProviderType = 0, ExposureType = 0, IsHeadlessEnabled= 0, RuntimeType = 1, // FastEndpoints Technology = 0, // .NET RegulatoryImpact = "None", RiskRating = "Low", SlaReference = "Standard-SLA-99.9", ApiRemarks = $"Auto-registered by ApiRegistrationTool | File: {Path.GetFileName(ep.FilePath)}", ApiSortOrder = 9999, RepoUrl = BuildRepoUrl(ep.FilePath), PipelineRef = "main", Geography = "Global", // ── MAPIVERSION ─────────────────────────────────────────────── VersionNumber = "1.0.0", VersionStatus = 0, // Draft IsBreaking = 0, VersionFromDate = DateTime.UtcNow, VersionToDate = null, SunsetDate = null, VersionDescription = $"Initial auto-registered version of {title}", VersionNotes = $"Auto-registered from {ep.FilePath}", VersionRemarks = "Generated by ApiRegistrationTool — review before activating", VersionSortOrder = 9999, // ── MAPIINTERFACE ───────────────────────────────────────────── InterfaceName = $"{title} Interface", InterfaceType = 0, // REST SpecType = 3, // Manual InterfaceMethod = methodByte, ApiPath = ep.Route, InterfaceDescription = interfaceDesc, SpecLocation = ep.Route, InterfaceSource = "ApiRegistrationTool source-scan", InterfaceRemarks = $"Namespace: {ep.Namespace}", ContentHash = contentHash, LastParsedAt = DateTime.UtcNow, ParsedSuccessfully = 1, InterfaceSortOrder = 9999, // ── MAPISCHEMA ──────────────────────────────────────────────── SchemaName = $"{title} Schema", SchemaDescription = $"Auto-generated schema for {httpVerb} {ep.Route}. " + $"Request DTO: {ep.RequestBodyType ?? "query-params"}. " + $"Response DTO: {ep.ResponseDtoType ?? entity + "DTO"}.", SpecificationSource = 2, // RepoPath SpecificationUrl = ep.FilePath, RequestJson = requestJson, ResponseJson = responseJson, SchemaJson = schemaJson, SchemaRemarks = "Auto-generated by ApiRegistrationTool — update after code changes", SchemaSortOrder = 9999, // ── MAPIDOMAIN — one shared domain per module (~36 total) ────── DomainName = module, DomainRemarks = $"Domain grouping for the {module} module", DomainSortOrder = 9999, // ── MAPICAPABILITY ──────────────────────────────────────────── CapabilityName = $"{entity} Capability", CapabilityDescription = $"Manages all {entity} operations within the {module} module.", CapabilityRemarks = $"Source: {ep.Namespace}", CapabilitySortOrder = 9999, // ── MAPIARTIFACT — source file is the artifact ──────────────── ArtifactTitle = $"{title} — {httpVerb} {ep.Route}", ArtifactType = 2, // Specification ArtifactLocation = ep.FilePath, ArtifactRemarks = $"FastEndpoints class {ep.ClassName} in {ep.Namespace}", ArtifactSortOrder = 9999, // ── MAPIOPERATION ───────────────────────────────────────────── OperationKey = ep.ClassName, OperationMethod = httpVerb, OperationPath = relativePath, OperationSummary = operationSummary, OperationRemarks = $"Gateway prefix: {modulePrefix} | Source route: {ep.Route}", OperationSortOrder = 1, // ── MAPIPARAMETER — first query param (ALL params in RequestJson) ─ ParameterName = firstQueryParam?.Name, ParameterInLocation = firstQueryParam != null ? "Query" : null, ParameterDataType = firstQueryParam != null ? MapCsType(firstQueryParam.CsType) : null, ParameterIsRequired = firstQueryParam != null ? (byte)(firstQueryParam.Required ? 1 : 0) : (byte)0, ParameterRemarks = firstQueryParam != null ? $"Query parameter of endpoint {ep.ClassName}" : null, ParameterSortOrder = 1, // ── MAPIEXPOSUREPOLICY ──────────────────────────────────────── AllowPartnerAccess = 0, AllowPublicAccess = (byte)(ep.AllowAnonymous ? 1 : 0), BillingCategory = ep.RequiresAuth ? "Authenticated" : "Internal", PolicySlaReference = "Standard-SLA-99.9", PolicyRemarks = ep.AllowAnonymous ? "Public endpoint — AllowAnonymous" : "Internal — requires Login header", PolicySortOrder = 9999 }; } // ── Description builders ───────────────────────────────────────────── private static string BuildDescription(ScannedEndpoint ep) { var sb = new StringBuilder(); sb.Append($"{ep.ClassName} — {ep.HttpMethod} {ep.Route}. "); if (!string.IsNullOrWhiteSpace(ep.BllMethodDescription)) sb.Append($"Flow: {ep.BllMethodDescription.Replace(" | ", "; ")}. "); else sb.Append($"Performs {InferOperation(ep.ClassName)} operations on {ep.Entity} data. "); if (ep.RequestBodyType != null) sb.Append($"Accepts {ep.RequestBodyType} in request body. "); else if (ep.AllParams.Count > 0) sb.Append($"Query params: {string.Join(", ", ep.AllParams.Select(p => p.Name))}. "); if (ep.ResponseDtoType != null) sb.Append($"Returns {ep.ResponseDtoType}."); return sb.ToString().Trim(); } private static string BuildAboutApi(ScannedEndpoint ep) { string op = InferOperation(ep.ClassName); var sb = new StringBuilder(); sb.Append($"This API {op}s {ep.Entity} records in the {ep.Module} module. "); sb.Append($"It is implemented as a FastEndpoints endpoint ({ep.Namespace}). "); if (ep.BllFile != null) sb.Append($"Business logic is handled by {Path.GetFileNameWithoutExtension(ep.BllFile)}. "); if (ep.RequestBodyFile != null) sb.Append($"The request body follows the {ep.RequestBodyType} DTO contract. "); if (ep.ResponseDtoType != null) sb.Append($"The response body returns a {ep.ResponseDtoType} wrapped in the GB5 standard response envelope."); return sb.ToString().Trim(); } private static string BuildKeyFeatures(ScannedEndpoint ep) { var features = new List { $"HTTP {ep.HttpMethod} — {ep.Route}", $"Module: {ep.Module} | Entity: {ep.Entity}" }; if (ep.AllowAnonymous) features.Add("Public endpoint (AllowAnonymous)"); if (ep.RequiresAuth) features.Add("Requires authentication"); if (ep.RequestBodyType != null) features.Add($"Request body: {ep.RequestBodyType}"); if (ep.AllParams.Count > 0) features.Add($"Parameters: {string.Join(", ", ep.AllParams.Select(p => $"{p.Name}:{p.CsType}"))}"); if (ep.ResponseDtoType != null) features.Add($"Response: {ep.ResponseDtoType}"); if (ep.BllMethodName != null) features.Add($"BLL method: {ep.BllMethodName}"); return string.Join(" | ", features); } private static string BuildUseCases(ScannedEndpoint ep) { string op = InferOperation(ep.ClassName); string entity = ep.Entity; return ep.HttpMethod switch { "GET" => $"Retrieve {entity} details. Use to load {entity} data for display or reporting.", "POST" => $"Create or save {entity} records. Use when submitting new {entity} data.", "PUT" => $"Update an existing {entity} record. Use to modify {entity} properties.", "DELETE" => $"Remove a {entity} record. Use to delete {entity} permanently.", _ => $"{op} {entity} data via the {ep.Module} service." }; } private static string BuildQuickStart(ScannedEndpoint ep) { var sb = new StringBuilder($"Call {ep.HttpMethod} {ep.Route}"); if (ep.AllParams.Any(p => p.Location == "Query")) { var qp = ep.AllParams.Where(p => p.Location == "Query").ToList(); sb.Append("?" + string.Join("&", qp.Select(p => $"{p.Name}=<{p.CsType}>"))); } sb.Append(" with header: Login = ."); if (ep.RequestBodyType != null) sb.Append($" Request body must be a JSON-serialised {ep.RequestBodyType}."); return sb.ToString(); } private static string BuildOperationSummary(ScannedEndpoint ep) { if (!string.IsNullOrWhiteSpace(ep.BllMethodDescription)) return $"{ep.ClassName}: {ep.BllMethodDescription.Replace(" | ", "; ")}"; string op = InferOperation(ep.ClassName); return $"{op} {ep.Entity} via {ep.HttpMethod} {ep.Route}" + (ep.AllParams.Count > 0 ? $" | Params: {string.Join(", ", ep.AllParams.Select(p => p.Name))}" : ""); } // ── Misc helpers ────────────────────────────────────────────────────── private static string InferOperation(string className) { if (className.StartsWith("Save", StringComparison.OrdinalIgnoreCase)) return "save"; if (className.StartsWith("Get", StringComparison.OrdinalIgnoreCase)) return "retrieve"; if (className.StartsWith("Delete", StringComparison.OrdinalIgnoreCase)) return "delete"; if (className.StartsWith("Update", StringComparison.OrdinalIgnoreCase)) return "update"; if (className.StartsWith("List", StringComparison.OrdinalIgnoreCase)) return "list"; return "process"; } private static byte DetermineCriticality(ScannedEndpoint ep) { // DELETE and auth-required are High; anonymous GET are Low; rest are Medium if (ep.HttpMethod == "DELETE" || ep.RequiresAuth) return 2; // High if (ep.AllowAnonymous && ep.HttpMethod == "GET") return 0; // Low return 1; // Medium } private LoginDTO BuildLogin() => new() { ClientId = _cfg.TenantId, UserId = _cfg.UserId, UserName = "ApiRegistrationTool", UserCode = "TOOL", SourceType = 2, DatabaseName = "Gb5System", WorkDate = DateTime.UtcNow }; private static string NormRoute(string route) => route.Trim().TrimStart('/').ToLowerInvariant(); private static string Sanitize(string input) { var chars = input.Where(c => char.IsLetterOrDigit(c) || c == '_') .Select(char.ToUpperInvariant).ToArray(); int start = 0; while (start < chars.Length && !char.IsLetter(chars[start])) start++; return new string(chars, start, chars.Length - start); } private static byte HttpMethodByte(string m) => m switch { "GET" => 0, "POST" => 1, "PUT" => 2, "PATCH" => 3, "DELETE"=> 4, _ => 0 }; private static string MapCsType(string cs) => cs.ToLowerInvariant() switch { "int" or "int32" or "long" or "short" => "integer", "bool" or "boolean" => "boolean", "decimal" or "double" or "float" => "number", "datetime" => "string (date-time)", _ => "string" }; private static string ComputeContentHash(string filePath) { try { byte[] bytes = File.ReadAllBytes(filePath); return Convert.ToHexString(SHA256.HashData(bytes)); } catch { return string.Empty; } } // Combines the endpoint file's hash with its request/response DTO files' // hashes, so adding/removing/changing a DTO property is detected as a // real change even when the endpoint .cs file itself is untouched. private static string ComputeContentHash(ScannedEndpoint ep) { var combined = new StringBuilder(); combined.Append(ComputeContentHash(ep.FilePath)); if (ep.RequestBodyFile != null && !ep.RequestBodyFile.Equals(ep.FilePath, StringComparison.OrdinalIgnoreCase)) combined.Append(ComputeContentHash(ep.RequestBodyFile)); if (ep.ResponseDtoFile != null && !ep.ResponseDtoFile.Equals(ep.FilePath, StringComparison.OrdinalIgnoreCase) && !ep.ResponseDtoFile.Equals(ep.RequestBodyFile, StringComparison.OrdinalIgnoreCase)) combined.Append(ComputeContentHash(ep.ResponseDtoFile)); return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(combined.ToString()))); } private static string BuildRepoUrl(string filePath) { string normalized = filePath.Replace('\\', '/'); int idx = normalized.IndexOf("/GB5Solution/", StringComparison.OrdinalIgnoreCase); string relative = idx >= 0 ? normalized[(idx + 1)..] : normalized; return $"https://gitlab.com/vvunisoft/gb5/-/blob/dev/{relative}"; } private static string Truncate(string s) => s.Length > 250 ? s[..250] + " …" : s; // Clamp generated text to a fixed-width SQL NVARCHAR column's max length, // leaving room for an ellipsis marker so it's clear the value was cut. private static string TruncateForColumn(string s, int maxLength) { if (s.Length <= maxLength) return s; const string suffix = " …"; return s[..(maxLength - suffix.Length)] + suffix; } private static void PrintLine(int n, int w, string status, ScannedEndpoint ep, ConsoleColor color) { Console.ForegroundColor = color; Console.Write($" [{n.ToString().PadLeft(w)}] {status,-22}"); Console.ResetColor(); Console.WriteLine($" [{ep.Module,-12}] {ep.HttpMethod,-7} {ep.Route}"); } }