diff --git a/src/JsonApiDotNetCore/Middleware/IJsonApiInputFormatter.cs b/src/JsonApiDotNetCore/Middleware/IJsonApiInputFormatter.cs index 268c0a2697..a3199271f0 100644 --- a/src/JsonApiDotNetCore/Middleware/IJsonApiInputFormatter.cs +++ b/src/JsonApiDotNetCore/Middleware/IJsonApiInputFormatter.cs @@ -1,4 +1,5 @@ using JetBrains.Annotations; +using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.AspNetCore.Mvc.Formatters; namespace JsonApiDotNetCore.Middleware @@ -7,7 +8,7 @@ namespace JsonApiDotNetCore.Middleware /// Application-wide entry point for reading JSON:API request bodies. /// [PublicAPI] - public interface IJsonApiInputFormatter : IInputFormatter + public interface IJsonApiInputFormatter : IInputFormatter, IApiRequestFormatMetadataProvider { } } diff --git a/src/JsonApiDotNetCore/Middleware/IJsonApiOutputFormatter.cs b/src/JsonApiDotNetCore/Middleware/IJsonApiOutputFormatter.cs index 725accb03f..eaec52918f 100644 --- a/src/JsonApiDotNetCore/Middleware/IJsonApiOutputFormatter.cs +++ b/src/JsonApiDotNetCore/Middleware/IJsonApiOutputFormatter.cs @@ -1,4 +1,5 @@ using JetBrains.Annotations; +using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.AspNetCore.Mvc.Formatters; namespace JsonApiDotNetCore.Middleware @@ -7,7 +8,7 @@ namespace JsonApiDotNetCore.Middleware /// Application-wide entry point for writing JSON:API response bodies. /// [PublicAPI] - public interface IJsonApiOutputFormatter : IOutputFormatter + public interface IJsonApiOutputFormatter : IOutputFormatter, IApiResponseTypeMetadataProvider { } } diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiInputFormatter.cs b/src/JsonApiDotNetCore/Middleware/JsonApiInputFormatter.cs index fc5a1e2230..154a8b2230 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiInputFormatter.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiInputFormatter.cs @@ -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 { @@ -24,5 +28,34 @@ public async Task ReadAsync(InputFormatterContext context) var reader = context.HttpContext.RequestServices.GetRequiredService(); return await reader.ReadAsync(context); } + + /// + public IReadOnlyList GetSupportedContentTypes(string contentType, Type objectType) + { + ArgumentGuard.NotNull(objectType, nameof(objectType)); + + var mediaTypes = new MediaTypeCollection(); + + switch (contentType) + { + case HeaderConstants.AtomicOperationsMediaType when typeof(IEnumerable).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).IsAssignableFrom(type) || type.IsOrImplementsInterface(typeof(IIdentifiable)) || type == typeof(object); + } } } diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiOutputFormatter.cs b/src/JsonApiDotNetCore/Middleware/JsonApiOutputFormatter.cs index bd66f66067..80a5db4e09 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiOutputFormatter.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiOutputFormatter.cs @@ -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 { @@ -24,5 +28,25 @@ public async Task WriteAsync(OutputFormatterWriteContext context) var writer = context.HttpContext.RequestServices.GetRequiredService(); await writer.WriteAsync(context); } + + /// + public IReadOnlyList 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).IsAssignableFrom(type) || type.IsOrImplementsInterface(typeof(IIdentifiable)) || type == typeof(object); + } } } diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ApiFormatMedataProvider/ApiExplorerConvention.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ApiFormatMedataProvider/ApiExplorerConvention.cs new file mode 100644 index 0000000000..a00c286cbd --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ApiFormatMedataProvider/ApiExplorerConvention.cs @@ -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; + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ApiFormatMedataProvider/ApiFormatMedataProviderTests.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ApiFormatMedataProvider/ApiFormatMedataProviderTests.cs new file mode 100644 index 0000000000..97cdf38213 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ApiFormatMedataProvider/ApiFormatMedataProviderTests.cs @@ -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, ShopDbContext>> + { + private readonly ExampleIntegrationTestContext, ShopDbContext> _testContext; + + public ApiFormatMedataProviderTests(ExampleIntegrationTestContext, ShopDbContext> testContext) + { + _testContext = testContext; + + testContext.UseController(); + testContext.UseController(); + testContext.UseController(); + + 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(); + + // Act + IReadOnlyList groups = provider.ApiDescriptionGroups.Items; + + // Assert + List 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(); + + // Act + IReadOnlyList groups = provider.ApiDescriptionGroups.Items; + + // Assert + List 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(); + + // Act + IReadOnlyList groups = provider.ApiDescriptionGroups.Items; + + // Assert + IReadOnlyList descriptions = groups.Single().Items; + + IEnumerable 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(); + + // Act + IReadOnlyList groups = provider.ApiDescriptionGroups.Items; + + // Assert + List 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(); + + // Act + IReadOnlyList groups = provider.ApiDescriptionGroups.Items; + + // Assert + List 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(); + + // Act + IReadOnlyList groups = provider.ApiDescriptionGroups.Items; + + // Assert + List 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(); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ApiFormatMedataProvider/OperationsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ApiFormatMedataProvider/OperationsController.cs new file mode 100644 index 0000000000..02972ef9c4 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ApiFormatMedataProvider/OperationsController.cs @@ -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 PostOperationsAsync(IList operations, CancellationToken cancellationToken) + { + return base.PostOperationsAsync(operations, cancellationToken); + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ApiFormatMedataProvider/Product.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ApiFormatMedataProvider/Product.cs new file mode 100644 index 0000000000..edd45df212 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ApiFormatMedataProvider/Product.cs @@ -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; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ApiFormatMedataProvider/ProductsController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ApiFormatMedataProvider/ProductsController.cs new file mode 100644 index 0000000000..0cbc6b8d6e --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ApiFormatMedataProvider/ProductsController.cs @@ -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 + { + public ProductsController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ApiFormatMedataProvider/ShopDbContext.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ApiFormatMedataProvider/ShopDbContext.cs new file mode 100644 index 0000000000..4ad0d95071 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ApiFormatMedataProvider/ShopDbContext.cs @@ -0,0 +1,18 @@ +using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ApiFormatMedataProvider +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class ShopDbContext : DbContext + { + public DbSet Stores { get; set; } + + public DbSet Products { get; set; } + + public ShopDbContext(DbContextOptions options) + : base(options) + { + } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ApiFormatMedataProvider/Store.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ApiFormatMedataProvider/Store.cs new file mode 100644 index 0000000000..5116b8d24e --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ApiFormatMedataProvider/Store.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using JetBrains.Annotations; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ApiFormatMedataProvider +{ + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + public sealed class Store : Identifiable + { + [Attr] + public string Name { get; set; } + + [Attr] + public string Address { get; set; } + + [HasMany] + public ICollection Products { get; set; } + } +} diff --git a/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ApiFormatMedataProvider/StoresController.cs b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ApiFormatMedataProvider/StoresController.cs new file mode 100644 index 0000000000..018bd726d2 --- /dev/null +++ b/test/JsonApiDotNetCoreExampleTests/IntegrationTests/ApiFormatMedataProvider/StoresController.cs @@ -0,0 +1,50 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Controllers; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace JsonApiDotNetCoreExampleTests.IntegrationTests.ApiFormatMedataProvider +{ + public sealed class StoresController : JsonApiController + { + public StoresController(IJsonApiOptions options, ILoggerFactory loggerFactory, IResourceService resourceService) + : base(options, loggerFactory, resourceService) + { + } + + [HttpGet] + [Produces("application/vnd.api+json")] + [ProducesResponseType(typeof(IEnumerable), 200)] + public override Task GetAsync(CancellationToken cancellationToken) + { + return base.GetAsync(cancellationToken); + } + + [HttpGet] + [ProducesResponseType(typeof(Store), 200)] + public override Task GetAsync(int id, CancellationToken cancellationToken) + { + return base.GetAsync(id, cancellationToken); + } + + [HttpPost] + [Consumes("application/vnd.api+json")] + public override async Task PostAsync([FromBody] Store resource, CancellationToken cancellationToken) + { + return await base.PostAsync(resource, cancellationToken); + } + + [HttpPost] + [Consumes("application/vnd.api+json; ext=\"https://jsonapi.org/ext/atomic\"")] + public override Task PostRelationshipAsync(int id, string relationshipName, ISet secondaryResourceIds, + CancellationToken cancellationToken) + { + return base.PostRelationshipAsync(id, relationshipName, secondaryResourceIds, cancellationToken); + } + } +}