Skip to content

Implement API format metadata provider interfaces #972

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 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/JsonApiDotNetCore/Middleware/IJsonApiInputFormatter.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using JetBrains.Annotations;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.AspNetCore.Mvc.Formatters;

namespace JsonApiDotNetCore.Middleware
Expand All @@ -7,7 +8,7 @@ namespace JsonApiDotNetCore.Middleware
/// Application-wide entry point for reading JSON:API request bodies.
/// </summary>
[PublicAPI]
public interface IJsonApiInputFormatter : IInputFormatter
public interface IJsonApiInputFormatter : IInputFormatter, IApiRequestFormatMetadataProvider
{
}
}
3 changes: 2 additions & 1 deletion src/JsonApiDotNetCore/Middleware/IJsonApiOutputFormatter.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using JetBrains.Annotations;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.AspNetCore.Mvc.Formatters;

namespace JsonApiDotNetCore.Middleware
Expand All @@ -7,7 +8,7 @@ namespace JsonApiDotNetCore.Middleware
/// Application-wide entry point for writing JSON:API response bodies.
/// </summary>
[PublicAPI]
public interface IJsonApiOutputFormatter : IOutputFormatter
public interface IJsonApiOutputFormatter : IOutputFormatter, IApiResponseTypeMetadataProvider
{
}
}
33 changes: 33 additions & 0 deletions src/JsonApiDotNetCore/Middleware/JsonApiInputFormatter.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using JsonApiDotNetCore.Resources;
using JsonApiDotNetCore.Serialization;
using Microsoft.AspNetCore.Mvc.Formatters;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Net.Http.Headers;

namespace JsonApiDotNetCore.Middleware
{
Expand All @@ -24,5 +28,34 @@ 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 typeof(IEnumerable<OperationContainer>).IsAssignableFrom(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)
{
return typeof(IEnumerable<IIdentifiable>).IsAssignableFrom(type) || type.IsOrImplementsInterface(typeof(IIdentifiable)) || type == typeof(object);
}
}
}
24 changes: 24 additions & 0 deletions src/JsonApiDotNetCore/Middleware/JsonApiOutputFormatter.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using JsonApiDotNetCore.Resources;
using JsonApiDotNetCore.Serialization;
using Microsoft.AspNetCore.Mvc.Formatters;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Net.Http.Headers;

namespace JsonApiDotNetCore.Middleware
{
Expand All @@ -24,5 +28,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 && IsJsonApiResource(objectType))
{
mediaTypes.Add(MediaTypeHeaderValue.Parse(contentType));
}

return mediaTypes;
}

private bool IsJsonApiResource(Type type)
{
return typeof(IEnumerable<IIdentifiable>).IsAssignableFrom(type) || type.IsOrImplementsInterface(typeof(IIdentifiable)) || type == typeof(object);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using Microsoft.AspNetCore.Mvc.ApplicationModels;

namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ApiFormatMedataProvider
{
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,172 @@
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using FluentAssertions;
using FluentAssertions.Common;
using JsonApiDotNetCore.Middleware;
using JsonApiDotNetCoreExampleTests.Startups;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.Extensions.DependencyInjection;
using Xunit;

namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ApiFormatMedataProvider
{
public sealed class ApiFormatMedataProviderTests : IClassFixture<ExampleIntegrationTestContext<TestableStartup<ShopDbContext>, ShopDbContext>>
{
private readonly ExampleIntegrationTestContext<TestableStartup<ShopDbContext>, ShopDbContext> _testContext;

public ApiFormatMedataProviderTests(ExampleIntegrationTestContext<TestableStartup<ShopDbContext>, ShopDbContext> testContext)
{
_testContext = testContext;

testContext.UseController<ProductsController>();
testContext.UseController<StoresController>();
testContext.UseController<OperationsController>();

testContext.ConfigureServicesBeforeStartup(services =>
{
IMvcCoreBuilder builder = services.AddMvcCore().AddApiExplorer();
builder.AddMvcOptions(options => options.Conventions.Add(new ApiExplorerConvention()));
});
}

[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.FirstOrDefault(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.FirstOrDefault(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.FirstOrDefault(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.FirstOrDefault(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.FirstOrDefault(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.ApiFormatMedataProvider
{
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);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using JetBrains.Annotations;
using JsonApiDotNetCore.Resources;
using JsonApiDotNetCore.Resources.Annotations;

namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ApiFormatMedataProvider
{
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
public sealed class Product : Identifiable
{
[Attr]
public string Name { get; set; }

[Attr]
public decimal Price { get; set; }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using JsonApiDotNetCore.Configuration;
using JsonApiDotNetCore.Controllers;
using JsonApiDotNetCore.Services;
using Microsoft.Extensions.Logging;

namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ApiFormatMedataProvider
{
public sealed class ProductsController : JsonApiController<Product>
{
public ProductsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService<Product> resourceService)
: base(options, loggerFactory, resourceService)
{
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using JetBrains.Annotations;
using Microsoft.EntityFrameworkCore;

namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ApiFormatMedataProvider
{
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
public sealed class ShopDbContext : DbContext
{
public DbSet<Store> Stores { get; set; }

public DbSet<Product> Products { get; set; }

public ShopDbContext(DbContextOptions<ShopDbContext> options)
: base(options)
{
}
}
}
Loading