diff --git a/src/JsonApiDotNetCore/Middleware/CurrentRequestMiddleware.cs b/src/JsonApiDotNetCore/Middleware/CurrentRequestMiddleware.cs index 0bf055cfdb..b47e87d636 100644 --- a/src/JsonApiDotNetCore/Middleware/CurrentRequestMiddleware.cs +++ b/src/JsonApiDotNetCore/Middleware/CurrentRequestMiddleware.cs @@ -1,4 +1,5 @@ using System; +using System.ComponentModel; using System.Linq; using System.Threading.Tasks; using JsonApiDotNetCore.Configuration; @@ -6,6 +7,7 @@ using JsonApiDotNetCore.Internal.Contracts; using JsonApiDotNetCore.Managers.Contracts; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Primitives; @@ -21,6 +23,7 @@ public class CurrentRequestMiddleware private ICurrentRequest _currentRequest; private IResourceGraph _resourceGraph; private IJsonApiOptions _options; + private RouteValueDictionary _routeValues; private IControllerResourceMapping _controllerResourceMapping; public CurrentRequestMiddleware(RequestDelegate next) @@ -39,12 +42,15 @@ public async Task Invoke(HttpContext httpContext, _controllerResourceMapping = controllerResourceMapping; _resourceGraph = resourceGraph; _options = options; + _routeValues = httpContext.GetRouteData().Values; var requestResource = GetCurrentEntity(); if (requestResource != null) { - _currentRequest.SetRequestResource(GetCurrentEntity()); + _currentRequest.SetRequestResource(requestResource); _currentRequest.IsRelationshipPath = PathIsRelationship(); - _currentRequest.BasePath = GetBasePath(_currentRequest.GetRequestResource().ResourceName); + _currentRequest.BasePath = GetBasePath(requestResource.ResourceName); + _currentRequest.BaseId = GetBaseId(); + _currentRequest.RelationshipId = GetRelationshipId(); } if (IsValid()) @@ -53,50 +59,127 @@ public async Task Invoke(HttpContext httpContext, } } - private string GetBasePath(string entityName) + private string GetBaseId() { - var r = _httpContext.Request; - if (_options.RelativeLinks) + var resource = _currentRequest.GetRequestResource(); + var individualComponents = SplitCurrentPath(); + if (individualComponents.Length < 2) { - return GetNamespaceFromPath(r.Path, entityName); + return null; } - return $"{r.Scheme}://{r.Host}{GetNamespaceFromPath(r.Path, entityName)}"; + var indexOfResource = individualComponents.ToList().FindIndex(c => c == resource.ResourceName); + var baseId = individualComponents.ElementAtOrDefault(indexOfResource + 1); + if (baseId == null) + { + return null; + } + CheckIdType(baseId, resource.IdentityType); + return baseId; } + private string GetRelationshipId() + { + var resource = _currentRequest.GetRequestResource(); + if (!_currentRequest.IsRelationshipPath) + { + return null; + } + var components = SplitCurrentPath(); + var toReturn = components.ElementAtOrDefault(4); - internal static string GetNamespaceFromPath(string path, string entityName) + if (toReturn == null) + { + return null; + } + var relType = _currentRequest.RequestRelationship.RightType; + var relResource = _resourceGraph.GetResourceContext(relType); + var relIdentityType = relResource.IdentityType; + CheckIdType(toReturn, relIdentityType); + return toReturn; + } + private string[] SplitCurrentPath() { - var entityNameSpan = entityName.AsSpan(); - var pathSpan = path.AsSpan(); - const char delimiter = '/'; - for (var i = 0; i < pathSpan.Length; i++) + var path = _httpContext.Request.Path.Value; + var ns = $"/{GetNameSpace()}"; + var nonNameSpaced = path.Replace(ns, ""); + nonNameSpaced = nonNameSpaced.Trim('/'); + var individualComponents = nonNameSpaced.Split('/'); + return individualComponents; + } + + + private void CheckIdType(string value, Type idType) + { + try { - if (pathSpan[i].Equals(delimiter)) + var converter = TypeDescriptor.GetConverter(idType); + if (converter != null) { - var nextPosition = i + 1; - if (pathSpan.Length > i + entityNameSpan.Length) + if (!converter.IsValid(value)) { - var possiblePathSegment = pathSpan.Slice(nextPosition, entityNameSpan.Length); - if (entityNameSpan.SequenceEqual(possiblePathSegment)) + throw new JsonApiException(500, $"We could not convert the id '{value}'"); + } + else + { + if (idType == typeof(int)) { - // check to see if it's the last position in the string - // or if the next character is a / - var lastCharacterPosition = nextPosition + entityNameSpan.Length; - - if (lastCharacterPosition == pathSpan.Length || pathSpan.Length >= lastCharacterPosition + 2 && pathSpan[lastCharacterPosition].Equals(delimiter)) + if ((int)converter.ConvertFromString(value) < 0) { - return pathSpan.Slice(0, i).ToString(); + throw new JsonApiException(500, "The base ID is an integer, and it is negative."); } } } } } + catch (NotSupportedException) + { + + } - return string.Empty; + } + + private string GetBasePath(string resourceName = null) + { + var r = _httpContext.Request; + if (_options.RelativeLinks) + { + return GetNameSpace(resourceName); + } + var ns = GetNameSpace(resourceName); + var customRoute = GetCustomRoute(r.Path.Value, resourceName); + var toReturn = $"{r.Scheme}://{r.Host}/{ns}"; + if(customRoute != null) + { + toReturn += $"/{customRoute}"; + } + return toReturn; + } + + private object GetCustomRoute(string path, string resourceName) + { + var ns = GetNameSpace(); + var trimmedComponents = path.Trim('/').Split('/').ToList(); + var resourceNameIndex = trimmedComponents.FindIndex(c => c == resourceName); + var newComponents = trimmedComponents.Take(resourceNameIndex ).ToArray(); + var customRoute = string.Join('/', newComponents); + if(customRoute == ns) + { + return null; + } + else + { + return customRoute; + } + } + + private string GetNameSpace(string resourceName = null) + { + + return _options.Namespace; } protected bool PathIsRelationship() { - var actionName = (string)_httpContext.GetRouteData().Values["action"]; + var actionName = (string)_routeValues["action"]; return actionName.ToLower().Contains("relationships"); } @@ -124,7 +207,9 @@ private bool IsValidAcceptHeader(HttpContext context) foreach (var acceptHeader in acceptHeaders) { if (ContainsMediaTypeParameters(acceptHeader) == false) + { continue; + } FlushResponse(context, 406); return false; @@ -165,16 +250,21 @@ private void FlushResponse(HttpContext context, int statusCode) /// private ResourceContext GetCurrentEntity() { - var controllerName = (string)_httpContext.GetRouteValue("controller"); + var controllerName = (string)_routeValues["controller"]; if (controllerName == null) + { return null; + } var resourceType = _controllerResourceMapping.GetAssociatedResource(controllerName); var requestResource = _resourceGraph.GetResourceContext(resourceType); if (requestResource == null) + { return requestResource; - var rd = _httpContext.GetRouteData().Values; - if (rd.TryGetValue("relationshipName", out object relationshipName)) + } + if (_routeValues.TryGetValue("relationshipName", out object relationshipName)) + { _currentRequest.RequestRelationship = requestResource.Relationships.Single(r => r.PublicRelationshipName == (string)relationshipName); + } return requestResource; } } diff --git a/src/JsonApiDotNetCore/RequestServices/Contracts/ICurrentRequest.cs b/src/JsonApiDotNetCore/RequestServices/Contracts/ICurrentRequest.cs index 0260db8c91..15eddf495e 100644 --- a/src/JsonApiDotNetCore/RequestServices/Contracts/ICurrentRequest.cs +++ b/src/JsonApiDotNetCore/RequestServices/Contracts/ICurrentRequest.cs @@ -29,6 +29,8 @@ public interface ICurrentRequest /// is the relationship attribute associated with the targeted relationship /// RelationshipAttribute RequestRelationship { get; set; } + string BaseId { get; set; } + string RelationshipId { get; set; } /// /// Sets the current context entity for this entire request diff --git a/src/JsonApiDotNetCore/RequestServices/CurrentRequest.cs b/src/JsonApiDotNetCore/RequestServices/CurrentRequest.cs index abb1b5863a..053cf39cfb 100644 --- a/src/JsonApiDotNetCore/RequestServices/CurrentRequest.cs +++ b/src/JsonApiDotNetCore/RequestServices/CurrentRequest.cs @@ -10,6 +10,8 @@ class CurrentRequest : ICurrentRequest public string BasePath { get; set; } public bool IsRelationshipPath { get; set; } public RelationshipAttribute RequestRelationship { get; set; } + public string BaseId { get; set; } + public string RelationshipId { get; set; } /// /// The main resource of the request. diff --git a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomControllerTests.cs b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomControllerTests.cs index 4797732c6b..a03a213534 100644 --- a/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomControllerTests.cs +++ b/test/JsonApiDotNetCoreExampleTests/Acceptance/Extensibility/CustomControllerTests.cs @@ -128,7 +128,7 @@ public async Task CustomRouteControllers_Creates_Proper_Relationship_Links() var deserializedBody = JsonConvert.DeserializeObject(body); var result = deserializedBody["data"]["relationships"]["owner"]["links"]["related"].ToString(); - Assert.EndsWith($"{route}/owner", deserializedBody["data"]["relationships"]["owner"]["links"]["related"].ToString()); + Assert.EndsWith($"{route}/owner", result); } } } diff --git a/test/UnitTests/Middleware/CurrentRequestMiddlewareTests.cs b/test/UnitTests/Middleware/CurrentRequestMiddlewareTests.cs new file mode 100644 index 0000000000..01b26a69b1 --- /dev/null +++ b/test/UnitTests/Middleware/CurrentRequestMiddlewareTests.cs @@ -0,0 +1,209 @@ +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Internal; +using JsonApiDotNetCore.Internal.Contracts; +using JsonApiDotNetCore.Managers; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Models; +using JsonApiDotNetCoreExample.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Routing; +using Moq; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Xunit; + +namespace UnitTests.Middleware +{ + public class CurrentRequestMiddlewareTests + { + [Fact] + public async Task ParseUrlBase_UrlHasBaseIdSet_ShouldSetCurrentRequestWithSaidId() + { + // Arrange + var id = "123"; + var configuration = GetConfiguration($"/users/{id}"); + var currentRequest = configuration.CurrentRequest; + + // Act + await RunMiddlewareTask(configuration); + + // Assert + Assert.Equal(id, currentRequest.BaseId); + } + + [Fact] + public async Task ParseUrlBase_UrlHasNoBaseIdSet_ShouldHaveBaseIdSetToNull() + { + // Arrange + var configuration = GetConfiguration("/users"); + var currentRequest = configuration.CurrentRequest; + + // Act + await RunMiddlewareTask(configuration); + + // Assert + Assert.Null(currentRequest.BaseId); + } + [Fact] + public async Task ParseUrlRel_UrlHasRelationshipIdSet_ShouldHaveBaseIdAndRelationshipIdSet() + { + // Arrange + var baseId = "5"; + var relId = "23"; + var configuration = GetConfiguration($"/users/{baseId}/relationships/books/{relId}", relType: typeof(TodoItem), relIdType: typeof(int)); + var currentRequest = configuration.CurrentRequest; + + // Act + await RunMiddlewareTask(configuration); + + // Assert + Assert.Equal(baseId, currentRequest.BaseId); + Assert.Equal(relId, currentRequest.RelationshipId); + } + + [Fact] + public async Task ParseUrlBase_UrlHasNegativeBaseIdAndTypeIsInt_ShouldThrowJAException() + { + // Arrange + var configuration = GetConfiguration("/users/-5/"); + + // Act + var task = RunMiddlewareTask(configuration); + + // Assert + var exception = await Assert.ThrowsAsync(async () => + { + await task; + }); + Assert.Equal(500, exception.GetStatusCode()); + Assert.Contains("negative", exception.Message); + } + + [Theory] + [InlineData("12315K", typeof(int), true)] + [InlineData("12315K", typeof(int), false)] + [InlineData("5", typeof(Guid), true)] + [InlineData("5", typeof(Guid), false)] + public async Task ParseUrlBase_UrlHasIncorrectBaseIdSet_ShouldThrowException(string baseId, Type idType, bool addSlash) + { + // Arrange + var url = addSlash ? $"/users/{baseId}/" : $"/users/{baseId}"; + var configuration = GetConfiguration(url, idType: idType); + + // Act + var task = RunMiddlewareTask(configuration); + + // Assert + var exception = await Assert.ThrowsAsync(async () => + { + await task; + }); + Assert.Equal(500, exception.GetStatusCode()); + Assert.Contains(baseId, exception.Message); + } + + class InvokeConfiguration + { + public CurrentRequestMiddleware MiddleWare; + public HttpContext HttpContext; + public Mock ControllerResourcemapping; + public Mock Options; + public CurrentRequest CurrentRequest; + public Mock ResourceGraph; + } + private Task RunMiddlewareTask(InvokeConfiguration holder) + { + var controllerResourceMapping = holder.ControllerResourcemapping.Object; + var context = holder.HttpContext; + var options = holder.Options.Object; + var currentRequest = holder.CurrentRequest; + var resourceGraph = holder.ResourceGraph.Object; + return holder.MiddleWare.Invoke(context, controllerResourceMapping, options, currentRequest, resourceGraph); + } + private InvokeConfiguration GetConfiguration(string path, string resourceName = "users", Type idType = null, Type relType = null, Type relIdType = null) + { + if((relType != null) != (relIdType != null)) + { + throw new ArgumentException("Define both reltype and relidType or dont."); + } + if (path.First() != '/') + { + throw new ArgumentException("Path should start with a '/'"); + } + idType ??= typeof(int); + var middleware = new CurrentRequestMiddleware((context) => + { + return Task.Run(() => Console.WriteLine("finished")); + }); + var forcedNamespace = "api/v1"; + var mockMapping = new Mock(); + Mock mockOptions = CreateMockOptions(forcedNamespace); + var mockGraph = CreateMockResourceGraph(idType, resourceName, relIdType : relIdType); + var currentRequest = new CurrentRequest(); + if (relType != null && relIdType != null) + { + currentRequest.RequestRelationship = new HasManyAttribute + { + RightType = relType + }; + } + var context = CreateHttpContext(path, isRelationship: relType != null); + return new InvokeConfiguration + { + MiddleWare = middleware, + ControllerResourcemapping = mockMapping, + Options = mockOptions, + CurrentRequest = currentRequest, + HttpContext = context, + ResourceGraph = mockGraph + }; + } + + private static Mock CreateMockOptions(string forcedNamespace) + { + var mockOptions = new Mock(); + mockOptions.Setup(o => o.Namespace).Returns(forcedNamespace); + return mockOptions; + } + + private static DefaultHttpContext CreateHttpContext(string path, bool isRelationship = false) + { + var context = new DefaultHttpContext(); + context.Request.Path = new PathString(path); + context.Response.Body = new MemoryStream(); + var feature = new RouteValuesFeature(); + feature.RouteValues["controller"] = "fake!"; + feature.RouteValues["action"] = isRelationship ? "relationships" : "noRel"; + context.Features.Set(feature); + return context; + } + + private Mock CreateMockResourceGraph(Type idType, string resourceName, Type relIdType = null) + { + var mockGraph = new Mock(); + var resourceContext = new ResourceContext + { + ResourceName = resourceName, + IdentityType = idType + }; + var seq = mockGraph.SetupSequence(d => d.GetResourceContext(It.IsAny())).Returns(resourceContext); + if (relIdType != null) + { + var relResourceContext = new ResourceContext + { + ResourceName = "todoItems", + IdentityType = relIdType + }; + seq.Returns(relResourceContext); + } + return mockGraph; + } + + } +}