diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index 2004178ccd..2973a664f6 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -168,6 +168,7 @@ public void ConfigureServiceContainer(ICollection dbContextTypes) _services.TryAddScoped(); _services.TryAddScoped(); _services.TryAddScoped(); + _services.TryAddSingleton(); } private void AddMiddlewareLayer() diff --git a/src/JsonApiDotNetCore/Serialization/Response/IDocumentDescriptionLinkProvider.cs b/src/JsonApiDotNetCore/Serialization/Response/IDocumentDescriptionLinkProvider.cs new file mode 100644 index 0000000000..54a5ae36b3 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Response/IDocumentDescriptionLinkProvider.cs @@ -0,0 +1,17 @@ +using JsonApiDotNetCore.Configuration; + +namespace JsonApiDotNetCore.Serialization.Response; + +/// +/// Provides the value for the "describedby" link in https://jsonapi.org/format/#document-top-level. +/// +public interface IDocumentDescriptionLinkProvider +{ + /// + /// Gets the URL for the "describedby" link, or null when unavailable. + /// + /// + /// The returned URL can be absolute or relative. If possible, it gets converted based on . + /// + string? GetUrl(); +} diff --git a/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs b/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs index c085507365..7740141002 100644 --- a/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs @@ -28,6 +28,8 @@ public class LinkBuilder : ILinkBuilder private static readonly string GetRelationshipControllerActionName = NoAsyncSuffix(nameof(BaseJsonApiController, int>.GetRelationshipAsync)); + private static readonly UriNormalizer UriNormalizer = new(); + private readonly IJsonApiOptions _options; private readonly IJsonApiRequest _request; private readonly IPaginationContext _paginationContext; @@ -35,6 +37,7 @@ public class LinkBuilder : ILinkBuilder private readonly LinkGenerator _linkGenerator; private readonly IControllerResourceMapping _controllerResourceMapping; private readonly IPaginationParser _paginationParser; + private readonly IDocumentDescriptionLinkProvider _documentDescriptionLinkProvider; private HttpContext HttpContext { @@ -50,7 +53,8 @@ private HttpContext HttpContext } public LinkBuilder(IJsonApiOptions options, IJsonApiRequest request, IPaginationContext paginationContext, IHttpContextAccessor httpContextAccessor, - LinkGenerator linkGenerator, IControllerResourceMapping controllerResourceMapping, IPaginationParser paginationParser) + LinkGenerator linkGenerator, IControllerResourceMapping controllerResourceMapping, IPaginationParser paginationParser, + IDocumentDescriptionLinkProvider documentDescriptionLinkProvider) { ArgumentGuard.NotNull(options); ArgumentGuard.NotNull(request); @@ -58,6 +62,7 @@ public LinkBuilder(IJsonApiOptions options, IJsonApiRequest request, IPagination ArgumentGuard.NotNull(linkGenerator); ArgumentGuard.NotNull(controllerResourceMapping); ArgumentGuard.NotNull(paginationParser); + ArgumentGuard.NotNull(documentDescriptionLinkProvider); _options = options; _request = request; @@ -66,6 +71,7 @@ public LinkBuilder(IJsonApiOptions options, IJsonApiRequest request, IPagination _linkGenerator = linkGenerator; _controllerResourceMapping = controllerResourceMapping; _paginationParser = paginationParser; + _documentDescriptionLinkProvider = documentDescriptionLinkProvider; } private static string NoAsyncSuffix(string actionName) @@ -94,6 +100,14 @@ private static string NoAsyncSuffix(string actionName) SetPaginationInTopLevelLinks(resourceType!, links); } + string? documentDescriptionUrl = _documentDescriptionLinkProvider.GetUrl(); + + if (!string.IsNullOrEmpty(documentDescriptionUrl)) + { + var requestUri = new Uri(HttpContext.Request.GetEncodedUrl()); + links.DescribedBy = UriNormalizer.Normalize(documentDescriptionUrl, _options.UseRelativeLinks, requestUri); + } + return links.HasValue() ? links : null; } diff --git a/src/JsonApiDotNetCore/Serialization/Response/NoDocumentDescriptionLinkProvider.cs b/src/JsonApiDotNetCore/Serialization/Response/NoDocumentDescriptionLinkProvider.cs new file mode 100644 index 0000000000..c419e1ae35 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Response/NoDocumentDescriptionLinkProvider.cs @@ -0,0 +1,15 @@ +namespace JsonApiDotNetCore.Serialization.Response; + +/// +/// Provides no value for the "describedby" link in https://jsonapi.org/format/#document-top-level. +/// +public sealed class NoDocumentDescriptionLinkProvider : IDocumentDescriptionLinkProvider +{ + /// + /// Always returns null. + /// + public string? GetUrl() + { + return null; + } +} diff --git a/src/JsonApiDotNetCore/Serialization/Response/UriNormalizer.cs b/src/JsonApiDotNetCore/Serialization/Response/UriNormalizer.cs new file mode 100644 index 0000000000..5b2517f4b0 --- /dev/null +++ b/src/JsonApiDotNetCore/Serialization/Response/UriNormalizer.cs @@ -0,0 +1,80 @@ +namespace JsonApiDotNetCore.Serialization.Response; + +internal sealed class UriNormalizer +{ + /// + /// Converts a URL to absolute or relative format, if possible. + /// + /// + /// The absolute or relative URL to normalize. + /// + /// + /// Whether to convert to absolute or relative format. + /// + /// + /// The URL of the current HTTP request, whose path and query string are discarded. + /// + public string Normalize(string sourceUrl, bool preferRelative, Uri requestUri) + { + var sourceUri = new Uri(sourceUrl, UriKind.RelativeOrAbsolute); + Uri baseUri = RemovePathFromAbsoluteUri(requestUri); + + if (!sourceUri.IsAbsoluteUri && !preferRelative) + { + var absoluteUri = new Uri(baseUri, sourceUrl); + return absoluteUri.AbsoluteUri; + } + + if (sourceUri.IsAbsoluteUri && preferRelative) + { + if (AreSameServer(baseUri, sourceUri)) + { + Uri relativeUri = baseUri.MakeRelativeUri(sourceUri); + return relativeUri.ToString(); + } + } + + return sourceUrl; + } + + private static Uri RemovePathFromAbsoluteUri(Uri uri) + { + var requestUriBuilder = new UriBuilder(uri) + { + Path = null + }; + + return requestUriBuilder.Uri; + } + + private static bool AreSameServer(Uri left, Uri right) + { + // Custom implementation because Uri.Equals() ignores the casing of username/password. + + string leftScheme = left.GetComponents(UriComponents.Scheme, UriFormat.UriEscaped); + string rightScheme = right.GetComponents(UriComponents.Scheme, UriFormat.UriEscaped); + + if (!string.Equals(leftScheme, rightScheme, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + string leftServer = left.GetComponents(UriComponents.HostAndPort, UriFormat.UriEscaped); + string rightServer = right.GetComponents(UriComponents.HostAndPort, UriFormat.UriEscaped); + + if (!string.Equals(leftServer, rightServer, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + string leftUserInfo = left.GetComponents(UriComponents.UserInfo, UriFormat.UriEscaped); + string rightUserInfo = right.GetComponents(UriComponents.UserInfo, UriFormat.UriEscaped); + + if (!string.Equals(leftUserInfo, rightUserInfo)) + { + return false; + } + + return true; + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs index 3221215461..74e5aaa25b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs @@ -58,6 +58,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); @@ -101,6 +102,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); responseDocument.Data.ManyValue.ShouldHaveCount(1); @@ -167,6 +169,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{photo.Album.StringId}"; @@ -211,6 +214,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); responseDocument.Data.ManyValue.ShouldHaveCount(1); @@ -259,6 +263,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Links.Should().BeNull(); @@ -293,6 +298,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); responseDocument.Data.ManyValue.ShouldHaveCount(1); responseDocument.Data.ManyValue[0].Links.Should().BeNull(); @@ -354,6 +360,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); @@ -437,6 +444,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); string photoLink = $"{HostPrefix}{PathPrefix}/photos/{existingPhoto.StringId}"; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs index b6060e3d7c..6ba9636128 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs @@ -58,6 +58,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); @@ -101,6 +102,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); responseDocument.Data.ManyValue.ShouldHaveCount(1); @@ -167,6 +169,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{photo.Album.StringId}"; @@ -211,6 +214,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); responseDocument.Data.ManyValue.ShouldHaveCount(1); @@ -259,6 +263,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Links.Should().BeNull(); @@ -293,6 +298,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); responseDocument.Data.ManyValue.ShouldHaveCount(1); responseDocument.Data.ManyValue[0].Links.Should().BeNull(); @@ -354,6 +360,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); @@ -437,6 +444,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); string photoLink = $"{HostPrefix}{PathPrefix}/photos/{existingPhoto.StringId}"; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/DocumentDescriptionLinkTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/DocumentDescriptionLinkTests.cs new file mode 100644 index 0000000000..a2183247e5 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/DocumentDescriptionLinkTests.cs @@ -0,0 +1,100 @@ +using System.Net; +using FluentAssertions; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Serialization.Objects; +using JsonApiDotNetCore.Serialization.Response; +using Microsoft.Extensions.DependencyInjection; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.IntegrationTests.Links; + +public sealed class DocumentDescriptionLinkTests : IClassFixture, LinksDbContext>> +{ + private readonly IntegrationTestContext, LinksDbContext> _testContext; + + public DocumentDescriptionLinkTests(IntegrationTestContext, LinksDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + + testContext.ConfigureServices(services => services.AddSingleton()); + } + + [Fact] + public async Task Get_primary_resource_by_ID_converts_relative_documentation_link_to_absolute() + { + // Arrange + var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); + options.UseRelativeLinks = false; + + var provider = (TestDocumentDescriptionLinkProvider)_testContext.Factory.Services.GetRequiredService(); + provider.Link = "description/json-schema?version=v1.0"; + + string route = $"/photos/{Unknown.StringId.For()}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.DescribedBy.Should().Be("http://localhost/description/json-schema?version=v1.0"); + } + + [Fact] + public async Task Get_primary_resource_by_ID_converts_absolute_documentation_link_to_relative() + { + // Arrange + var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); + options.UseRelativeLinks = true; + + var provider = (TestDocumentDescriptionLinkProvider)_testContext.Factory.Services.GetRequiredService(); + provider.Link = "http://localhost:80/description/json-schema?version=v1.0"; + + string route = $"/photos/{Unknown.StringId.For()}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.DescribedBy.Should().Be("description/json-schema?version=v1.0"); + } + + [Fact] + public async Task Get_primary_resource_by_ID_cannot_convert_absolute_documentation_link_to_relative() + { + // Arrange + var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); + options.UseRelativeLinks = true; + + var provider = (TestDocumentDescriptionLinkProvider)_testContext.Factory.Services.GetRequiredService(); + provider.Link = "https://docs.api.com/description/json-schema?version=v1.0"; + + string route = $"/photos/{Unknown.StringId.For()}"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.ShouldHaveStatusCode(HttpStatusCode.NotFound); + + responseDocument.Links.ShouldNotBeNull(); + responseDocument.Links.DescribedBy.Should().Be("https://docs.api.com/description/json-schema?version=v1.0"); + } + + private sealed class TestDocumentDescriptionLinkProvider : IDocumentDescriptionLinkProvider + { + public string? Link { get; set; } + + public string? GetUrl() + { + return Link; + } + } +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs index 79a2b8408a..604f9c3e90 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithNamespaceTests.cs @@ -58,6 +58,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); @@ -101,6 +102,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); responseDocument.Data.ManyValue.ShouldHaveCount(1); @@ -167,6 +169,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{photo.Album.StringId}"; @@ -211,6 +214,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); responseDocument.Data.ManyValue.ShouldHaveCount(1); @@ -259,6 +263,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Links.Should().BeNull(); @@ -293,6 +298,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); responseDocument.Data.ManyValue.ShouldHaveCount(1); responseDocument.Data.ManyValue[0].Links.Should().BeNull(); @@ -354,6 +360,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); @@ -437,6 +444,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); string photoLink = $"{HostPrefix}{PathPrefix}/photos/{existingPhoto.StringId}"; diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs index 7e7e1d8f7a..6ce1effd7c 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/Links/RelativeLinksWithoutNamespaceTests.cs @@ -58,6 +58,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); @@ -101,6 +102,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); responseDocument.Data.ManyValue.ShouldHaveCount(1); @@ -167,6 +169,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{photo.Album.StringId}"; @@ -211,6 +214,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); responseDocument.Data.ManyValue.ShouldHaveCount(1); @@ -259,6 +263,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Links.Should().BeNull(); @@ -293,6 +298,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Last.Should().Be(responseDocument.Links.Self); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); responseDocument.Data.ManyValue.ShouldHaveCount(1); responseDocument.Data.ManyValue[0].Links.Should().BeNull(); @@ -354,6 +360,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); responseDocument.Data.SingleValue.ShouldNotBeNull(); responseDocument.Data.SingleValue.Links.ShouldNotBeNull(); @@ -437,6 +444,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Last.Should().BeNull(); responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); + responseDocument.Links.DescribedBy.Should().BeNull(); string photoLink = $"{HostPrefix}{PathPrefix}/photos/{existingPhoto.StringId}"; diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs index 392d61709a..94b9cb9386 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/Links/LinkInclusionTests.cs @@ -88,7 +88,10 @@ public void Applies_cascading_settings_for_top_level_links(LinkTypes linksInReso var linkGenerator = new FakeLinkGenerator(); var controllerResourceMapping = new FakeControllerResourceMapping(); var paginationParser = new PaginationParser(); - var linkBuilder = new LinkBuilder(options, request, paginationContext, httpContextAccessor, linkGenerator, controllerResourceMapping, paginationParser); + var documentDescriptionLinkProvider = new NoDocumentDescriptionLinkProvider(); + + var linkBuilder = new LinkBuilder(options, request, paginationContext, httpContextAccessor, linkGenerator, controllerResourceMapping, paginationParser, + documentDescriptionLinkProvider); // Act TopLevelLinks? topLevelLinks = linkBuilder.GetTopLevelLinks(); @@ -171,7 +174,10 @@ public void Applies_cascading_settings_for_resource_links(LinkTypes linksInResou var linkGenerator = new FakeLinkGenerator(); var controllerResourceMapping = new FakeControllerResourceMapping(); var paginationParser = new PaginationParser(); - var linkBuilder = new LinkBuilder(options, request, paginationContext, httpContextAccessor, linkGenerator, controllerResourceMapping, paginationParser); + var documentDescriptionLinkProvider = new NoDocumentDescriptionLinkProvider(); + + var linkBuilder = new LinkBuilder(options, request, paginationContext, httpContextAccessor, linkGenerator, controllerResourceMapping, paginationParser, + documentDescriptionLinkProvider); // Act ResourceLinks? resourceLinks = linkBuilder.GetResourceLinks(exampleResourceType, new ExampleResource()); @@ -332,7 +338,10 @@ public void Applies_cascading_settings_for_relationship_links(LinkTypes linksInR var linkGenerator = new FakeLinkGenerator(); var controllerResourceMapping = new FakeControllerResourceMapping(); var paginationParser = new PaginationParser(); - var linkBuilder = new LinkBuilder(options, request, paginationContext, httpContextAccessor, linkGenerator, controllerResourceMapping, paginationParser); + var documentDescriptionLinkProvider = new NoDocumentDescriptionLinkProvider(); + + var linkBuilder = new LinkBuilder(options, request, paginationContext, httpContextAccessor, linkGenerator, controllerResourceMapping, paginationParser, + documentDescriptionLinkProvider); var relationship = new HasOneAttribute { diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Links/UriNormalizerTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Links/UriNormalizerTests.cs new file mode 100644 index 0000000000..545866c6ff --- /dev/null +++ b/test/JsonApiDotNetCoreTests/UnitTests/Links/UriNormalizerTests.cs @@ -0,0 +1,78 @@ +using FluentAssertions; +using JsonApiDotNetCore.Serialization.Response; +using Xunit; + +namespace JsonApiDotNetCoreTests.UnitTests.Links; + +public sealed class UriNormalizerTests +{ + [Theory] + [InlineData("some/path", "http://localhost")] + [InlineData("some?version=1", "http://localhost")] + public void Keeps_relative_URL_relative(string sourceUrl, string requestUrl) + { + // Arrange + var normalizer = new UriNormalizer(); + + // Act + string result = normalizer.Normalize(sourceUrl, true, new Uri(requestUrl)); + + // Assert + result.Should().Be(sourceUrl); + } + + [Theory] + [InlineData("some/path", "http://localhost", "http://localhost/some/path")] + [InlineData("some/path", "https://api-server.com", "https://api-server.com/some/path")] + [InlineData("some/path", "https://user:pass@api-server.com:9999", "https://user:pass@api-server.com:9999/some/path")] + [InlineData("some/path", "http://localhost/api/articles?debug=true#anchor", "http://localhost/some/path")] + [InlineData("some?version=1", "http://localhost/api/articles/1?debug=true#anchor", "http://localhost/some?version=1")] + public void Makes_relative_URL_absolute(string sourceUrl, string requestUrl, string expected) + { + // Arrange + var normalizer = new UriNormalizer(); + + // Act + string result = normalizer.Normalize(sourceUrl, false, new Uri(requestUrl)); + + // Assert + result.Should().Be(expected); + } + + [Theory] + [InlineData("http://localhost/some/path", "http://api-server.com")] + [InlineData("http://localhost/some/path", "https://localhost")] + [InlineData("http://localhost:8080/some/path", "http://localhost")] + [InlineData("http://user:pass@localhost/some/path?version=1", "http://localhost")] + [InlineData("http://user:pass@localhost/some/path?version=1", "http://USER:PASS@localhost")] + public void Keeps_absolute_URL_absolute(string sourceUrl, string requestUrl) + { + // Arrange + var normalizer = new UriNormalizer(); + + // Act + string result = normalizer.Normalize(sourceUrl, true, new Uri(requestUrl)); + + // Assert + result.Should().Be(sourceUrl); + } + + [Theory] + [InlineData("http://localhost/some/path", "http://localhost/api/articles/1", "some/path")] + [InlineData("http://api-server.com/some/path", "http://api-server.com/api/articles/1", "some/path")] + [InlineData("https://localhost/some/path", "https://localhost/api/articles/1", "some/path")] + [InlineData("https://localhost:443/some/path", "https://localhost/api/articles/1", "some/path")] + [InlineData("https://localhost/some/path", "https://localhost:443/api/articles/1", "some/path")] + [InlineData("HTTPS://LOCALHOST/some/path", "https://localhost:443/api/articles/1", "some/path")] + public void Makes_absolute_URL_relative(string sourceUrl, string requestUrl, string expected) + { + // Arrange + var normalizer = new UriNormalizer(); + + // Act + string result = normalizer.Normalize(sourceUrl, true, new Uri(requestUrl)); + + // Assert + result.Should().Be(expected); + } +}