diff --git a/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md b/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md index 4c07d9c9..d3feb3ab 100644 --- a/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md +++ b/src/CrestApps.Core.Docs/docs/changelog/1.1.0.md @@ -18,3 +18,7 @@ page will be updated as changes land after 1.0.0. ## Highlights - upgrades the framework's dependency baseline, including YesSql 6.0 (new `ISession.SaveAsync` signature), the Model Context Protocol 2.0 packages, the GitHub Copilot SDK 1.0.8 (new `PermissionsApi.SetAllowAllAsync` mode-based API), Anthropic 12.39.0, OllamaSharp 5.4.30, the .NET 10.0.10 runtime/extension packages, and the `Microsoft.Extensions.AI` 10.8.3 packages + +## Fixes + +- fixes AI document citation links throwing `System.InvalidOperationException: The following endpoints with a duplicate endpoint name were found` in multi-tenant hosts. `DocumentAIReferenceLinkResolver` no longer resolves the download link by endpoint name (which forced ASP.NET Core to validate global endpoint-name uniqueness and failed when other modules registered the same named API endpoints across tenants); it now builds the path directly from the static `DownloadAIDocument.RoutePattern`, respecting the request `PathBase`. diff --git a/src/Primitives/CrestApps.Core.AI.Documents/Endpoints/DownloadAIDocument.cs b/src/Primitives/CrestApps.Core.AI.Documents/Endpoints/DownloadAIDocument.cs index a5b593d0..1f091385 100644 --- a/src/Primitives/CrestApps.Core.AI.Documents/Endpoints/DownloadAIDocument.cs +++ b/src/Primitives/CrestApps.Core.AI.Documents/Endpoints/DownloadAIDocument.cs @@ -17,6 +17,11 @@ public static class DownloadAIDocument { public const string DefaultRouteName = "DownloadAIDocument"; + /// + /// The static route pattern mapped for the AI document download endpoint. + /// + public const string RoutePattern = "ai/documents/{documentId}/download"; + /// /// Adds the shared AI document download endpoint used by citation links. /// @@ -24,7 +29,7 @@ public static class DownloadAIDocument /// The route name. public static IEndpointRouteBuilder AddDownloadAIDocumentEndpoint(this IEndpointRouteBuilder builder, string routeName = DefaultRouteName) { - var endpoint = builder.MapGet("ai/documents/{documentId}/download", HandleAsync); + var endpoint = builder.MapGet(RoutePattern, HandleAsync); if (!string.IsNullOrEmpty(routeName)) { diff --git a/src/Primitives/CrestApps.Core.AI.Documents/Services/DocumentAIReferenceLinkResolver.cs b/src/Primitives/CrestApps.Core.AI.Documents/Services/DocumentAIReferenceLinkResolver.cs index bebf75be..27bf3843 100644 --- a/src/Primitives/CrestApps.Core.AI.Documents/Services/DocumentAIReferenceLinkResolver.cs +++ b/src/Primitives/CrestApps.Core.AI.Documents/Services/DocumentAIReferenceLinkResolver.cs @@ -1,7 +1,6 @@ using CrestApps.Core.AI.Documents.Endpoints; using CrestApps.Core.AI.Profiles; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; namespace CrestApps.Core.AI.Documents.Services; @@ -10,19 +9,14 @@ namespace CrestApps.Core.AI.Documents.Services; /// public sealed class DocumentAIReferenceLinkResolver : IAIReferenceLinkResolver { - private readonly LinkGenerator _linkGenerator; private readonly IHttpContextAccessor _httpContextAccessor; /// /// Initializes a new instance of the class. /// - /// The link generator. /// The http context accessor. - public DocumentAIReferenceLinkResolver( - LinkGenerator linkGenerator, - IHttpContextAccessor httpContextAccessor) + public DocumentAIReferenceLinkResolver(IHttpContextAccessor httpContextAccessor) { - _linkGenerator = linkGenerator; _httpContextAccessor = httpContextAccessor; } @@ -38,12 +32,17 @@ public string ResolveLink(string referenceId, IDictionary metada return null; } - return _linkGenerator.GetPathByName( - _httpContextAccessor.HttpContext, - DownloadAIDocument.DefaultRouteName, - new RouteValueDictionary - { - ["documentId"] = referenceId, - }); + // Build the path directly from the static route pattern instead of resolving by endpoint + // name. Name-based link generation forces ASP.NET Core to validate that every endpoint name + // is globally unique, which throws in multi-tenant hosts where other modules register the + // same named API endpoints across tenants. + var relativePath = "/" + DownloadAIDocument.RoutePattern.Replace( + "{documentId}", + Uri.EscapeDataString(referenceId), + StringComparison.Ordinal); + + var pathBase = _httpContextAccessor.HttpContext?.Request.PathBase ?? PathString.Empty; + + return pathBase.Add(relativePath).Value; } }