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..ea29bfa4d1 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 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; 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 NoAsyncSuffix(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); - } - } - - 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); + links.Next = GetLinkForPagination(_paginationContext.PageNumber.OneBasedValue + 1, pageSizeValue); } - - 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) - { - return uri.Replace("%5B", "[").Replace("%5D", "]").Replace("%27", "'").Replace("%3A", ":"); } - private string GetPageLink(ResourceContext resourceContext, int pageOffset, PageSize pageSize) + private string CalculatePageSizeValue(PageSize topPageSize, ResourceContext requestContext) { - 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; - } + string pageSizeParameterValue = _httpContextAccessor.HttpContext.Request.Query[PageSizeParameterName]; - 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,101 @@ 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) + { + 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()); + + 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; + return DecodeSpecialCharacters(queryStringValue); + } + + 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 null; + 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 _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, GetPrimaryControllerActionName, routeValues); } /// @@ -256,79 +274,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, GetRelationshipControllerActionName, 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, GetSecondaryControllerActionName, 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(); + } } } }