diff --git a/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Http/InteropFixture.cs b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Http/InteropFixture.cs new file mode 100644 index 00000000..dd9a8c22 --- /dev/null +++ b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Http/InteropFixture.cs @@ -0,0 +1,15 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +// Ignore Spelling: Interop +namespace Asp.Versioning.Http; + +using Microsoft.Extensions.DependencyInjection; + +public class InteropFixture : MinimalApiFixture +{ + protected override void OnConfigureServices( IServiceCollection services ) + { + services.AddSingleton(); + base.OnConfigureServices( services ); + } +} \ No newline at end of file diff --git a/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Http/MinimalApiFixture.cs b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Http/MinimalApiFixture.cs index 208c5130..9b910e01 100644 --- a/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Http/MinimalApiFixture.cs +++ b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Http/MinimalApiFixture.cs @@ -1,6 +1,6 @@ // Copyright (c) .NET Foundation and contributors. All rights reserved. -namespace Asp.Versioning; +namespace Asp.Versioning.Http; using Asp.Versioning.Conventions; using Microsoft.AspNetCore.Builder; diff --git a/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Http/given a versioned minimal API/when error objects are enabled.cs b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Http/given a versioned minimal API/when error objects are enabled.cs new file mode 100644 index 00000000..c06148e8 --- /dev/null +++ b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Http/given a versioned minimal API/when error objects are enabled.cs @@ -0,0 +1,52 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace given_a_versioned_minimal_API; + +using Asp.Versioning; +using Asp.Versioning.Http; + +public class when_error_objects_are_enabled : AcceptanceTest, IClassFixture +{ + [Fact] + public async Task then_the_response_should_not_be_problem_details() + { + // arrange + var example = new + { + error = new + { + code = default( string ), + message = default( string ), + target = default( string ), + innerError = new + { + message = default( string ), + }, + }, + }; + + // act + var response = await GetAsync( "api/values?api-version=3.0" ); + var error = await response.Content.ReadAsExampleAsync( example ); + + // assert + response.Content.Headers.ContentType.MediaType.Should().Be( "application/json" ); + error.Should().BeEquivalentTo( + new + { + error = new + { + code = "UnsupportedApiVersion", + message = "Unsupported API version", + innerError = new + { + message = "The HTTP resource that matches the request URI " + + "'http://localhost/api/values' does not support " + + "the API version '3.0'.", + }, + }, + } ); + } + + public when_error_objects_are_enabled( InteropFixture fixture ) : base( fixture ) { } +} \ No newline at end of file diff --git a/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Mvc/UsingAttributes/InteropFixture.cs b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Mvc/UsingAttributes/InteropFixture.cs new file mode 100644 index 00000000..6f0348a8 --- /dev/null +++ b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Mvc/UsingAttributes/InteropFixture.cs @@ -0,0 +1,15 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +// Ignore Spelling: Interop +namespace Asp.Versioning.Mvc.UsingAttributes; + +using Microsoft.Extensions.DependencyInjection; + +public class InteropFixture : BasicFixture +{ + protected override void OnConfigureServices( IServiceCollection services ) + { + services.AddSingleton(); + base.OnConfigureServices( services ); + } +} \ No newline at end of file diff --git a/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Mvc/UsingAttributes/given a versioned Controller/when error objects are enabled.cs b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Mvc/UsingAttributes/given a versioned Controller/when error objects are enabled.cs new file mode 100644 index 00000000..aad5b852 --- /dev/null +++ b/src/AspNetCore/Acceptance/Asp.Versioning.Mvc.Acceptance.Tests/Mvc/UsingAttributes/given a versioned Controller/when error objects are enabled.cs @@ -0,0 +1,52 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace given_a_versioned_Controller; + +using Asp.Versioning; +using Asp.Versioning.Mvc.UsingAttributes; + +public class when_error_objects_are_enabled : AcceptanceTest, IClassFixture +{ + [Fact] + public async Task then_the_response_should_not_be_problem_details() + { + // arrange + var example = new + { + error = new + { + code = default( string ), + message = default( string ), + target = default( string ), + innerError = new + { + message = default( string ), + }, + }, + }; + + // act + var response = await GetAsync( "api/values?api-version=3.0" ); + var error = await response.Content.ReadAsExampleAsync( example ); + + // assert + response.Content.Headers.ContentType.MediaType.Should().Be( "application/json" ); + error.Should().BeEquivalentTo( + new + { + error = new + { + code = "UnsupportedApiVersion", + message = "Unsupported API version", + innerError = new + { + message = "The HTTP resource that matches the request URI " + + "'http://localhost/api/values' does not support " + + "the API version '3.0'.", + }, + }, + } ); + } + + public when_error_objects_are_enabled( InteropFixture fixture ) : base( fixture ) { } +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Asp.Versioning.Http.csproj b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Asp.Versioning.Http.csproj index 982d8a77..5e50544b 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Asp.Versioning.Http.csproj +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/Asp.Versioning.Http.csproj @@ -1,8 +1,8 @@  - 6.4.0 - 6.4.0.0 + 6.5.0 + 6.5.0.0 net6.0;netcoreapp3.1 Asp.Versioning ASP.NET Core API Versioning diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ErrorObjectFactory.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ErrorObjectFactory.cs new file mode 100644 index 00000000..d6040290 --- /dev/null +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ErrorObjectFactory.cs @@ -0,0 +1,114 @@ +// Copyright (c) .NET Foundation and contributors. All rights reserved. + +namespace Asp.Versioning; + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +/// +/// Represents a factory that creates problem details formatted for error objects in responses. +/// +/// This enables backward compatibility by converting into Error Objects that +/// conform to the Error Responses +/// in the Microsoft REST API Guidelines and +/// OData Error Responses. +[CLSCompliant( false )] +public sealed class ErrorObjectFactory : IProblemDetailsFactory +{ + private readonly IProblemDetailsFactory inner; + + /// + /// Initializes a new instance of the class. + /// + public ErrorObjectFactory() : this( new DefaultProblemDetailsFactory() ) { } + + private ErrorObjectFactory( IProblemDetailsFactory inner ) => this.inner = inner; + + /// + /// Creates and returns a new factory that decorates another factory. + /// + /// The inner, decorated factory instance. + /// A new . + public static ErrorObjectFactory Decorate( IProblemDetailsFactory decorated ) => new( decorated ); + + /// + public ProblemDetails CreateProblemDetails( + HttpRequest request, + int? statusCode = null, + string? title = null, + string? type = null, + string? detail = null, + string? instance = null ) + { + var problemDetails = inner.CreateProblemDetails( + request ?? throw new ArgumentNullException( nameof( request ) ), + statusCode, + title, + type, + detail, + instance ); + + if ( IsSupported( problemDetails ) ) + { + var response = request.HttpContext.Response; + response.OnStarting( ChangeContentType, response ); + return ToErrorObject( problemDetails ); + } + + return problemDetails; + } + + private static bool IsSupported( ProblemDetails problemDetails ) + { + var type = problemDetails.Type; + + return type == ProblemDetailsDefaults.Unsupported.Type || + type == ProblemDetailsDefaults.Unspecified.Type || + type == ProblemDetailsDefaults.Invalid.Type || + type == ProblemDetailsDefaults.Ambiguous.Type; + } + + private static ProblemDetails ToErrorObject( ProblemDetails problemDetails ) + { + var error = new Dictionary( capacity: 4 ); + var errorObject = new ProblemDetails() + { + Extensions = + { + [nameof( error )] = error, + }, + }; + + if ( !string.IsNullOrEmpty( problemDetails.Title ) ) + { + error["message"] = problemDetails.Title; + } + + if ( problemDetails.Extensions.TryGetValue( "code", out var value ) && value is string code ) + { + error["code"] = code; + } + + if ( !string.IsNullOrEmpty( problemDetails.Instance ) ) + { + error["target"] = problemDetails.Instance; + } + + if ( !string.IsNullOrEmpty( problemDetails.Detail ) ) + { + error["innerError"] = new Dictionary( capacity: 1 ) + { + ["message"] = problemDetails.Detail, + }; + } + + return errorObject; + } + + private static Task ChangeContentType( object state ) + { + var response = (HttpResponse) state; + response.ContentType = "application/json"; + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ReleaseNotes.txt b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ReleaseNotes.txt index 5f282702..5cdc847a 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ReleaseNotes.txt +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Http/ReleaseNotes.txt @@ -1 +1 @@ - \ No newline at end of file +Add backward compatibility for error objects \ No newline at end of file diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/DependencyInjection/IApiVersioningBuilderExtensions.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/DependencyInjection/IApiVersioningBuilderExtensions.cs index afa02343..93348777 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/DependencyInjection/IApiVersioningBuilderExtensions.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/DependencyInjection/IApiVersioningBuilderExtensions.cs @@ -1,5 +1,6 @@ // Copyright (c) .NET Foundation and contributors. All rights reserved. +// Ignore Spelling: Mvc namespace Microsoft.Extensions.DependencyInjection; using Asp.Versioning; @@ -10,6 +11,7 @@ namespace Microsoft.Extensions.DependencyInjection; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.ApplicationModels; +using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; @@ -76,7 +78,18 @@ private static void AddServices( IServiceCollection services ) services.TryAddEnumerable( Transient() ); services.TryAddEnumerable( Singleton() ); services.Replace( WithUrlHelperFactoryDecorator( services ) ); - services.TryReplace( typeof( DefaultProblemDetailsFactory ), Singleton() ); + + if ( !services.TryReplace( + typeof( DefaultProblemDetailsFactory ), + Singleton() ) ) + { + services.TryReplace( + typeof( ErrorObjectFactory ), + Singleton( + sp => ErrorObjectFactory.Decorate( + new MvcProblemDetailsFactory( + sp.GetRequiredService() ) ) ) ); + } } private static object CreateInstance( this IServiceProvider services, ServiceDescriptor descriptor ) @@ -130,18 +143,24 @@ IUrlHelperFactory NewFactory( IServiceProvider serviceProvider ) return new DecoratedServiceDescriptor( typeof( IUrlHelperFactory ), NewFactory, lifetime ); } - private static void TryReplace( this IServiceCollection services, Type implementationType, ServiceDescriptor descriptor ) + private static bool TryReplace( this IServiceCollection services, Type implementationType, ServiceDescriptor descriptor ) { for ( var i = 0; i < services.Count; i++ ) { var service = services[i]; + var match = service.ServiceType == descriptor.ServiceType && + ( service.ImplementationType == implementationType || + service.ImplementationInstance?.GetType() == implementationType || + service.ImplementationFactory?.Method.ReturnType == implementationType ); - if ( service.ServiceType == descriptor.ServiceType && descriptor.ImplementationType == implementationType ) + if ( match ) { services[i] = descriptor; - return; + return true; } } + + return false; } private sealed class DecoratedServiceDescriptor : ServiceDescriptor diff --git a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/MvcProblemDetailsFactory.cs b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/MvcProblemDetailsFactory.cs index 8e1bcc4e..44efb81b 100644 --- a/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/MvcProblemDetailsFactory.cs +++ b/src/AspNetCore/WebApi/src/Asp.Versioning.Mvc/MvcProblemDetailsFactory.cs @@ -1,5 +1,6 @@ // Copyright (c) .NET Foundation and contributors. All rights reserved. +// Ignore Spelling: Mvc #pragma warning disable CA1812 namespace Asp.Versioning; @@ -24,7 +25,7 @@ public ProblemDetails CreateProblemDetails( { var httpContext = request.HttpContext; var problemDetails = factory.CreateProblemDetails( httpContext, statusCode, title, type, detail, instance ); - DefaultProblemDetailsFactory.ApplyExtensions(problemDetails ); + DefaultProblemDetailsFactory.ApplyExtensions( problemDetails ); return problemDetails; } } \ No newline at end of file