Skip to content

Instantly share code, notes, and snippets.

@alastairtree
Created February 22, 2021 01:05
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save alastairtree/dad60c363a4d7b3726e3b3322443bf48 to your computer and use it in GitHub Desktop.
Save alastairtree/dad60c363a4d7b3726e3b3322443bf48 to your computer and use it in GitHub Desktop.
MVC Route aware ILinkBuilder for JsonApiDotNet 4 & aspnet core 3.1
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
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.Building;
using JsonApiDotNetCore.Serialization.Objects;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
namespace JsonApiDotNetCore.Serialization.Building
{
public class RoutingLinkBuilder : ILinkBuilder
{
private const string _pageSizeParameterName = "page[size]";
private const string _pageNumberParameterName = "page[number]";
private readonly IResourceContextProvider _provider;
private readonly IRequestQueryStringAccessor _queryStringAccessor;
private readonly LinkGenerator _linkGenerator;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IJsonApiOptions _options;
private readonly IJsonApiRequest _request;
private readonly IPaginationContext _paginationContext;
public const string GetResourceByIdActionName = "Get";
public RoutingLinkBuilder(IJsonApiOptions options,
IJsonApiRequest request,
IPaginationContext paginationContext,
IResourceContextProvider provider,
IRequestQueryStringAccessor queryStringAccessor,
LinkGenerator linkGenerator,
IHttpContextAccessor httpContextAccessor)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_request = request ?? throw new ArgumentNullException(nameof(request));
_paginationContext = paginationContext ?? throw new ArgumentNullException(nameof(paginationContext));
_provider = provider ?? throw new ArgumentNullException(nameof(provider));
_queryStringAccessor = queryStringAccessor ?? throw new ArgumentNullException(nameof(queryStringAccessor));
_linkGenerator = linkGenerator;
_httpContextAccessor = httpContextAccessor;
}
/// <inheritdoc />
public TopLevelLinks GetTopLevelLinks()
{
ResourceContext resourceContext = _request.PrimaryResource;
TopLevelLinks topLevelLinks = null;
if (ShouldAddTopLevelLink(resourceContext, LinkTypes.Self))
{
topLevelLinks = new TopLevelLinks {Self = GetSelfTopLevelLink(resourceContext, null)};
}
if (ShouldAddTopLevelLink(resourceContext, LinkTypes.Related) && _request.Kind == EndpointKind.Relationship)
{
topLevelLinks ??= new TopLevelLinks();
topLevelLinks.Related = GetRelatedRelationshipLink(_request.PrimaryResource.PublicName, _request.PrimaryId, _request.Relationship.PublicName, resourceContext);
}
if (ShouldAddTopLevelLink(resourceContext, LinkTypes.Paging) && _paginationContext.PageSize != null && _request.IsCollection)
{
SetPageLinks(resourceContext, topLevelLinks ??= new TopLevelLinks());
}
return topLevelLinks;
}
/// <summary>
/// Checks if the top-level <paramref name="link"/> should be added by first checking
/// configuration on the <see cref="ResourceContext"/>, and if not configured, by checking with the
/// global configuration in <see cref="IJsonApiOptions"/>.
/// </summary>
private bool ShouldAddTopLevelLink(ResourceContext resourceContext, LinkTypes link)
{
if (resourceContext.TopLevelLinks != LinkTypes.NotConfigured)
{
return resourceContext.TopLevelLinks.HasFlag(link);
}
return _options.TopLevelLinks.HasFlag(link);
}
private void SetPageLinks(ResourceContext resourceContext, TopLevelLinks links)
{
links.First = GetPageLink(resourceContext, 1, _paginationContext.PageSize);
if (_paginationContext.TotalPageCount > 0)
{
links.Last = GetPageLink(resourceContext, _paginationContext.TotalPageCount.Value, _paginationContext.PageSize);
}
if (_paginationContext.PageNumber.OneBasedValue > 1)
{
links.Prev = GetPageLink(resourceContext, _paginationContext.PageNumber.OneBasedValue - 1, _paginationContext.PageSize);
}
bool hasNextPage = _paginationContext.PageNumber.OneBasedValue < _paginationContext.TotalPageCount;
bool possiblyHasNextPage = _paginationContext.TotalPageCount == null && _paginationContext.IsPageFull;
if (hasNextPage || possiblyHasNextPage)
{
links.Next = GetPageLink(resourceContext, _paginationContext.PageNumber.OneBasedValue + 1, _paginationContext.PageSize);
}
}
private string GetSelfTopLevelLink(ResourceContext resourceContext, Action<Dictionary<string, string>> queryStringUpdateAction)
{
var builder = new StringBuilder();
builder.Append(GetResourceLink(resourceContext, resourceContext.PublicName, _request.PrimaryId));
// TODO: could probably also use _linkGenerator for the /relationship+ bits of the links but
// they are more predictable than the base resource urls
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<Dictionary<string, string>> updateAction)
{
var 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)
{
return GetSelfTopLevelLink(resourceContext, parameters =>
{
var 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();
}
});
}
private string ChangeTopPageSize(string pageSizeParameterValue, PageSize topPageSize)
{
var elements = ParsePageSizeExpression(pageSizeParameterValue);
var elementInTopScopeIndex = elements.FindIndex(expression => expression.Scope == null);
if (topPageSize != null)
{
var topPageSizeElement = new PaginationElementQueryStringValueExpression(null, topPageSize.Value);
if (elementInTopScopeIndex != -1)
{
elements[elementInTopScopeIndex] = topPageSizeElement;
}
else
{
elements.Insert(0, topPageSizeElement);
}
}
else
{
if (elementInTopScopeIndex != -1)
{
elements.RemoveAt(elementInTopScopeIndex);
}
}
var parameterValue = string.Join(',',
elements.Select(expression => expression.Scope == null ? expression.Value.ToString() : $"{expression.Scope}:{expression.Value}"));
return parameterValue == string.Empty ? null : parameterValue;
}
private List<PaginationElementQueryStringValueExpression> ParsePageSizeExpression(string pageSizeParameterValue)
{
if (pageSizeParameterValue == null)
{
return new List<PaginationElementQueryStringValueExpression>();
}
var requestResource = _request.SecondaryResource ?? _request.PrimaryResource;
var parser = new PaginationParser(_provider);
var paginationExpression = parser.Parse(pageSizeParameterValue, requestResource);
return new List<PaginationElementQueryStringValueExpression>(paginationExpression.Elements);
}
/// <inheritdoc />
public ResourceLinks GetResourceLinks(string resourceName, string id)
{
if (resourceName == null) throw new ArgumentNullException(nameof(resourceName));
if (id == null) throw new ArgumentNullException(nameof(id));
var resourceContext = _provider.GetResourceContext(resourceName);
if (ShouldAddResourceLink(resourceContext, LinkTypes.Self))
{
return new ResourceLinks
{
Self = GetResourceLink(resourceContext, resourceName, id)
};
}
return null;
}
/// <inheritdoc />
public RelationshipLinks GetRelationshipLinks(RelationshipAttribute relationship, IIdentifiable parent)
{
if (relationship == null) throw new ArgumentNullException(nameof(relationship));
if (parent == null) throw new ArgumentNullException(nameof(parent));
var parentResourceContext = _provider.GetResourceContext(parent.GetType());
var childNavigation = relationship.PublicName;
RelationshipLinks links = null;
if (ShouldAddRelationshipLink(parentResourceContext, relationship, LinkTypes.Related))
{
links = new RelationshipLinks { Related = GetRelatedRelationshipLink(parentResourceContext.PublicName, parent.StringId, childNavigation, parentResourceContext) };
}
if (ShouldAddRelationshipLink(parentResourceContext, relationship, LinkTypes.Self))
{
links ??= new RelationshipLinks();
links.Self = GetSelfRelationshipLink(parentResourceContext.PublicName, parent.StringId, childNavigation, parentResourceContext);
}
return links;
}
private string GetSelfRelationshipLink(string parent, string parentId, string navigation,
ResourceContext parentResourceContext)
{
return $"{GetResourceLink(parentResourceContext, parent, parentId)}/relationships/{navigation}";
}
/// <summary>
/// Example1: GET /api/articles/123 HTTP/1.1
/// Example2: GET /api/articles HTTP/1.1
/// </summary>
private string GetResourceLink(ResourceContext resourceContext, string resource, string resourceId = null)
{
// if current request has any route params specified we need to fetch them here to reuse them,
// and append/set the id of the resource we are routing to
var routeData =
new RouteValueDictionary(_httpContextAccessor.HttpContext.Request.RouteValues) { ["id"] = resourceId };
var controllerName = routeData["controller"]?.ToString() ?? resource;
var link = _linkGenerator.GetPathByAction(
_httpContextAccessor.HttpContext, action: GetResourceByIdActionName
, controllerName, routeData);
return link;
}
private string GetRelatedRelationshipLink(string parent, string parentId, string navigation,
ResourceContext resourceContext)
{
return $"{GetResourceLink(resourceContext, parent, parentId)}/{navigation}";
}
/// <summary>
/// Checks if the resource object level <paramref name="link"/> should be added by first checking
/// configuration on the <see cref="ResourceContext"/>, and if not configured, by checking with the
/// global configuration in <see cref="IJsonApiOptions"/>.
/// </summary>
private bool ShouldAddResourceLink(ResourceContext resourceContext, LinkTypes link)
{
if (_request.Kind == EndpointKind.Relationship)
{
return false;
}
if (resourceContext.ResourceLinks != LinkTypes.NotConfigured)
{
return resourceContext.ResourceLinks.HasFlag(link);
}
return _options.ResourceLinks.HasFlag(link);
}
/// <summary>
/// Checks if the resource object level <paramref name="link"/> should be added by first checking
/// configuration on the <paramref name="relationship"/> attribute, if not configured by checking
/// the <see cref="ResourceContext"/>, and if not configured by checking with the
/// global configuration in <see cref="IJsonApiOptions"/>.
/// </summary>
private bool ShouldAddRelationshipLink(ResourceContext resourceContext, RelationshipAttribute relationship, LinkTypes link)
{
if (relationship.Links != LinkTypes.NotConfigured)
{
return relationship.Links.HasFlag(link);
}
if (resourceContext.RelationshipLinks != LinkTypes.NotConfigured)
{
return resourceContext.RelationshipLinks.HasFlag(link);
}
return _options.RelationshipLinks.HasFlag(link);
}
}
}
@alastairtree
Copy link
Author

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment