Skip to content

Commit 6db37de

Browse files
committed
Enable setting the "describedby" top-level link by implementing IDocumentDescriptionLinkProvider
1 parent ae0129e commit 6db37de

12 files changed

+350
-4
lines changed

Diff for: src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs

+1
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ public void ConfigureServiceContainer(ICollection<Type> dbContextTypes)
168168
_services.TryAddScoped<ISparseFieldSetCache, SparseFieldSetCache>();
169169
_services.TryAddScoped<IQueryLayerComposer, QueryLayerComposer>();
170170
_services.TryAddScoped<IInverseNavigationResolver, InverseNavigationResolver>();
171+
_services.TryAddSingleton<IDocumentDescriptionLinkProvider, NoDocumentDescriptionLinkProvider>();
171172
}
172173

173174
private void AddMiddlewareLayer()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
using JsonApiDotNetCore.Configuration;
2+
3+
namespace JsonApiDotNetCore.Serialization.Response;
4+
5+
/// <summary>
6+
/// Provides the value for the "describedby" link in https://jsonapi.org/format/#document-top-level.
7+
/// </summary>
8+
public interface IDocumentDescriptionLinkProvider
9+
{
10+
/// <summary>
11+
/// Gets the URL for the "describedby" link, or <c>null</c> when unavailable.
12+
/// </summary>
13+
/// <remarks>
14+
/// The returned URL can be absolute or relative. If possible, it gets converted based on <see cref="IJsonApiOptions.UseRelativeLinks" />.
15+
/// </remarks>
16+
string? GetUrl();
17+
}

Diff for: src/JsonApiDotNetCore/Serialization/Response/LinkBuilder.cs

+15-1
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,16 @@ public class LinkBuilder : ILinkBuilder
2828
private static readonly string GetRelationshipControllerActionName =
2929
NoAsyncSuffix(nameof(BaseJsonApiController<Identifiable<int>, int>.GetRelationshipAsync));
3030

31+
private static readonly UriNormalizer UriNormalizer = new();
32+
3133
private readonly IJsonApiOptions _options;
3234
private readonly IJsonApiRequest _request;
3335
private readonly IPaginationContext _paginationContext;
3436
private readonly IHttpContextAccessor _httpContextAccessor;
3537
private readonly LinkGenerator _linkGenerator;
3638
private readonly IControllerResourceMapping _controllerResourceMapping;
3739
private readonly IPaginationParser _paginationParser;
40+
private readonly IDocumentDescriptionLinkProvider _documentDescriptionLinkProvider;
3841

3942
private HttpContext HttpContext
4043
{
@@ -50,14 +53,16 @@ private HttpContext HttpContext
5053
}
5154

5255
public LinkBuilder(IJsonApiOptions options, IJsonApiRequest request, IPaginationContext paginationContext, IHttpContextAccessor httpContextAccessor,
53-
LinkGenerator linkGenerator, IControllerResourceMapping controllerResourceMapping, IPaginationParser paginationParser)
56+
LinkGenerator linkGenerator, IControllerResourceMapping controllerResourceMapping, IPaginationParser paginationParser,
57+
IDocumentDescriptionLinkProvider documentDescriptionLinkProvider)
5458
{
5559
ArgumentGuard.NotNull(options);
5660
ArgumentGuard.NotNull(request);
5761
ArgumentGuard.NotNull(paginationContext);
5862
ArgumentGuard.NotNull(linkGenerator);
5963
ArgumentGuard.NotNull(controllerResourceMapping);
6064
ArgumentGuard.NotNull(paginationParser);
65+
ArgumentGuard.NotNull(documentDescriptionLinkProvider);
6166

6267
_options = options;
6368
_request = request;
@@ -66,6 +71,7 @@ public LinkBuilder(IJsonApiOptions options, IJsonApiRequest request, IPagination
6671
_linkGenerator = linkGenerator;
6772
_controllerResourceMapping = controllerResourceMapping;
6873
_paginationParser = paginationParser;
74+
_documentDescriptionLinkProvider = documentDescriptionLinkProvider;
6975
}
7076

7177
private static string NoAsyncSuffix(string actionName)
@@ -94,6 +100,14 @@ private static string NoAsyncSuffix(string actionName)
94100
SetPaginationInTopLevelLinks(resourceType!, links);
95101
}
96102

103+
string? documentDescriptionUrl = _documentDescriptionLinkProvider.GetUrl();
104+
105+
if (!string.IsNullOrEmpty(documentDescriptionUrl))
106+
{
107+
var requestUri = new Uri(HttpContext.Request.GetEncodedUrl());
108+
links.DescribedBy = UriNormalizer.Normalize(documentDescriptionUrl, _options.UseRelativeLinks, requestUri);
109+
}
110+
97111
return links.HasValue() ? links : null;
98112
}
99113

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
namespace JsonApiDotNetCore.Serialization.Response;
2+
3+
/// <summary>
4+
/// Provides no value for the "describedby" link in https://jsonapi.org/format/#document-top-level.
5+
/// </summary>
6+
public sealed class NoDocumentDescriptionLinkProvider : IDocumentDescriptionLinkProvider
7+
{
8+
/// <summary>
9+
/// Always returns <c>null</c>.
10+
/// </summary>
11+
public string? GetUrl()
12+
{
13+
return null;
14+
}
15+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
namespace JsonApiDotNetCore.Serialization.Response;
2+
3+
internal sealed class UriNormalizer
4+
{
5+
/// <summary>
6+
/// Converts a URL to absolute or relative format, if possible.
7+
/// </summary>
8+
/// <param name="sourceUrl">
9+
/// The absolute or relative URL to normalize.
10+
/// </param>
11+
/// <param name="preferRelative">
12+
/// Whether to convert <paramref name="sourceUrl" /> to absolute or relative format.
13+
/// </param>
14+
/// <param name="requestUri">
15+
/// The URL of the current HTTP request, whose path and query string are discarded.
16+
/// </param>
17+
public string Normalize(string sourceUrl, bool preferRelative, Uri requestUri)
18+
{
19+
var sourceUri = new Uri(sourceUrl, UriKind.RelativeOrAbsolute);
20+
Uri baseUri = RemovePathFromAbsoluteUri(requestUri);
21+
22+
if (!sourceUri.IsAbsoluteUri && !preferRelative)
23+
{
24+
var absoluteUri = new Uri(baseUri, sourceUrl);
25+
return absoluteUri.AbsoluteUri;
26+
}
27+
28+
if (sourceUri.IsAbsoluteUri && preferRelative)
29+
{
30+
if (AreSameServer(baseUri, sourceUri))
31+
{
32+
Uri relativeUri = baseUri.MakeRelativeUri(sourceUri);
33+
return relativeUri.ToString();
34+
}
35+
}
36+
37+
return sourceUrl;
38+
}
39+
40+
private static Uri RemovePathFromAbsoluteUri(Uri uri)
41+
{
42+
var requestUriBuilder = new UriBuilder(uri)
43+
{
44+
Path = null
45+
};
46+
47+
return requestUriBuilder.Uri;
48+
}
49+
50+
private static bool AreSameServer(Uri left, Uri right)
51+
{
52+
// Custom implementation because Uri.Equals() ignores the casing of username/password.
53+
54+
string leftScheme = left.GetComponents(UriComponents.Scheme, UriFormat.UriEscaped);
55+
string rightScheme = right.GetComponents(UriComponents.Scheme, UriFormat.UriEscaped);
56+
57+
if (!string.Equals(leftScheme, rightScheme, StringComparison.OrdinalIgnoreCase))
58+
{
59+
return false;
60+
}
61+
62+
string leftServer = left.GetComponents(UriComponents.HostAndPort, UriFormat.UriEscaped);
63+
string rightServer = right.GetComponents(UriComponents.HostAndPort, UriFormat.UriEscaped);
64+
65+
if (!string.Equals(leftServer, rightServer, StringComparison.OrdinalIgnoreCase))
66+
{
67+
return false;
68+
}
69+
70+
string leftUserInfo = left.GetComponents(UriComponents.UserInfo, UriFormat.UriEscaped);
71+
string rightUserInfo = right.GetComponents(UriComponents.UserInfo, UriFormat.UriEscaped);
72+
73+
if (!string.Equals(leftUserInfo, rightUserInfo))
74+
{
75+
return false;
76+
}
77+
78+
return true;
79+
}
80+
}

Diff for: test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithNamespaceTests.cs

+8
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
5858
responseDocument.Links.Last.Should().BeNull();
5959
responseDocument.Links.Prev.Should().BeNull();
6060
responseDocument.Links.Next.Should().BeNull();
61+
responseDocument.Links.DescribedBy.Should().BeNull();
6162

6263
responseDocument.Data.SingleValue.ShouldNotBeNull();
6364
responseDocument.Data.SingleValue.Links.ShouldNotBeNull();
@@ -101,6 +102,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
101102
responseDocument.Links.Last.Should().Be(responseDocument.Links.Self);
102103
responseDocument.Links.Prev.Should().BeNull();
103104
responseDocument.Links.Next.Should().BeNull();
105+
responseDocument.Links.DescribedBy.Should().BeNull();
104106

105107
responseDocument.Data.ManyValue.ShouldHaveCount(1);
106108

@@ -167,6 +169,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
167169
responseDocument.Links.Last.Should().BeNull();
168170
responseDocument.Links.Prev.Should().BeNull();
169171
responseDocument.Links.Next.Should().BeNull();
172+
responseDocument.Links.DescribedBy.Should().BeNull();
170173

171174
string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{photo.Album.StringId}";
172175

@@ -211,6 +214,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
211214
responseDocument.Links.Last.Should().Be(responseDocument.Links.Self);
212215
responseDocument.Links.Prev.Should().BeNull();
213216
responseDocument.Links.Next.Should().BeNull();
217+
responseDocument.Links.DescribedBy.Should().BeNull();
214218

215219
responseDocument.Data.ManyValue.ShouldHaveCount(1);
216220

@@ -259,6 +263,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
259263
responseDocument.Links.Last.Should().BeNull();
260264
responseDocument.Links.Prev.Should().BeNull();
261265
responseDocument.Links.Next.Should().BeNull();
266+
responseDocument.Links.DescribedBy.Should().BeNull();
262267

263268
responseDocument.Data.SingleValue.ShouldNotBeNull();
264269
responseDocument.Data.SingleValue.Links.Should().BeNull();
@@ -293,6 +298,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
293298
responseDocument.Links.Last.Should().Be(responseDocument.Links.Self);
294299
responseDocument.Links.Prev.Should().BeNull();
295300
responseDocument.Links.Next.Should().BeNull();
301+
responseDocument.Links.DescribedBy.Should().BeNull();
296302

297303
responseDocument.Data.ManyValue.ShouldHaveCount(1);
298304
responseDocument.Data.ManyValue[0].Links.Should().BeNull();
@@ -354,6 +360,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
354360
responseDocument.Links.Last.Should().BeNull();
355361
responseDocument.Links.Prev.Should().BeNull();
356362
responseDocument.Links.Next.Should().BeNull();
363+
responseDocument.Links.DescribedBy.Should().BeNull();
357364

358365
responseDocument.Data.SingleValue.ShouldNotBeNull();
359366
responseDocument.Data.SingleValue.Links.ShouldNotBeNull();
@@ -437,6 +444,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
437444
responseDocument.Links.Last.Should().BeNull();
438445
responseDocument.Links.Prev.Should().BeNull();
439446
responseDocument.Links.Next.Should().BeNull();
447+
responseDocument.Links.DescribedBy.Should().BeNull();
440448

441449
string photoLink = $"{HostPrefix}{PathPrefix}/photos/{existingPhoto.StringId}";
442450

Diff for: test/JsonApiDotNetCoreTests/IntegrationTests/Links/AbsoluteLinksWithoutNamespaceTests.cs

+8
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
5858
responseDocument.Links.Last.Should().BeNull();
5959
responseDocument.Links.Prev.Should().BeNull();
6060
responseDocument.Links.Next.Should().BeNull();
61+
responseDocument.Links.DescribedBy.Should().BeNull();
6162

6263
responseDocument.Data.SingleValue.ShouldNotBeNull();
6364
responseDocument.Data.SingleValue.Links.ShouldNotBeNull();
@@ -101,6 +102,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
101102
responseDocument.Links.Last.Should().Be(responseDocument.Links.Self);
102103
responseDocument.Links.Prev.Should().BeNull();
103104
responseDocument.Links.Next.Should().BeNull();
105+
responseDocument.Links.DescribedBy.Should().BeNull();
104106

105107
responseDocument.Data.ManyValue.ShouldHaveCount(1);
106108

@@ -167,6 +169,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
167169
responseDocument.Links.Last.Should().BeNull();
168170
responseDocument.Links.Prev.Should().BeNull();
169171
responseDocument.Links.Next.Should().BeNull();
172+
responseDocument.Links.DescribedBy.Should().BeNull();
170173

171174
string albumLink = $"{HostPrefix}{PathPrefix}/photoAlbums/{photo.Album.StringId}";
172175

@@ -211,6 +214,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
211214
responseDocument.Links.Last.Should().Be(responseDocument.Links.Self);
212215
responseDocument.Links.Prev.Should().BeNull();
213216
responseDocument.Links.Next.Should().BeNull();
217+
responseDocument.Links.DescribedBy.Should().BeNull();
214218

215219
responseDocument.Data.ManyValue.ShouldHaveCount(1);
216220

@@ -259,6 +263,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
259263
responseDocument.Links.Last.Should().BeNull();
260264
responseDocument.Links.Prev.Should().BeNull();
261265
responseDocument.Links.Next.Should().BeNull();
266+
responseDocument.Links.DescribedBy.Should().BeNull();
262267

263268
responseDocument.Data.SingleValue.ShouldNotBeNull();
264269
responseDocument.Data.SingleValue.Links.Should().BeNull();
@@ -293,6 +298,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
293298
responseDocument.Links.Last.Should().Be(responseDocument.Links.Self);
294299
responseDocument.Links.Prev.Should().BeNull();
295300
responseDocument.Links.Next.Should().BeNull();
301+
responseDocument.Links.DescribedBy.Should().BeNull();
296302

297303
responseDocument.Data.ManyValue.ShouldHaveCount(1);
298304
responseDocument.Data.ManyValue[0].Links.Should().BeNull();
@@ -354,6 +360,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
354360
responseDocument.Links.Last.Should().BeNull();
355361
responseDocument.Links.Prev.Should().BeNull();
356362
responseDocument.Links.Next.Should().BeNull();
363+
responseDocument.Links.DescribedBy.Should().BeNull();
357364

358365
responseDocument.Data.SingleValue.ShouldNotBeNull();
359366
responseDocument.Data.SingleValue.Links.ShouldNotBeNull();
@@ -437,6 +444,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext =>
437444
responseDocument.Links.Last.Should().BeNull();
438445
responseDocument.Links.Prev.Should().BeNull();
439446
responseDocument.Links.Next.Should().BeNull();
447+
responseDocument.Links.DescribedBy.Should().BeNull();
440448

441449
string photoLink = $"{HostPrefix}{PathPrefix}/photos/{existingPhoto.StringId}";
442450

0 commit comments

Comments
 (0)