From 3c054381ec9a2e829a1770d5183592a7114dd491 Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Wed, 5 May 2021 01:00:01 +0200 Subject: [PATCH 1/2] Rewrite of LinkBuilder to use ASP.NET Core routing to render links. The limitation that custom routes must end in the public resource name no longer applies. - Fixed: Resource-level Self links in atomic:operations responses are now hidden when no controller exists for the resource type. - Fixed: For determining which links to render, settings from primary resource were used on secondary endpoints. For example, if you configure Customer to show all links, but Orders no show none, then /customer/1/orders would show all links. - Optimization: Compound `page[size]` parameter value (example: `10,articles:5`) is calculated once, instead of again for each pagination link. - Deprecated: `IJsonApiRequest.BasePath`. This information is no longer needed, but we still set it for back-compat. - Added support for non-standard route parameters in links, for example: `[DisableRoutingConvention, Route("{shopName}/products")]` --- docs/usage/routing.md | 4 +- .../Middleware/IControllerResourceMapping.cs | 7 +- .../Middleware/IJsonApiRequest.cs | 2 + .../Middleware/JsonApiRequest.cs | 2 + .../Middleware/JsonApiRoutingConvention.cs | 26 +- .../Serialization/Building/LinkBuilder.cs | 328 +++++++++--------- .../Objects/RelationshipLinks.cs | 5 + .../Serialization/Objects/ResourceLinks.cs | 5 + .../Serialization/Objects/TopLevelLinks.cs | 6 + .../Serialization/RequestDeserializer.cs | 6 + .../Creating/AtomicCreateResourceTests.cs | 5 + ...reateResourceWithClientGeneratedIdTests.cs | 3 + ...eateResourceWithToManyRelationshipTests.cs | 4 + ...reateResourceWithToOneRelationshipTests.cs | 4 + .../Links/AtomicAbsoluteLinksTests.cs | 52 +++ .../AtomicRelativeLinksWithNamespaceTests.cs | 4 + .../AtomicOperations/LyricsController.cs | 15 + .../Mixed/AtomicSerializationTests.cs | 3 + .../AtomicOperations/PerformersController.cs | 15 + .../AtomicOperations/PlaylistsController.cs | 15 + .../RecordCompaniesController.cs | 15 + .../TextLanguagesController.cs | 16 + .../Resources/AtomicUpdateResourceTests.cs | 3 + .../HostingInIIS/HostingTests.cs | 13 +- .../HostingInIIS/PaintingsController.cs | 2 +- .../Links/LinkInclusionTests.cs | 31 ++ .../MultiTenancy/FakeTenantProvider.cs | 17 - .../MultiTenancy/ITenantProvider.cs | 2 + .../MultiTenancy/MultiTenancyTests.cs | 111 ++++-- .../MultiTenancy/RouteTenantProvider.cs | 32 ++ .../MultiTenancy/WebProductsController.cs | 4 + .../MultiTenancy/WebShopsController.cs | 4 + .../UnitTests/Links/LinkInclusionTests.cs | 112 ++++-- 33 files changed, 618 insertions(+), 255 deletions(-) create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/LyricsController.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/PerformersController.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/PlaylistsController.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/RecordCompaniesController.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/TextLanguagesController.cs delete mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/FakeTenantProvider.cs create mode 100644 test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/RouteTenantProvider.cs diff --git a/docs/usage/routing.md b/docs/usage/routing.md index 9d4ebe5853..314e2bdfb1 100644 --- a/docs/usage/routing.md +++ b/docs/usage/routing.md @@ -62,7 +62,7 @@ GET /orderLines HTTP/1.1 It is possible to bypass the default routing convention for a controller. ```c# -[Route("v1/custom/route/orderLines"), DisableRoutingConvention] +[Route("v1/custom/route/lines-in-order"), DisableRoutingConvention] public class OrderLineController : JsonApiController { public OrderLineController(IJsonApiOptions options, ILoggerFactory loggerFactory, @@ -73,8 +73,6 @@ public class OrderLineController : JsonApiController } ``` -It is required to match your custom url with the exposed name of the associated resource. - ## Advanced Usage: Custom Routing Convention It is possible to replace the built-in routing convention with a [custom routing convention](https://docs.microsoft.com/en-us/aspnet/core/mvc/controllers/application-model?view=aspnetcore-3.1#sample-custom-routing-convention) by registering an implementation of `IJsonApiRoutingConvention`. diff --git a/src/JsonApiDotNetCore/Middleware/IControllerResourceMapping.cs b/src/JsonApiDotNetCore/Middleware/IControllerResourceMapping.cs index 6f4a8beb7a..4290b3b771 100644 --- a/src/JsonApiDotNetCore/Middleware/IControllerResourceMapping.cs +++ b/src/JsonApiDotNetCore/Middleware/IControllerResourceMapping.cs @@ -8,8 +8,13 @@ namespace JsonApiDotNetCore.Middleware public interface IControllerResourceMapping { /// - /// Get the associated resource type for the provided controller type. + /// Gets the associated resource type for the provided controller type. /// Type GetResourceTypeForController(Type controllerType); + + /// + /// Gets the associated controller name for the provided resource type. + /// + string GetControllerNameForResourceType(Type resourceType); } } diff --git a/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs b/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs index c926e80d84..081d398b39 100644 --- a/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs +++ b/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs @@ -1,3 +1,4 @@ +using System; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Resources.Annotations; @@ -22,6 +23,7 @@ public interface IJsonApiRequest /// Relative: /api/v1 /// ]]> /// + [Obsolete("This value is calculated for backwards compatibility, but it is no longer used and will be removed in a future version.")] string BasePath { get; } /// diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs b/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs index 389d423b83..b732529e2d 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs @@ -44,7 +44,9 @@ public void CopyFrom(IJsonApiRequest other) ArgumentGuard.NotNull(other, nameof(other)); Kind = other.Kind; +#pragma warning disable CS0618 // Type or member is obsolete BasePath = other.BasePath; +#pragma warning restore CS0618 // Type or member is obsolete PrimaryId = other.PrimaryId; PrimaryResource = other.PrimaryResource; SecondaryResource = other.SecondaryResource; diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs b/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs index 6063c81a3d..1259aa08e5 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs @@ -35,6 +35,7 @@ public class JsonApiRoutingConvention : IJsonApiRoutingConvention private readonly IResourceContextProvider _resourceContextProvider; private readonly Dictionary _registeredControllerNameByTemplate = new Dictionary(); private readonly Dictionary _resourceContextPerControllerTypeMap = new Dictionary(); + private readonly Dictionary _controllerPerResourceContextMap = new Dictionary(); public JsonApiRoutingConvention(IJsonApiOptions options, IResourceContextProvider resourceContextProvider) { @@ -58,6 +59,22 @@ public Type GetResourceTypeForController(Type controllerType) return null; } + /// + public string GetControllerNameForResourceType(Type resourceType) + { + ArgumentGuard.NotNull(resourceType, nameof(resourceType)); + + ResourceContext resourceContext = _resourceContextProvider.GetResourceContext(resourceType); + + if (_controllerPerResourceContextMap.TryGetValue(resourceContext, out ControllerModel controllerModel)) + + { + return controllerModel.ControllerName; + } + + return null; + } + /// public void Apply(ApplicationModel application) { @@ -78,6 +95,7 @@ public void Apply(ApplicationModel application) if (resourceContext != null) { _resourceContextPerControllerTypeMap.Add(controller.ControllerType, resourceContext); + _controllerPerResourceContextMap.Add(resourceContext, controller); } } } @@ -117,9 +135,7 @@ private string TemplateFromResource(ControllerModel model) { if (_resourceContextPerControllerTypeMap.TryGetValue(model.ControllerType, out ResourceContext resourceContext)) { - string template = $"{_options.Namespace}/{resourceContext.PublicName}"; - - return template; + return $"{_options.Namespace}/{resourceContext.PublicName}"; } return null; @@ -131,9 +147,7 @@ private string TemplateFromResource(ControllerModel model) private string TemplateFromController(ControllerModel model) { string controllerName = _options.SerializerNamingStrategy.GetPropertyName(model.ControllerName, false); - string template = $"{_options.Namespace}/{controllerName}"; - - return template; + return $"{_options.Namespace}/{controllerName}"; } /// diff --git a/src/JsonApiDotNetCore/Serialization/Building/LinkBuilder.cs b/src/JsonApiDotNetCore/Serialization/Building/LinkBuilder.cs index 376c6f36e4..cdf81515df 100644 --- a/src/JsonApiDotNetCore/Serialization/Building/LinkBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Building/LinkBuilder.cs @@ -1,18 +1,19 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Text; using JetBrains.Annotations; using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; using JsonApiDotNetCore.Queries.Expressions; using JsonApiDotNetCore.Queries.Internal.Parsing; -using JsonApiDotNetCore.QueryStrings; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.AspNetCore.Routing; namespace JsonApiDotNetCore.Serialization.Building { @@ -22,83 +23,103 @@ public class LinkBuilder : ILinkBuilder private const string PageSizeParameterName = "page[size]"; private const string PageNumberParameterName = "page[number]"; - private readonly IResourceContextProvider _provider; - private readonly IRequestQueryStringAccessor _queryStringAccessor; + private static readonly string GetPrimaryControllerAction = WithoutAsyncSuffix(nameof(BaseJsonApiController.GetAsync)); + private static readonly string GetSecondaryControllerAction = WithoutAsyncSuffix(nameof(BaseJsonApiController.GetSecondaryAsync)); + private static readonly string GetRelationshipControllerAction = WithoutAsyncSuffix(nameof(BaseJsonApiController.GetRelationshipAsync)); + private readonly IJsonApiOptions _options; private readonly IJsonApiRequest _request; private readonly IPaginationContext _paginationContext; - - public LinkBuilder(IJsonApiOptions options, IJsonApiRequest request, IPaginationContext paginationContext, IResourceContextProvider provider, - IRequestQueryStringAccessor queryStringAccessor) + private readonly IResourceContextProvider _resourceContextProvider; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly LinkGenerator _linkGenerator; + private readonly IControllerResourceMapping _controllerResourceMapping; + + public LinkBuilder(IJsonApiOptions options, IJsonApiRequest request, IPaginationContext paginationContext, + IResourceContextProvider resourceContextProvider, IHttpContextAccessor httpContextAccessor, LinkGenerator linkGenerator, + IControllerResourceMapping controllerResourceMapping) { ArgumentGuard.NotNull(options, nameof(options)); ArgumentGuard.NotNull(request, nameof(request)); ArgumentGuard.NotNull(paginationContext, nameof(paginationContext)); - ArgumentGuard.NotNull(provider, nameof(provider)); - ArgumentGuard.NotNull(queryStringAccessor, nameof(queryStringAccessor)); + ArgumentGuard.NotNull(resourceContextProvider, nameof(resourceContextProvider)); + ArgumentGuard.NotNull(linkGenerator, nameof(linkGenerator)); + ArgumentGuard.NotNull(controllerResourceMapping, nameof(controllerResourceMapping)); _options = options; _request = request; _paginationContext = paginationContext; - _provider = provider; - _queryStringAccessor = queryStringAccessor; + _resourceContextProvider = resourceContextProvider; + _httpContextAccessor = httpContextAccessor; + _linkGenerator = linkGenerator; + _controllerResourceMapping = controllerResourceMapping; + } + + private static string WithoutAsyncSuffix(string actionName) + { + return actionName.EndsWith("Async", StringComparison.Ordinal) ? actionName[..^"Async".Length] : actionName; } /// public TopLevelLinks GetTopLevelLinks() { - ResourceContext resourceContext = _request.PrimaryResource; + var links = new TopLevelLinks(); - TopLevelLinks topLevelLinks = null; + ResourceContext requestContext = _request.SecondaryResource ?? _request.PrimaryResource; - if (ShouldAddTopLevelLink(resourceContext, LinkTypes.Self)) + if (ShouldIncludeTopLevelLink(LinkTypes.Self, requestContext)) { - topLevelLinks = new TopLevelLinks - { - Self = GetSelfTopLevelLink(resourceContext, null) - }; + links.Self = GetLinkForTopLevelSelf(); } - if (ShouldAddTopLevelLink(resourceContext, LinkTypes.Related) && _request.Kind == EndpointKind.Relationship) + if (_request.Kind == EndpointKind.Relationship && ShouldIncludeTopLevelLink(LinkTypes.Related, requestContext)) { - topLevelLinks ??= new TopLevelLinks(); - topLevelLinks.Related = GetRelatedRelationshipLink(_request.PrimaryResource.PublicName, _request.PrimaryId, _request.Relationship.PublicName); + links.Related = GetLinkForRelationshipRelated(_request.PrimaryId, _request.Relationship); } - if (ShouldAddTopLevelLink(resourceContext, LinkTypes.Paging) && _paginationContext.PageSize != null && _request.IsCollection) + if (_request.IsCollection && _paginationContext.PageSize != null && ShouldIncludeTopLevelLink(LinkTypes.Paging, requestContext)) { - SetPageLinks(resourceContext, topLevelLinks ??= new TopLevelLinks()); + SetPaginationInTopLevelLinks(requestContext, links); } - return topLevelLinks; + return links.HasValue() ? links : null; } /// - /// Checks if the top-level should be added by first checking configuration on the , and if not - /// configured, by checking with the global configuration in . + /// Checks if the top-level should be added by first checking configuration on the , and if + /// not configured, by checking with the global configuration in . /// - private bool ShouldAddTopLevelLink(ResourceContext resourceContext, LinkTypes link) + private bool ShouldIncludeTopLevelLink(LinkTypes linkType, ResourceContext resourceContext) { if (resourceContext.TopLevelLinks != LinkTypes.NotConfigured) { - return resourceContext.TopLevelLinks.HasFlag(link); + return resourceContext.TopLevelLinks.HasFlag(linkType); } - return _options.TopLevelLinks.HasFlag(link); + return _options.TopLevelLinks.HasFlag(linkType); } - private void SetPageLinks(ResourceContext resourceContext, TopLevelLinks links) + private string GetLinkForTopLevelSelf() { - links.First = GetPageLink(resourceContext, 1, _paginationContext.PageSize); + return _options.UseRelativeLinks + ? _httpContextAccessor.HttpContext.Request.GetEncodedPathAndQuery() + : _httpContextAccessor.HttpContext.Request.GetEncodedUrl(); + } + + private void SetPaginationInTopLevelLinks(ResourceContext requestContext, TopLevelLinks links) + { + string pageSizeValue = CalculatePageSizeValue(_paginationContext.PageSize, requestContext); + + links.First = GetLinkForPagination(1, pageSizeValue); if (_paginationContext.TotalPageCount > 0) { - links.Last = GetPageLink(resourceContext, _paginationContext.TotalPageCount.Value, _paginationContext.PageSize); + links.Last = GetLinkForPagination(_paginationContext.TotalPageCount.Value, pageSizeValue); } if (_paginationContext.PageNumber.OneBasedValue > 1) { - links.Prev = GetPageLink(resourceContext, _paginationContext.PageNumber.OneBasedValue - 1, _paginationContext.PageSize); + links.Prev = GetLinkForPagination(_paginationContext.PageNumber.OneBasedValue - 1, pageSizeValue); } bool hasNextPage = _paginationContext.PageNumber.OneBasedValue < _paginationContext.TotalPageCount; @@ -106,87 +127,21 @@ private void SetPageLinks(ResourceContext resourceContext, TopLevelLinks links) if (hasNextPage || possiblyHasNextPage) { - links.Next = GetPageLink(resourceContext, _paginationContext.PageNumber.OneBasedValue + 1, _paginationContext.PageSize); + links.Next = GetLinkForPagination(_paginationContext.PageNumber.OneBasedValue + 1, pageSizeValue); } } - private string GetSelfTopLevelLink(ResourceContext resourceContext, Action> queryStringUpdateAction) - { - var builder = new StringBuilder(); - builder.Append(_request.BasePath); - builder.Append('/'); - builder.Append(resourceContext.PublicName); - - if (_request.PrimaryId != null) - { - builder.Append('/'); - builder.Append(_request.PrimaryId); - } - - if (_request.Kind == EndpointKind.Relationship) - { - builder.Append("/relationships"); - } - - if (_request.Relationship != null) - { - builder.Append('/'); - builder.Append(_request.Relationship.PublicName); - } - - string queryString = BuildQueryString(queryStringUpdateAction); - builder.Append(queryString); - - return builder.ToString(); - } - - private string BuildQueryString(Action> updateAction) - { - Dictionary parameters = _queryStringAccessor.Query.ToDictionary(pair => pair.Key, pair => pair.Value.ToString()); - updateAction?.Invoke(parameters); - string queryString = QueryString.Create(parameters).Value; - - return DecodeSpecialCharacters(queryString); - } - - private static string DecodeSpecialCharacters(string uri) + private string CalculatePageSizeValue(PageSize topPageSize, ResourceContext requestContext) { - return uri.Replace("%5B", "[").Replace("%5D", "]").Replace("%27", "'").Replace("%3A", ":"); - } + string pageSizeParameterValue = _httpContextAccessor.HttpContext.Request.Query[PageSizeParameterName]; - private string GetPageLink(ResourceContext resourceContext, int pageOffset, PageSize pageSize) - { - return GetSelfTopLevelLink(resourceContext, parameters => - { - string existingPageSizeParameterValue = parameters.ContainsKey(PageSizeParameterName) ? parameters[PageSizeParameterName] : null; - - PageSize newTopPageSize = Equals(pageSize, _options.DefaultPageSize) ? null : pageSize; - - string newPageSizeParameterValue = ChangeTopPageSize(existingPageSizeParameterValue, newTopPageSize); - - if (newPageSizeParameterValue == null) - { - parameters.Remove(PageSizeParameterName); - } - else - { - parameters[PageSizeParameterName] = newPageSizeParameterValue; - } - - if (pageOffset == 1) - { - parameters.Remove(PageNumberParameterName); - } - else - { - parameters[PageNumberParameterName] = pageOffset.ToString(); - } - }); + PageSize newTopPageSize = Equals(topPageSize, _options.DefaultPageSize) ? null : topPageSize; + return ChangeTopPageSize(pageSizeParameterValue, newTopPageSize, requestContext); } - private string ChangeTopPageSize(string pageSizeParameterValue, PageSize topPageSize) + private string ChangeTopPageSize(string pageSizeParameterValue, PageSize topPageSize, ResourceContext requestContext) { - IList elements = ParsePageSizeExpression(pageSizeParameterValue); + IList elements = ParsePageSizeExpression(pageSizeParameterValue, requestContext); int elementInTopScopeIndex = elements.FindIndex(expression => expression.Scope == null); if (topPageSize != null) @@ -216,38 +171,96 @@ private string ChangeTopPageSize(string pageSizeParameterValue, PageSize topPage return parameterValue == string.Empty ? null : parameterValue; } - private IList ParsePageSizeExpression(string pageSizeParameterValue) + private IList ParsePageSizeExpression(string pageSizeParameterValue, ResourceContext requestResource) { if (pageSizeParameterValue == null) { return new List(); } - ResourceContext requestResource = _request.SecondaryResource ?? _request.PrimaryResource; - - var parser = new PaginationParser(_provider); + var parser = new PaginationParser(_resourceContextProvider); PaginationQueryStringValueExpression paginationExpression = parser.Parse(pageSizeParameterValue, requestResource); return paginationExpression.Elements.ToList(); } + private string GetLinkForPagination(int pageOffset, string pageSizeValue) + { + IDictionary parameters = + _httpContextAccessor.HttpContext.Request.Query.ToDictionary(pair => pair.Key, pair => pair.Value.ToString()); + + if (pageSizeValue == null) + { + parameters.Remove(PageSizeParameterName); + } + else + { + parameters[PageSizeParameterName] = pageSizeValue; + } + + if (pageOffset == 1) + { + parameters.Remove(PageNumberParameterName); + } + else + { + parameters[PageNumberParameterName] = pageOffset.ToString(); + } + + string queryStringValue = QueryString.Create(parameters).Value; + queryStringValue = DecodeSpecialCharacters(queryStringValue); + + var builder = new UriBuilder(_httpContextAccessor.HttpContext.Request.GetEncodedUrl()) + { + Query = queryStringValue + }; + + UriComponents components = _options.UseRelativeLinks ? UriComponents.PathAndQuery : UriComponents.AbsoluteUri; + return builder.Uri.GetComponents(components, UriFormat.SafeUnescaped); + } + + private static string DecodeSpecialCharacters(string uri) + { + return uri.Replace("%5B", "[").Replace("%5D", "]").Replace("%27", "'").Replace("%3A", ":"); + } + /// public ResourceLinks GetResourceLinks(string resourceName, string id) { ArgumentGuard.NotNullNorEmpty(resourceName, nameof(resourceName)); ArgumentGuard.NotNullNorEmpty(id, nameof(id)); - ResourceContext resourceContext = _provider.GetResourceContext(resourceName); + var links = new ResourceLinks(); + ResourceContext resourceContext = _resourceContextProvider.GetResourceContext(resourceName); - if (ShouldAddResourceLink(resourceContext, LinkTypes.Self)) + if (_request.Kind != EndpointKind.Relationship && ShouldIncludeResourceLink(LinkTypes.Self, resourceContext)) { - return new ResourceLinks - { - Self = GetSelfResourceLink(resourceName, id) - }; + links.Self = GetLinkForResourceSelf(resourceContext, id); + } + + return links.HasValue() ? links : null; + } + + /// + /// Checks if the resource object level should be added by first checking configuration on the + /// , and if not configured, by checking with the global configuration in . + /// + private bool ShouldIncludeResourceLink(LinkTypes linkType, ResourceContext resourceContext) + { + if (resourceContext.ResourceLinks != LinkTypes.NotConfigured) + { + return resourceContext.ResourceLinks.HasFlag(linkType); } - return null; + return _options.ResourceLinks.HasFlag(linkType); + } + + private string GetLinkForResourceSelf(ResourceContext resourceContext, string resourceId) + { + string controllerName = _controllerResourceMapping.GetControllerNameForResourceType(resourceContext.ResourceType); + IDictionary routeValues = GetRouteValues(resourceId, null); + + return RenderLinkForAction(controllerName, GetPrimaryControllerAction, routeValues); } /// @@ -256,79 +269,76 @@ public RelationshipLinks GetRelationshipLinks(RelationshipAttribute relationship ArgumentGuard.NotNull(relationship, nameof(relationship)); ArgumentGuard.NotNull(parent, nameof(parent)); - ResourceContext parentResourceContext = _provider.GetResourceContext(parent.GetType()); - string childNavigation = relationship.PublicName; - RelationshipLinks links = null; + var links = new RelationshipLinks(); + ResourceContext leftResourceContext = _resourceContextProvider.GetResourceContext(parent.GetType()); - if (ShouldAddRelationshipLink(parentResourceContext, relationship, LinkTypes.Related)) + if (ShouldIncludeRelationshipLink(LinkTypes.Self, relationship, leftResourceContext)) { - links = new RelationshipLinks - { - Related = GetRelatedRelationshipLink(parentResourceContext.PublicName, parent.StringId, childNavigation) - }; + links.Self = GetLinkForRelationshipSelf(parent.StringId, relationship); } - if (ShouldAddRelationshipLink(parentResourceContext, relationship, LinkTypes.Self)) + if (ShouldIncludeRelationshipLink(LinkTypes.Related, relationship, leftResourceContext)) { - links ??= new RelationshipLinks(); - links.Self = GetSelfRelationshipLink(parentResourceContext.PublicName, parent.StringId, childNavigation); + links.Related = GetLinkForRelationshipRelated(parent.StringId, relationship); } - return links; + return links.HasValue() ? links : null; } - private string GetSelfRelationshipLink(string parent, string parentId, string navigation) + private string GetLinkForRelationshipSelf(string primaryId, RelationshipAttribute relationship) { - return $"{_request.BasePath}/{parent}/{parentId}/relationships/{navigation}"; - } + string controllerName = _controllerResourceMapping.GetControllerNameForResourceType(relationship.LeftType); + IDictionary routeValues = GetRouteValues(primaryId, relationship.PublicName); - private string GetSelfResourceLink(string resource, string resourceId) - { - return $"{_request.BasePath}/{resource}/{resourceId}"; + return RenderLinkForAction(controllerName, GetRelationshipControllerAction, routeValues); } - private string GetRelatedRelationshipLink(string parent, string parentId, string navigation) + private string GetLinkForRelationshipRelated(string primaryId, RelationshipAttribute relationship) { - return $"{_request.BasePath}/{parent}/{parentId}/{navigation}"; + string controllerName = _controllerResourceMapping.GetControllerNameForResourceType(relationship.LeftType); + IDictionary routeValues = GetRouteValues(primaryId, relationship.PublicName); + + return RenderLinkForAction(controllerName, GetSecondaryControllerAction, routeValues); } - /// - /// Checks if the resource object level should be added by first checking configuration on the , - /// and if not configured, by checking with the global configuration in . - /// - private bool ShouldAddResourceLink(ResourceContext resourceContext, LinkTypes link) + private IDictionary GetRouteValues(string primaryId, string relationshipName) { - if (_request.Kind == EndpointKind.Relationship) - { - return false; - } + // By default, we copy all route parameters from the *current* endpoint, which helps in case all endpoints have the same + // set of non-standard parameters. There is no way we can know which non-standard parameters a *different* endpoint needs, + // so users must override RenderLinkForAction to supply them, if applicable. + RouteValueDictionary routeValues = _httpContextAccessor.HttpContext.Request.RouteValues; - if (resourceContext.ResourceLinks != LinkTypes.NotConfigured) - { - return resourceContext.ResourceLinks.HasFlag(link); - } + routeValues["id"] = primaryId; + routeValues["relationshipName"] = relationshipName; - return _options.ResourceLinks.HasFlag(link); + return routeValues; + } + + protected virtual string RenderLinkForAction(string controllerName, string actionName, IDictionary routeValues) + { + return _options.UseRelativeLinks + ? _linkGenerator.GetPathByAction(_httpContextAccessor.HttpContext, actionName, controllerName, routeValues) + : _linkGenerator.GetUriByAction(_httpContextAccessor.HttpContext, actionName, controllerName, routeValues); } /// - /// Checks if the resource object level should be added by first checking configuration on the - /// attribute, if not configured by checking the , and if not configured by checking with the global configuration in - /// . + /// Checks if the relationship object level should be added by first checking configuration on the + /// attribute, if not configured by checking on the resource + /// type that contains this relationship, and if not configured by checking with the global configuration in . /// - private bool ShouldAddRelationshipLink(ResourceContext resourceContext, RelationshipAttribute relationship, LinkTypes link) + private bool ShouldIncludeRelationshipLink(LinkTypes linkType, RelationshipAttribute relationship, ResourceContext leftResourceContext) { if (relationship.Links != LinkTypes.NotConfigured) { - return relationship.Links.HasFlag(link); + return relationship.Links.HasFlag(linkType); } - if (resourceContext.RelationshipLinks != LinkTypes.NotConfigured) + if (leftResourceContext.RelationshipLinks != LinkTypes.NotConfigured) { - return resourceContext.RelationshipLinks.HasFlag(link); + return leftResourceContext.RelationshipLinks.HasFlag(linkType); } - return _options.RelationshipLinks.HasFlag(link); + return _options.RelationshipLinks.HasFlag(linkType); } } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/RelationshipLinks.cs b/src/JsonApiDotNetCore/Serialization/Objects/RelationshipLinks.cs index fd747eea8e..e0ee4c680f 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/RelationshipLinks.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/RelationshipLinks.cs @@ -15,5 +15,10 @@ public sealed class RelationshipLinks /// [JsonProperty("related", NullValueHandling = NullValueHandling.Ignore)] public string Related { get; set; } + + internal bool HasValue() + { + return !string.IsNullOrEmpty(Self) || !string.IsNullOrEmpty(Related); + } } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/ResourceLinks.cs b/src/JsonApiDotNetCore/Serialization/Objects/ResourceLinks.cs index 701e42a3f1..3afbd3ffbd 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/ResourceLinks.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/ResourceLinks.cs @@ -9,5 +9,10 @@ public sealed class ResourceLinks /// [JsonProperty("self", NullValueHandling = NullValueHandling.Ignore)] public string Self { get; set; } + + internal bool HasValue() + { + return !string.IsNullOrEmpty(Self); + } } } diff --git a/src/JsonApiDotNetCore/Serialization/Objects/TopLevelLinks.cs b/src/JsonApiDotNetCore/Serialization/Objects/TopLevelLinks.cs index 98eb1a07df..6970eecb8a 100644 --- a/src/JsonApiDotNetCore/Serialization/Objects/TopLevelLinks.cs +++ b/src/JsonApiDotNetCore/Serialization/Objects/TopLevelLinks.cs @@ -63,5 +63,11 @@ public bool ShouldSerializeNext() { return !string.IsNullOrEmpty(Next); } + + internal bool HasValue() + { + return ShouldSerializeSelf() || ShouldSerializeRelated() || ShouldSerializeDescribedBy() || ShouldSerializeFirst() || ShouldSerializeLast() || + ShouldSerializePrev() || ShouldSerializeNext(); + } } } diff --git a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs index ba4144bb9d..d013736ce7 100644 --- a/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs +++ b/src/JsonApiDotNetCore/Serialization/RequestDeserializer.cs @@ -205,7 +205,9 @@ private OperationContainer ParseForCreateOrUpdateResourceOperation(AtomicOperati var request = new JsonApiRequest { Kind = EndpointKind.AtomicOperations, +#pragma warning disable CS0618 // Type or member is obsolete BasePath = _request.BasePath, +#pragma warning restore CS0618 // Type or member is obsolete PrimaryResource = primaryResourceContext, OperationKind = kind }; @@ -329,7 +331,9 @@ private OperationContainer ParseForDeleteResourceOperation(AtomicOperationObject var request = new JsonApiRequest { Kind = EndpointKind.AtomicOperations, +#pragma warning disable CS0618 // Type or member is obsolete BasePath = _request.BasePath, +#pragma warning restore CS0618 // Type or member is obsolete PrimaryId = primaryResource.StringId, PrimaryResource = primaryResourceContext, OperationKind = kind @@ -364,7 +368,9 @@ private OperationContainer ParseForRelationshipOperation(AtomicOperationObject o var request = new JsonApiRequest { Kind = EndpointKind.AtomicOperations, +#pragma warning disable CS0618 // Type or member is obsolete BasePath = _request.BasePath, +#pragma warning restore CS0618 // Type or member is obsolete PrimaryId = primaryResource.StringId, PrimaryResource = primaryResourceContext, SecondaryResource = secondaryResourceContext, diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs index 39d5378f21..ae864c878a 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceTests.cs @@ -25,6 +25,11 @@ public AtomicCreateResourceTests(ExampleIntegrationTestContext(); + + // These routes need to be registered in ASP.NET for rendering links to resource/relationship endpoints. + testContext.UseController(); + testContext.UseController(); + testContext.UseController(); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs index 9c88b14335..3e40fa3094 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithClientGeneratedIdTests.cs @@ -26,6 +26,9 @@ public AtomicCreateResourceWithClientGeneratedIdTests( testContext.UseController(); + // These routes need to be registered in ASP.NET for rendering links to resource/relationship endpoints. + testContext.UseController(); + var options = (JsonApiOptions)testContext.Factory.Services.GetRequiredService(); options.AllowClientGeneratedIds = true; } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs index abd177bfe8..3f910178ae 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToManyRelationshipTests.cs @@ -25,6 +25,10 @@ public AtomicCreateResourceWithToManyRelationshipTests( _testContext = testContext; testContext.UseController(); + + // These routes need to be registered in ASP.NET for rendering links to resource/relationship endpoints. + testContext.UseController(); + testContext.UseController(); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs index 9a1dceef3a..659f2abf28 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Creating/AtomicCreateResourceWithToOneRelationshipTests.cs @@ -27,6 +27,10 @@ public AtomicCreateResourceWithToOneRelationshipTests( _testContext = testContext; testContext.UseController(); + + // These routes need to be registered in ASP.NET for rendering links to resource/relationship endpoints. + testContext.UseController(); + testContext.UseController(); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/AtomicAbsoluteLinksTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/AtomicAbsoluteLinksTests.cs index 16bc2c68f3..d8dc20a58d 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/AtomicAbsoluteLinksTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/AtomicAbsoluteLinksTests.cs @@ -25,6 +25,10 @@ public AtomicAbsoluteLinksTests(ExampleIntegrationTestContext(); + // These routes need to be registered in ASP.NET for rendering links to resource/relationship endpoints. + testContext.UseController(); + testContext.UseController(); + testContext.ConfigureServicesAfterStartup(services => { services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); @@ -108,5 +112,53 @@ await _testContext.RunOnDatabaseAsync(async dbContext => singleData2.Relationships["tracks"].Links.Self.Should().Be(companyLink + "/relationships/tracks"); singleData2.Relationships["tracks"].Links.Related.Should().Be(companyLink + "/tracks"); } + + [Fact] + public async Task Update_resource_with_side_effects_and_missing_resource_controller_hides_links() + { + // Arrange + Playlist existingPlaylist = _fakers.Playlist.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Playlists.Add(existingPlaylist); + await dbContext.SaveChangesAsync(); + }); + + var requestBody = new + { + atomic__operations = new object[] + { + new + { + op = "update", + data = new + { + type = "playlists", + id = existingPlaylist.StringId, + attributes = new + { + } + } + } + } + }; + + const string route = "/operations"; + + // Act + (HttpResponseMessage httpResponse, AtomicOperationsDocument responseDocument) = + await _testContext.ExecutePostAtomicAsync(route, requestBody); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Results.Should().HaveCount(1); + + ResourceObject singleData = responseDocument.Results[0].SingleData; + singleData.Should().NotBeNull(); + singleData.Links.Should().BeNull(); + singleData.Relationships.Should().BeNull(); + } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/AtomicRelativeLinksWithNamespaceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/AtomicRelativeLinksWithNamespaceTests.cs index dafe56cc1e..6cd3b802c4 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/AtomicRelativeLinksWithNamespaceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Links/AtomicRelativeLinksWithNamespaceTests.cs @@ -25,6 +25,10 @@ public AtomicRelativeLinksWithNamespaceTests( testContext.UseController(); + // These routes need to be registered in ASP.NET for rendering links to resource/relationship endpoints. + testContext.UseController(); + testContext.UseController(); + testContext.ConfigureServicesAfterStartup(services => { services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/LyricsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/LyricsController.cs new file mode 100644 index 0000000000..c34c3de26c --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/LyricsController.cs @@ -0,0 +1,15 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations +{ + public sealed class LyricsController : JsonApiController + { + public LyricsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/AtomicSerializationTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/AtomicSerializationTests.cs index 52dd8b2efa..c9127a1158 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/AtomicSerializationTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Mixed/AtomicSerializationTests.cs @@ -23,6 +23,9 @@ public AtomicSerializationTests(ExampleIntegrationTestContext(); + // These routes need to be registered in ASP.NET for rendering links to resource/relationship endpoints. + testContext.UseController(); + testContext.ConfigureServicesAfterStartup(services => { services.AddScoped(typeof(IResourceChangeTracker<>), typeof(NeverSameResourceChangeTracker<>)); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/PerformersController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/PerformersController.cs new file mode 100644 index 0000000000..76eb4c9fe8 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/PerformersController.cs @@ -0,0 +1,15 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations +{ + public sealed class PerformersController : JsonApiController + { + public PerformersController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/PlaylistsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/PlaylistsController.cs new file mode 100644 index 0000000000..6ec97e341a --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/PlaylistsController.cs @@ -0,0 +1,15 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations +{ + public sealed class PlaylistsController : JsonApiController + { + public PlaylistsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/RecordCompaniesController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/RecordCompaniesController.cs new file mode 100644 index 0000000000..a0c55aeb24 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/RecordCompaniesController.cs @@ -0,0 +1,15 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations +{ + public sealed class RecordCompaniesController : JsonApiController + { + public RecordCompaniesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/TextLanguagesController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/TextLanguagesController.cs new file mode 100644 index 0000000000..eb01382927 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/TextLanguagesController.cs @@ -0,0 +1,16 @@ +using System; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Services; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.AtomicOperations +{ + public sealed class TextLanguagesController : JsonApiController + { + public TextLanguagesController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs index 5e6e328dc1..3a69931019 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/AtomicOperations/Updating/Resources/AtomicUpdateResourceTests.cs @@ -25,6 +25,9 @@ public AtomicUpdateResourceTests(ExampleIntegrationTestContext(); + + // These routes need to be registered in ASP.NET for rendering links to resource/relationship endpoints. + testContext.UseController(); } [Fact] diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/HostingInIIS/HostingTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/HostingInIIS/HostingTests.cs index e326e41805..d3ea6b1054 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/HostingInIIS/HostingTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/HostingInIIS/HostingTests.cs @@ -60,9 +60,8 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.ManyData[0].Relationships["paintings"].Links.Self.Should().Be(galleryLink + "/relationships/paintings"); responseDocument.ManyData[0].Relationships["paintings"].Links.Related.Should().Be(galleryLink + "/paintings"); - // TODO: The next link is wrong: it should use the custom route. - // https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/956 - string paintingLink = HostPrefix + $"/iis-application-virtual-directory/public-api/paintings/{gallery.Paintings.ElementAt(0).StringId}"; + string paintingLink = HostPrefix + + $"/iis-application-virtual-directory/custom/path/to/paintings-of-the-world/{gallery.Paintings.ElementAt(0).StringId}"; responseDocument.Included.Should().HaveCount(1); responseDocument.Included[0].Links.Self.Should().Be(paintingLink); @@ -84,7 +83,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - const string route = "/iis-application-virtual-directory/custom/path/to/paintings?include=exposedAt"; + const string route = "/iis-application-virtual-directory/custom/path/to/paintings-of-the-world?include=exposedAt"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -99,16 +98,14 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Links.Prev.Should().BeNull(); responseDocument.Links.Next.Should().BeNull(); - string paintingLink = HostPrefix + $"/iis-application-virtual-directory/custom/path/to/paintings/{painting.StringId}"; + string paintingLink = HostPrefix + $"/iis-application-virtual-directory/custom/path/to/paintings-of-the-world/{painting.StringId}"; responseDocument.ManyData.Should().HaveCount(1); responseDocument.ManyData[0].Links.Self.Should().Be(paintingLink); responseDocument.ManyData[0].Relationships["exposedAt"].Links.Self.Should().Be(paintingLink + "/relationships/exposedAt"); responseDocument.ManyData[0].Relationships["exposedAt"].Links.Related.Should().Be(paintingLink + "/exposedAt"); - // TODO: The next link is wrong: it should not use the custom route. - // https://github.com/json-api-dotnet/JsonApiDotNetCore/issues/956 - string galleryLink = HostPrefix + $"/iis-application-virtual-directory/custom/path/to/artGalleries/{painting.ExposedAt.StringId}"; + string galleryLink = HostPrefix + $"/iis-application-virtual-directory/public-api/artGalleries/{painting.ExposedAt.StringId}"; responseDocument.Included.Should().HaveCount(1); responseDocument.Included[0].Links.Self.Should().Be(galleryLink); diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/HostingInIIS/PaintingsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/HostingInIIS/PaintingsController.cs index 5a6a51da2f..f79d36fe92 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/HostingInIIS/PaintingsController.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/HostingInIIS/PaintingsController.cs @@ -8,7 +8,7 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.HostingInIIS { [DisableRoutingConvention] - [Route("custom/path/to/paintings")] + [Route("custom/path/to/paintings-of-the-world")] public sealed class PaintingsController : JsonApiController { public PaintingsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/LinkInclusionTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/LinkInclusionTests.cs index c93426d464..b47697dad7 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/LinkInclusionTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/Links/LinkInclusionTests.cs @@ -18,6 +18,7 @@ public LinkInclusionTests(ExampleIntegrationTestContext(); testContext.UseController(); } @@ -61,5 +62,35 @@ await _testContext.RunOnDatabaseAsync(async dbContext => responseDocument.Included[1].Relationships["photos"].Links.Self.Should().NotBeNull(); responseDocument.Included[1].Relationships["photos"].Links.Related.Should().NotBeNull(); } + + [Fact] + public async Task Get_secondary_resource_applies_links_visibility_from_ResourceLinksAttribute() + { + // Arrange + Photo photo = _fakers.Photo.Generate(); + photo.Location = _fakers.PhotoLocation.Generate(); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + dbContext.Photos.Add(photo); + await dbContext.SaveChangesAsync(); + }); + + string route = $"/photos/{photo.StringId}/location"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Should().BeNull(); + + responseDocument.SingleData.Should().NotBeNull(); + responseDocument.SingleData.Links.Should().BeNull(); + responseDocument.SingleData.Relationships["photo"].Links.Self.Should().BeNull(); + responseDocument.SingleData.Relationships["photo"].Links.Related.Should().NotBeNull(); + responseDocument.SingleData.Relationships.Should().NotContainKey("album"); + } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/FakeTenantProvider.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/FakeTenantProvider.cs deleted file mode 100644 index dafbe11176..0000000000 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/FakeTenantProvider.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System; - -namespace JsonApiDotNetCoreExampleTests.IntegrationTests.MultiTenancy -{ - internal sealed class FakeTenantProvider : ITenantProvider - { - public Guid TenantId { get; } - - public FakeTenantProvider(Guid tenantId) - { - // A real implementation would be registered at request scope and obtain the tenant ID from the request, for example - // from the incoming authentication token, a custom HTTP header, the route or a query string parameter. - - TenantId = tenantId; - } - } -} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/ITenantProvider.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/ITenantProvider.cs index 7d0872b20f..2a524cc06f 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/ITenantProvider.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/ITenantProvider.cs @@ -4,6 +4,8 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.MultiTenancy { public interface ITenantProvider { + // An implementation would obtain the tenant ID from the request, for example from the incoming + // authentication token, a custom HTTP header, the route or a query string parameter. Guid TenantId { get; } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/MultiTenancyTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/MultiTenancyTests.cs index 39db753277..190b15d019 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/MultiTenancyTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/MultiTenancyTests.cs @@ -7,6 +7,8 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Serialization.Objects; using JsonApiDotNetCoreExampleTests.Startups; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using TestBuildingBlocks; using Xunit; @@ -15,8 +17,8 @@ namespace JsonApiDotNetCoreExampleTests.IntegrationTests.MultiTenancy { public sealed class MultiTenancyTests : IClassFixture, MultiTenancyDbContext>> { - private static readonly Guid ThisTenantId = Guid.NewGuid(); - private static readonly Guid OtherTenantId = Guid.NewGuid(); + private static readonly Guid ThisTenantId = RouteTenantProvider.TenantRegistry["nld"]; + private static readonly Guid OtherTenantId = RouteTenantProvider.TenantRegistry["ita"]; private readonly ExampleIntegrationTestContext, MultiTenancyDbContext> _testContext; private readonly MultiTenancyFakers _fakers = new MultiTenancyFakers(); @@ -30,7 +32,8 @@ public MultiTenancyTests(ExampleIntegrationTestContext { - services.AddSingleton(new FakeTenantProvider(ThisTenantId)); + services.AddSingleton(); + services.AddScoped(); }); testContext.ConfigureServicesAfterStartup(services => @@ -38,6 +41,9 @@ public MultiTenancyTests(ExampleIntegrationTestContext>(); services.AddResourceService>(); }); + + var options = (JsonApiOptions)_testContext.Factory.Services.GetRequiredService(); + options.UseRelativeLinks = true; } [Fact] @@ -55,7 +61,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - const string route = "/webShops"; + const string route = "/nld/shops"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -85,7 +91,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - const string route = "/webShops?filter=has(products)"; + const string route = "/nld/shops?filter=has(products)"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -115,7 +121,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - const string route = "/webShops?include=products"; + const string route = "/nld/shops?include=products"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -145,7 +151,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = "/webShops/" + shop.StringId; + string route = "/nld/shops/" + shop.StringId; // Act (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -175,7 +181,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/webShops/{shop.StringId}/products"; + string route = $"/nld/shops/{shop.StringId}/products"; // Act (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -205,7 +211,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/webProducts/{product.StringId}/shop"; + string route = $"/nld/products/{product.StringId}/shop"; // Act (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -235,7 +241,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/webShops/{shop.StringId}/relationships/products"; + string route = $"/nld/shops/{shop.StringId}/relationships/products"; // Act (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -265,7 +271,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = $"/webProducts/{product.StringId}/relationships/shop"; + string route = $"/nld/products/{product.StringId}/relationships/shop"; // Act (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteGetAsync(route); @@ -299,7 +305,7 @@ public async Task Can_create_resource() } }; - const string route = "/webShops"; + const string route = "/nld/shops"; // Act (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); @@ -315,7 +321,7 @@ public async Task Can_create_resource() await _testContext.RunOnDatabaseAsync(async dbContext => { - WebShop shopInDatabase = await dbContext.WebShops.FirstWithIdAsync(newShopId); + WebShop shopInDatabase = await dbContext.WebShops.IgnoreQueryFilters().FirstWithIdAsync(newShopId); shopInDatabase.Url.Should().Be(newShopUrl); shopInDatabase.TenantId.Should().Be(ThisTenantId); @@ -364,7 +370,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - const string route = "/webShops"; + const string route = "/nld/shops"; // Act (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); @@ -418,7 +424,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - const string route = "/webProducts"; + const string route = "/nld/products"; // Act (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); @@ -463,7 +469,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - string route = "/webProducts/" + existingProduct.StringId; + string route = "/nld/products/" + existingProduct.StringId; // Act (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -475,7 +481,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - WebProduct productInDatabase = await dbContext.WebProducts.FirstWithIdAsync(existingProduct.Id); + WebProduct productInDatabase = await dbContext.WebProducts.IgnoreQueryFilters().FirstWithIdAsync(existingProduct.Id); productInDatabase.Name.Should().Be(newProductName); productInDatabase.Price.Should().Be(existingProduct.Price); @@ -511,7 +517,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - string route = "/webProducts/" + existingProduct.StringId; + string route = "/nld/products/" + existingProduct.StringId; // Act (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -567,7 +573,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - string route = "/webShops/" + existingShop.StringId; + string route = "/nld/shops/" + existingShop.StringId; // Act (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -620,7 +626,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - string route = "/webProducts/" + existingProduct.StringId; + string route = "/nld/products/" + existingProduct.StringId; // Act (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -655,7 +661,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = new object[0] }; - string route = $"/webShops/{existingShop.StringId}/relationships/products"; + string route = $"/nld/shops/{existingShop.StringId}/relationships/products"; // Act (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -700,7 +706,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - string route = $"/webShops/{existingShop.StringId}/relationships/products"; + string route = $"/nld/shops/{existingShop.StringId}/relationships/products"; // Act (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -735,7 +741,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => data = (object)null }; - string route = $"/webProducts/{existingProduct.StringId}/relationships/shop"; + string route = $"/nld/products/{existingProduct.StringId}/relationships/shop"; // Act (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -777,7 +783,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - string route = $"/webProducts/{existingProduct.StringId}/relationships/shop"; + string route = $"/nld/products/{existingProduct.StringId}/relationships/shop"; // Act (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePatchAsync(route, requestBody); @@ -822,7 +828,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - string route = $"/webShops/{existingShop.StringId}/relationships/products"; + string route = $"/nld/shops/{existingShop.StringId}/relationships/products"; // Act (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); @@ -866,7 +872,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - string route = $"/webShops/{existingShop.StringId}/relationships/products"; + string route = $"/nld/shops/{existingShop.StringId}/relationships/products"; // Act (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecutePostAsync(route, requestBody); @@ -908,7 +914,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => } }; - string route = $"/webShops/{existingShop.StringId}/relationships/products"; + string route = $"/nld/shops/{existingShop.StringId}/relationships/products"; // Act (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteDeleteAsync(route, requestBody); @@ -938,7 +944,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = "/webProducts/" + existingProduct.StringId; + string route = "/nld/products/" + existingProduct.StringId; // Act (HttpResponseMessage httpResponse, string responseDocument) = await _testContext.ExecuteDeleteAsync(route); @@ -950,7 +956,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await _testContext.RunOnDatabaseAsync(async dbContext => { - WebProduct productInDatabase = await dbContext.WebProducts.FirstWithIdOrDefaultAsync(existingProduct.Id); + WebProduct productInDatabase = await dbContext.WebProducts.IgnoreQueryFilters().FirstWithIdOrDefaultAsync(existingProduct.Id); productInDatabase.Should().BeNull(); }); @@ -970,7 +976,7 @@ await _testContext.RunOnDatabaseAsync(async dbContext => await dbContext.SaveChangesAsync(); }); - string route = "/webProducts/" + existingProduct.StringId; + string route = "/nld/products/" + existingProduct.StringId; // Act (HttpResponseMessage httpResponse, ErrorDocument responseDocument) = await _testContext.ExecuteDeleteAsync(route); @@ -985,5 +991,50 @@ await _testContext.RunOnDatabaseAsync(async dbContext => error.Title.Should().Be("The requested resource does not exist."); error.Detail.Should().Be($"Resource of type 'webProducts' with ID '{existingProduct.StringId}' does not exist."); } + + [Fact] + public async Task Renders_links_with_tenant_route_parameter() + { + // Arrange + WebShop shop = _fakers.WebShop.Generate(); + shop.TenantId = ThisTenantId; + shop.Products = _fakers.WebProduct.Generate(1); + + await _testContext.RunOnDatabaseAsync(async dbContext => + { + await dbContext.ClearTableAsync(); + dbContext.WebShops.Add(shop); + await dbContext.SaveChangesAsync(); + }); + + const string route = "/nld/shops?include=products"; + + // Act + (HttpResponseMessage httpResponse, Document responseDocument) = await _testContext.ExecuteGetAsync(route); + + // Assert + httpResponse.Should().HaveStatusCode(HttpStatusCode.OK); + + responseDocument.Links.Self.Should().Be(route); + responseDocument.Links.Related.Should().BeNull(); + responseDocument.Links.First.Should().Be(route); + responseDocument.Links.Last.Should().BeNull(); + responseDocument.Links.Prev.Should().BeNull(); + responseDocument.Links.Next.Should().BeNull(); + + string shopLink = $"/nld/shops/{shop.StringId}"; + + responseDocument.ManyData.Should().HaveCount(1); + responseDocument.ManyData[0].Links.Self.Should().Be(shopLink); + responseDocument.ManyData[0].Relationships["products"].Links.Self.Should().Be(shopLink + "/relationships/products"); + responseDocument.ManyData[0].Relationships["products"].Links.Related.Should().Be(shopLink + "/products"); + + string productLink = $"/nld/products/{shop.Products[0].StringId}"; + + responseDocument.Included.Should().HaveCount(1); + responseDocument.Included[0].Links.Self.Should().Be(productLink); + responseDocument.Included[0].Relationships["shop"].Links.Self.Should().Be(productLink + "/relationships/shop"); + responseDocument.Included[0].Relationships["shop"].Links.Related.Should().Be(productLink + "/shop"); + } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/RouteTenantProvider.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/RouteTenantProvider.cs new file mode 100644 index 0000000000..56c806530f --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/RouteTenantProvider.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using Microsoft.AspNetCore.Http; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.MultiTenancy +{ + internal sealed class RouteTenantProvider : ITenantProvider + { + // In reality, this would be looked up in a database. We'll keep it hardcoded for simplicity. + public static readonly IDictionary TenantRegistry = new Dictionary + { + ["nld"] = Guid.NewGuid(), + ["ita"] = Guid.NewGuid() + }; + + private readonly IHttpContextAccessor _httpContextAccessor; + + public Guid TenantId + { + get + { + string countryCode = (string)_httpContextAccessor.HttpContext.Request.RouteValues["countryCode"]; + return TenantRegistry.ContainsKey(countryCode) ? TenantRegistry[countryCode] : Guid.Empty; + } + } + + public RouteTenantProvider(IHttpContextAccessor httpContextAccessor) + { + _httpContextAccessor = httpContextAccessor; + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/WebProductsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/WebProductsController.cs index fd9f6f0adc..1eaee35a11 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/WebProductsController.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/WebProductsController.cs @@ -1,10 +1,14 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Controllers.Annotations; using JsonApiDotNetCore.Services; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.MultiTenancy { + [DisableRoutingConvention] + [Route("{countryCode}/products")] public sealed class WebProductsController : JsonApiController { public WebProductsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/WebShopsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/WebShopsController.cs index 03f9ac1adc..c17ec48ec7 100644 --- a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/WebShopsController.cs +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/MultiTenancy/WebShopsController.cs @@ -1,10 +1,14 @@ using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Controllers.Annotations; using JsonApiDotNetCore.Services; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; namespace JsonApiDotNetCoreExampleTests.IntegrationTests.MultiTenancy { + [DisableRoutingConvention] + [Route("{countryCode}/shops")] public sealed class WebShopsController : JsonApiController { public WebShopsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) diff --git a/test/JsonApiDotNetCoreExampleTests/UnitTests/Links/LinkInclusionTests.cs b/test/JsonApiDotNetCoreExampleTests/UnitTests/Links/LinkInclusionTests.cs index dd22f16d2d..e7e628d805 100644 --- a/test/JsonApiDotNetCoreExampleTests/UnitTests/Links/LinkInclusionTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/UnitTests/Links/LinkInclusionTests.cs @@ -1,14 +1,15 @@ +using System; using FluentAssertions; using JsonApiDotNetCore; using JsonApiDotNetCore.Configuration; using JsonApiDotNetCore.Middleware; using JsonApiDotNetCore.Queries; -using JsonApiDotNetCore.QueryStrings; using JsonApiDotNetCore.Resources; using JsonApiDotNetCore.Resources.Annotations; using JsonApiDotNetCore.Serialization.Building; using JsonApiDotNetCore.Serialization.Objects; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; using Xunit; namespace JsonApiDotNetCoreExampleTests.UnitTests.Links @@ -62,7 +63,10 @@ public void Applies_cascading_settings_for_top_level_links(LinkTypes linksInReso TopLevelLinks = linksInResourceContext }; - var resourceGraph = new ResourceGraph(exampleResourceContext.AsArray()); + var options = new JsonApiOptions + { + TopLevelLinks = linksInOptions + }; var request = new JsonApiRequest { @@ -80,14 +84,13 @@ public void Applies_cascading_settings_for_top_level_links(LinkTypes linksInReso TotalResourceCount = 10 }; - var queryStringAccessor = new EmptyRequestQueryStringAccessor(); - - var options = new JsonApiOptions - { - TopLevelLinks = linksInOptions - }; + var resourceGraph = new ResourceGraph(exampleResourceContext.AsArray()); + var httpContextAccessor = new FakeHttpContextAccessor(); + var linkGenerator = new FakeLinkGenerator(); + var controllerResourceMapping = new FakeControllerResourceMapping(); - var linkBuilder = new LinkBuilder(options, request, paginationContext, resourceGraph, queryStringAccessor); + var linkBuilder = new LinkBuilder(options, request, paginationContext, resourceGraph, httpContextAccessor, linkGenerator, + controllerResourceMapping); // Act TopLevelLinks topLevelLinks = linkBuilder.GetTopLevelLinks(); @@ -161,20 +164,20 @@ public void Applies_cascading_settings_for_resource_links(LinkTypes linksInResou ResourceLinks = linksInResourceContext }; - var resourceGraph = new ResourceGraph(exampleResourceContext.AsArray()); - - var request = new JsonApiRequest(); - - var paginationContext = new PaginationContext(); - - var queryStringAccessor = new EmptyRequestQueryStringAccessor(); - var options = new JsonApiOptions { ResourceLinks = linksInOptions }; - var linkBuilder = new LinkBuilder(options, request, paginationContext, resourceGraph, queryStringAccessor); + var request = new JsonApiRequest(); + var paginationContext = new PaginationContext(); + var resourceGraph = new ResourceGraph(exampleResourceContext.AsArray()); + var httpContextAccessor = new FakeHttpContextAccessor(); + var linkGenerator = new FakeLinkGenerator(); + var controllerResourceMapping = new FakeControllerResourceMapping(); + + var linkBuilder = new LinkBuilder(options, request, paginationContext, resourceGraph, httpContextAccessor, linkGenerator, + controllerResourceMapping); // Act ResourceLinks resourceLinks = linkBuilder.GetResourceLinks(nameof(ExampleResource), "id"); @@ -327,20 +330,20 @@ public void Applies_cascading_settings_for_relationship_links(LinkTypes linksInR RelationshipLinks = linksInResourceContext }; - var resourceGraph = new ResourceGraph(exampleResourceContext.AsArray()); - - var request = new JsonApiRequest(); - - var paginationContext = new PaginationContext(); - - var queryStringAccessor = new EmptyRequestQueryStringAccessor(); - var options = new JsonApiOptions { RelationshipLinks = linksInOptions }; - var linkBuilder = new LinkBuilder(options, request, paginationContext, resourceGraph, queryStringAccessor); + var request = new JsonApiRequest(); + var paginationContext = new PaginationContext(); + var resourceGraph = new ResourceGraph(exampleResourceContext.AsArray()); + var httpContextAccessor = new FakeHttpContextAccessor(); + var linkGenerator = new FakeLinkGenerator(); + var controllerResourceMapping = new FakeControllerResourceMapping(); + + var linkBuilder = new LinkBuilder(options, request, paginationContext, resourceGraph, httpContextAccessor, linkGenerator, + controllerResourceMapping); var relationship = new HasOneAttribute { @@ -377,13 +380,62 @@ public void Applies_cascading_settings_for_relationship_links(LinkTypes linksInR } } - private sealed class EmptyRequestQueryStringAccessor : IRequestQueryStringAccessor + private sealed class ExampleResource : Identifiable { - public IQueryCollection Query { get; } = new QueryCollection(); } - private sealed class ExampleResource : Identifiable + private sealed class FakeHttpContextAccessor : IHttpContextAccessor + { + public HttpContext HttpContext { get; set; } = new DefaultHttpContext + { + Request = + { + Scheme = "http", + Host = new HostString("localhost") + } + }; + } + + private sealed class FakeControllerResourceMapping : IControllerResourceMapping + { + public Type GetResourceTypeForController(Type controllerType) + { + throw new NotImplementedException(); + } + + public string GetControllerNameForResourceType(Type resourceType) + { + return null; + } + } + + private sealed class FakeLinkGenerator : LinkGenerator { + public override string GetPathByAddress(HttpContext httpContext, TAddress address, RouteValueDictionary values, + RouteValueDictionary ambientValues = null, PathString? pathBase = null, FragmentString fragment = new FragmentString(), + LinkOptions options = null) + { + throw new NotImplementedException(); + } + + public override string GetPathByAddress(TAddress address, RouteValueDictionary values, PathString pathBase = new PathString(), + FragmentString fragment = new FragmentString(), LinkOptions options = null) + { + throw new NotImplementedException(); + } + + public override string GetUriByAddress(HttpContext httpContext, TAddress address, RouteValueDictionary values, + RouteValueDictionary ambientValues = null, string scheme = null, HostString? host = null, PathString? pathBase = null, + FragmentString fragment = new FragmentString(), LinkOptions options = null) + { + return "https://domain.com/some/path"; + } + + public override string GetUriByAddress(TAddress address, RouteValueDictionary values, string scheme, HostString host, + PathString pathBase = new PathString(), FragmentString fragment = new FragmentString(), LinkOptions options = null) + { + throw new NotImplementedException(); + } } } } From 4afa439c20344b975efeb5febc9d5e0b7860b81b Mon Sep 17 00:00:00 2001 From: Bart Koelman Date: Fri, 28 May 2021 12:01:31 +0200 Subject: [PATCH 2/2] Small tweaks --- .../Serialization/Building/LinkBuilder.cs | 37 +++++++++++-------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/src/JsonApiDotNetCore/Serialization/Building/LinkBuilder.cs b/src/JsonApiDotNetCore/Serialization/Building/LinkBuilder.cs index cdf81515df..ea29bfa4d1 100644 --- a/src/JsonApiDotNetCore/Serialization/Building/LinkBuilder.cs +++ b/src/JsonApiDotNetCore/Serialization/Building/LinkBuilder.cs @@ -23,9 +23,9 @@ public class LinkBuilder : ILinkBuilder private const string PageSizeParameterName = "page[size]"; private const string PageNumberParameterName = "page[number]"; - private static readonly string GetPrimaryControllerAction = WithoutAsyncSuffix(nameof(BaseJsonApiController.GetAsync)); - private static readonly string GetSecondaryControllerAction = WithoutAsyncSuffix(nameof(BaseJsonApiController.GetSecondaryAsync)); - private static readonly string GetRelationshipControllerAction = WithoutAsyncSuffix(nameof(BaseJsonApiController.GetRelationshipAsync)); + private static readonly string GetPrimaryControllerActionName = NoAsyncSuffix(nameof(BaseJsonApiController.GetAsync)); + private static readonly string GetSecondaryControllerActionName = NoAsyncSuffix(nameof(BaseJsonApiController.GetSecondaryAsync)); + private static readonly string GetRelationshipControllerActionName = NoAsyncSuffix(nameof(BaseJsonApiController.GetRelationshipAsync)); private readonly IJsonApiOptions _options; private readonly IJsonApiRequest _request; @@ -55,7 +55,7 @@ public LinkBuilder(IJsonApiOptions options, IJsonApiRequest request, IPagination _controllerResourceMapping = controllerResourceMapping; } - private static string WithoutAsyncSuffix(string actionName) + private static string NoAsyncSuffix(string actionName) { return actionName.EndsWith("Async", StringComparison.Ordinal) ? actionName[..^"Async".Length] : actionName; } @@ -185,6 +185,19 @@ private IList ParsePageSizeExpressi } private string GetLinkForPagination(int pageOffset, string pageSizeValue) + { + string queryStringValue = GetQueryStringInPaginationLink(pageOffset, pageSizeValue); + + var builder = new UriBuilder(_httpContextAccessor.HttpContext.Request.GetEncodedUrl()) + { + Query = queryStringValue + }; + + UriComponents components = _options.UseRelativeLinks ? UriComponents.PathAndQuery : UriComponents.AbsoluteUri; + return builder.Uri.GetComponents(components, UriFormat.SafeUnescaped); + } + + private string GetQueryStringInPaginationLink(int pageOffset, string pageSizeValue) { IDictionary parameters = _httpContextAccessor.HttpContext.Request.Query.ToDictionary(pair => pair.Key, pair => pair.Value.ToString()); @@ -208,15 +221,7 @@ private string GetLinkForPagination(int pageOffset, string pageSizeValue) } string queryStringValue = QueryString.Create(parameters).Value; - queryStringValue = DecodeSpecialCharacters(queryStringValue); - - var builder = new UriBuilder(_httpContextAccessor.HttpContext.Request.GetEncodedUrl()) - { - Query = queryStringValue - }; - - UriComponents components = _options.UseRelativeLinks ? UriComponents.PathAndQuery : UriComponents.AbsoluteUri; - return builder.Uri.GetComponents(components, UriFormat.SafeUnescaped); + return DecodeSpecialCharacters(queryStringValue); } private static string DecodeSpecialCharacters(string uri) @@ -260,7 +265,7 @@ private string GetLinkForResourceSelf(ResourceContext resourceContext, string re string controllerName = _controllerResourceMapping.GetControllerNameForResourceType(resourceContext.ResourceType); IDictionary routeValues = GetRouteValues(resourceId, null); - return RenderLinkForAction(controllerName, GetPrimaryControllerAction, routeValues); + return RenderLinkForAction(controllerName, GetPrimaryControllerActionName, routeValues); } /// @@ -290,7 +295,7 @@ private string GetLinkForRelationshipSelf(string primaryId, RelationshipAttribut string controllerName = _controllerResourceMapping.GetControllerNameForResourceType(relationship.LeftType); IDictionary routeValues = GetRouteValues(primaryId, relationship.PublicName); - return RenderLinkForAction(controllerName, GetRelationshipControllerAction, routeValues); + return RenderLinkForAction(controllerName, GetRelationshipControllerActionName, routeValues); } private string GetLinkForRelationshipRelated(string primaryId, RelationshipAttribute relationship) @@ -298,7 +303,7 @@ private string GetLinkForRelationshipRelated(string primaryId, RelationshipAttri string controllerName = _controllerResourceMapping.GetControllerNameForResourceType(relationship.LeftType); IDictionary routeValues = GetRouteValues(primaryId, relationship.PublicName); - return RenderLinkForAction(controllerName, GetSecondaryControllerAction, routeValues); + return RenderLinkForAction(controllerName, GetSecondaryControllerActionName, routeValues); } private IDictionary GetRouteValues(string primaryId, string relationshipName)