Skip to content

Make InputFormatters and OutputFormatters implement IApiRequestFormatMetadataProvider #967

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 10 commits into from
47 changes: 45 additions & 2 deletions src/JsonApiDotNetCore/Middleware/JsonApiInputFormatter.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Threading.Tasks;
using JsonApiDotNetCore.Resources;
using JsonApiDotNetCore.Serialization;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.AspNetCore.Mvc.Formatters;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Net.Http.Headers;

namespace JsonApiDotNetCore.Middleware
{
/// <inheritdoc />
public sealed class JsonApiInputFormatter : IJsonApiInputFormatter
public sealed class JsonApiInputFormatter : IJsonApiInputFormatter, IApiRequestFormatMetadataProvider
{
/// <inheritdoc />
public bool CanRead(InputFormatterContext context)
Expand All @@ -24,5 +29,43 @@ public async Task<InputFormatterResult> ReadAsync(InputFormatterContext context)
var reader = context.HttpContext.RequestServices.GetRequiredService<IJsonApiReader>();
return await reader.ReadAsync(context);
}

/// <inheritdoc />
public IReadOnlyList<string> GetSupportedContentTypes(string contentType, Type objectType)
{
ArgumentGuard.NotNull(objectType, nameof(objectType));

var mediaTypes = new MediaTypeCollection();

switch (contentType)
{
case HeaderConstants.AtomicOperationsMediaType when IsOperationsType(objectType):
{
mediaTypes.Add(MediaTypeHeaderValue.Parse(HeaderConstants.AtomicOperationsMediaType));
break;
}
case HeaderConstants.MediaType when IsJsonApiResource(objectType):
{
mediaTypes.Add(MediaTypeHeaderValue.Parse(HeaderConstants.MediaType));
break;
}
}

return mediaTypes;
}

private bool IsJsonApiResource(Type type)
{
Type typeToCheck = typeof(IEnumerable).IsAssignableFrom(type) ? type.GetGenericArguments()[0] : type;

return typeToCheck.IsOrImplementsInterface(typeof(IIdentifiable)) || typeToCheck == typeof(object);
}

private bool IsOperationsType(Type type)
{
Type typeToCheck = typeof(IEnumerable).IsAssignableFrom(type) ? type.GetGenericArguments()[0] : type;

return typeToCheck == typeof(OperationContainer);
}
}
}
29 changes: 27 additions & 2 deletions src/JsonApiDotNetCore/Middleware/JsonApiOutputFormatter.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Threading.Tasks;
using JsonApiDotNetCore.Resources;
using JsonApiDotNetCore.Serialization;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.AspNetCore.Mvc.Formatters;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Net.Http.Headers;

namespace JsonApiDotNetCore.Middleware
{
/// <inheritdoc />
public sealed class JsonApiOutputFormatter : IJsonApiOutputFormatter
public sealed class JsonApiOutputFormatter : IJsonApiOutputFormatter, IApiResponseTypeMetadataProvider
{
/// <inheritdoc />
public bool CanWriteResult(OutputFormatterCanWriteContext context)
Expand All @@ -24,5 +29,25 @@ public async Task WriteAsync(OutputFormatterWriteContext context)
var writer = context.HttpContext.RequestServices.GetRequiredService<IJsonApiWriter>();
await writer.WriteAsync(context);
}

/// <inheritdoc />
public IReadOnlyList<string> GetSupportedContentTypes(string contentType, Type objectType)
{
ArgumentGuard.NotNull(objectType, nameof(objectType));

var mediaTypes = new MediaTypeCollection();

if (contentType == HeaderConstants.MediaType)
{
Type typeToCheck = typeof(IEnumerable).IsAssignableFrom(objectType) ? objectType.GetGenericArguments()[0] : objectType;

if (typeToCheck.IsOrImplementsInterface(typeof(IIdentifiable)) || typeToCheck == typeof(object))
{
mediaTypes.Add(MediaTypeHeaderValue.Parse(contentType));
}
}

return mediaTypes;
}
}
}
21 changes: 8 additions & 13 deletions src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ public class JsonApiRoutingConvention : IJsonApiRoutingConvention
{
private readonly IJsonApiOptions _options;
private readonly IResourceContextProvider _resourceContextProvider;
private readonly HashSet<string> _registeredTemplates = new HashSet<string>();
private readonly Dictionary<string, ControllerModel> _registeredTemplates = new Dictionary<string, ControllerModel>();
private readonly Dictionary<Type, ResourceContext> _resourceContextPerControllerTypeMap = new Dictionary<Type, ResourceContext>();

public JsonApiRoutingConvention(IJsonApiOptions options, IResourceContextProvider resourceContextProvider)
Expand Down Expand Up @@ -89,11 +89,14 @@ public void Apply(ApplicationModel application)

string template = TemplateFromResource(controller) ?? TemplateFromController(controller);

if (template == null)
if (_registeredTemplates.ContainsKey(template))
{
throw new InvalidConfigurationException($"Controllers with overlapping route templates detected: {controller.ControllerType.FullName}");
throw new InvalidConfigurationException(
$"Cannot register '{controller.ControllerType.FullName}' for template '{template}' because '{_registeredTemplates[template].ControllerType.FullName}' was already registered for this template.");
}

_registeredTemplates.Add(template, controller);

controller.Selectors[0].AttributeRouteModel = new AttributeRouteModel
{
Template = template
Expand All @@ -116,10 +119,7 @@ private string TemplateFromResource(ControllerModel model)
{
string template = $"{_options.Namespace}/{resourceContext.PublicName}";

if (_registeredTemplates.Add(template))
{
return template;
}
return template;
}

return null;
Expand All @@ -133,12 +133,7 @@ private string TemplateFromController(ControllerModel model)
string controllerName = _options.SerializerNamingStrategy.GetPropertyName(model.ControllerName, false);
string template = $"{_options.Namespace}/{controllerName}";

if (_registeredTemplates.Add(template))
{
return template;
}

return null;
return template;
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using Microsoft.AspNetCore.Mvc.ApplicationModels;

namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ApiRequestFormatMedataProvider
{
internal sealed class ApiExplorerConvention : IControllerModelConvention
{
public void Apply(ControllerModel controller)
{
controller.ApiExplorer.IsVisible = true;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using JetBrains.Annotations;
using JsonApiDotNetCore.Configuration;
using JsonApiDotNetCoreExampleTests.Startups;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;

namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ApiRequestFormatMedataProvider
{
[UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)]
public sealed class ApiExplorerStartup<TDbContext> : TestableStartup<TDbContext>
where TDbContext : DbContext
{
public override void ConfigureServices(IServiceCollection services)
{
IMvcCoreBuilder builder = services.AddMvcCore().AddApiExplorer();
builder.AddMvcOptions(options => options.Conventions.Add(new ApiExplorerConvention()));

services.UseControllersFromNamespace(GetType().Namespace);

services.AddJsonApi<TDbContext>(SetJsonApiOptions, mvcBuilder: builder);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using FluentAssertions;
using FluentAssertions.Common;
using JsonApiDotNetCore.Middleware;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.Extensions.DependencyInjection;
using Xunit;

namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ApiRequestFormatMedataProvider
{
public sealed class ApiRequestFormatMedataProviderTests : IClassFixture<ExampleIntegrationTestContext<ApiExplorerStartup<ShopDbContext>, ShopDbContext>>
{
private readonly ExampleIntegrationTestContext<ApiExplorerStartup<ShopDbContext>, ShopDbContext> _testContext;

public ApiRequestFormatMedataProviderTests(ExampleIntegrationTestContext<ApiExplorerStartup<ShopDbContext>, ShopDbContext> testContext)
{
_testContext = testContext;
}

[Fact]
public void Can_retrieve_request_content_type_in_ApiExplorer_when_using_ConsumesAttribute()
{
// Arrange
var provider = _testContext.Factory.Services.GetRequiredService<IApiDescriptionGroupCollectionProvider>();

// Act
IReadOnlyList<ApiDescriptionGroup> groups = provider.ApiDescriptionGroups.Items;

// Assert
List<ApiDescription> descriptions = groups.Single().Items.ToList();
MethodInfo postStore = typeof(StoresController).GetMethod(nameof(StoresController.PostAsync));

ApiDescription postStoreDescription = descriptions.First(description => (description.ActionDescriptor as ControllerActionDescriptor)?.MethodInfo ==
postStore);

postStoreDescription.Should().NotBeNull();
postStoreDescription.SupportedRequestFormats.Should().HaveCount(1);
postStoreDescription.SupportedRequestFormats[0].MediaType.Should().Be(HeaderConstants.MediaType);
}

[Fact]
public void Can_retrieve_atomic_operations_request_content_type_in_ApiExplorer_when_using_ConsumesAttribute()
{
// Arrange
var provider = _testContext.Factory.Services.GetRequiredService<IApiDescriptionGroupCollectionProvider>();

// Act
IReadOnlyList<ApiDescriptionGroup> groups = provider.ApiDescriptionGroups.Items;

// Assert
List<ApiDescription> descriptions = groups.Single().Items.ToList();
MethodInfo postOperations = typeof(OperationsController).GetMethod(nameof(OperationsController.PostOperationsAsync));

ApiDescription postOperationsDescription =
descriptions.First(description => (description.ActionDescriptor as ControllerActionDescriptor)?.MethodInfo == postOperations);

postOperationsDescription.Should().NotBeNull();
postOperationsDescription.SupportedRequestFormats.Should().HaveCount(1);
postOperationsDescription.SupportedRequestFormats[0].MediaType.Should().Be(HeaderConstants.AtomicOperationsMediaType);
}

[Fact]
public void Cannot_retrieve_request_content_type_in_ApiExplorer_without_usage_of_ConsumesAttribute()
{
// Arrange
var provider = _testContext.Factory.Services.GetRequiredService<IApiDescriptionGroupCollectionProvider>();

// Act
IReadOnlyList<ApiDescriptionGroup> groups = provider.ApiDescriptionGroups.Items;

// Assert
IReadOnlyList<ApiDescription> descriptions = groups.Single().Items;

IEnumerable<ApiDescription> productActionDescriptions = descriptions.Where(description =>
(description.ActionDescriptor as ControllerActionDescriptor)?.ControllerTypeInfo == typeof(ProductsController));

foreach (ApiDescription description in productActionDescriptions)
{
description.SupportedRequestFormats.Should().NotContain(format => format.MediaType == HeaderConstants.MediaType);
}
}

[Fact]
public void Cannot_retrieve_atomic_operations_request_content_type_in_ApiExplorer_when_set_on_relationship_endpoint()
{
// Arrange
var provider = _testContext.Factory.Services.GetRequiredService<IApiDescriptionGroupCollectionProvider>();

// Act
IReadOnlyList<ApiDescriptionGroup> groups = provider.ApiDescriptionGroups.Items;

// Assert
List<ApiDescription> descriptions = groups.Single().Items.ToList();
MethodInfo postRelationship = typeof(StoresController).GetMethod(nameof(StoresController.PostRelationshipAsync));

ApiDescription postRelationshipDescription = descriptions.First(description =>
(description.ActionDescriptor as ControllerActionDescriptor)?.MethodInfo == postRelationship);

postRelationshipDescription.Should().NotBeNull();
postRelationshipDescription.SupportedRequestFormats.Should().HaveCount(0);
}

[Fact]
public void Can_retrieve_response_content_type_in_ApiExplorer_when_using_ProducesAttribute_with_ProducesResponseTypeAttribute()
{
// Arrange
var provider = _testContext.Factory.Services.GetRequiredService<IApiDescriptionGroupCollectionProvider>();

// Act
IReadOnlyList<ApiDescriptionGroup> groups = provider.ApiDescriptionGroups.Items;

// Assert
List<ApiDescription> descriptions = groups.Single().Items.ToList();

MethodInfo getStores = typeof(StoresController).GetMethods()
.First(method => method.Name == nameof(StoresController.GetAsync) && method.GetParameters().Length == 1);

ApiDescription getStoresDescription = descriptions.First(description => (description.ActionDescriptor as ControllerActionDescriptor)?.MethodInfo ==
getStores);

getStoresDescription.Should().NotBeNull();
getStoresDescription.SupportedResponseTypes.Should().HaveCount(1);

ApiResponseFormat jsonApiResponse = getStoresDescription.SupportedResponseTypes[0].ApiResponseFormats
.FirstOrDefault(format => format.Formatter.GetType().Implements(typeof(IJsonApiOutputFormatter)));

jsonApiResponse.Should().NotBeNull();
jsonApiResponse!.MediaType.Should().Be(HeaderConstants.MediaType);
}

[Fact]
public void Cannot_retrieve_response_content_type_in_ApiExplorer_when_using_ProducesResponseTypeAttribute_without_ProducesAttribute()
{
// Arrange
var provider = _testContext.Factory.Services.GetRequiredService<IApiDescriptionGroupCollectionProvider>();

// Act
IReadOnlyList<ApiDescriptionGroup> groups = provider.ApiDescriptionGroups.Items;

// Assert
List<ApiDescription> descriptions = groups.Single().Items.ToList();

MethodInfo getStores = typeof(StoresController).GetMethods()
.First(method => method.Name == nameof(StoresController.GetAsync) && method.GetParameters().Length == 2);

ApiDescription getStoresDescription = descriptions.First(description => (description.ActionDescriptor as ControllerActionDescriptor)?.MethodInfo ==
getStores);

getStoresDescription.Should().NotBeNull();
getStoresDescription.SupportedResponseTypes.Should().HaveCount(1);

ApiResponseFormat jsonApiResponse = getStoresDescription.SupportedResponseTypes[0].ApiResponseFormats
.FirstOrDefault(format => format.Formatter.GetType().Implements(typeof(IJsonApiOutputFormatter)));

jsonApiResponse.Should().BeNull();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using JsonApiDotNetCore.AtomicOperations;
using JsonApiDotNetCore.Configuration;
using JsonApiDotNetCore.Controllers;
using JsonApiDotNetCore.Middleware;
using JsonApiDotNetCore.Resources;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;

namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ApiRequestFormatMedataProvider
{
public sealed class OperationsController : JsonApiOperationsController
{
public OperationsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IOperationsProcessor processor, IJsonApiRequest request,
ITargetedFields targetedFields)
: base(options, loggerFactory, processor, request, targetedFields)
{
}

[HttpPost]
[Consumes("application/vnd.api+json; ext=\"https://jsonapi.org/ext/atomic\"")]
public override Task<IActionResult> PostOperationsAsync(IList<OperationContainer> operations, CancellationToken cancellationToken)
{
return base.PostOperationsAsync(operations, cancellationToken);
}
}
}
Loading