Skip to content

Commit

Permalink
Back port error object support. Related to #1019
Browse files Browse the repository at this point in the history
  • Loading branch information
commonsensesoftware committed Sep 12, 2023
1 parent c462195 commit 936aa57
Show file tree
Hide file tree
Showing 10 changed files with 277 additions and 9 deletions.
Original file line number Diff line number Diff line change
@@ -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<IProblemDetailsFactory, ErrorObjectFactory>();
base.OnConfigureServices( services );
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<InteropFixture>
{
[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 ) { }
}
Original file line number Diff line number Diff line change
@@ -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<IProblemDetailsFactory, ErrorObjectFactory>();
base.OnConfigureServices( services );
}
}
Original file line number Diff line number Diff line change
@@ -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<InteropFixture>
{
[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 ) { }
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<VersionPrefix>6.4.0</VersionPrefix>
<AssemblyVersion>6.4.0.0</AssemblyVersion>
<VersionPrefix>6.5.0</VersionPrefix>
<AssemblyVersion>6.5.0.0</AssemblyVersion>
<TargetFrameworks>net6.0;netcoreapp3.1</TargetFrameworks>
<RootNamespace>Asp.Versioning</RootNamespace>
<AssemblyTitle>ASP.NET Core API Versioning</AssemblyTitle>
Expand Down
114 changes: 114 additions & 0 deletions src/AspNetCore/WebApi/src/Asp.Versioning.Http/ErrorObjectFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// Copyright (c) .NET Foundation and contributors. All rights reserved.

namespace Asp.Versioning;

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;

/// <summary>
/// Represents a factory that creates problem details formatted for error objects in responses.
/// </summary>
/// <remarks>This enables backward compatibility by converting <see cref="ProblemDetails"/> into Error Objects that
/// conform to the <a ref="https://github.com/microsoft/api-guidelines/blob/vNext/Guidelines.md#7102-error-condition-responses">Error Responses</a>
/// in the Microsoft REST API Guidelines and
/// <a ref="https://docs.oasis-open.org/odata/odata-json-format/v4.01/odata-json-format-v4.01.html#_Toc38457793">OData Error Responses</a>.</remarks>
[CLSCompliant( false )]
public sealed class ErrorObjectFactory : IProblemDetailsFactory
{
private readonly IProblemDetailsFactory inner;

/// <summary>
/// Initializes a new instance of the <see cref="ErrorObjectFactory"/> class.
/// </summary>
public ErrorObjectFactory() : this( new DefaultProblemDetailsFactory() ) { }

private ErrorObjectFactory( IProblemDetailsFactory inner ) => this.inner = inner;

/// <summary>
/// Creates and returns a new factory that decorates another factory.
/// </summary>
/// <param name="decorated">The inner, decorated factory instance.</param>
/// <returns>A new <see cref="ErrorObjectFactory"/>.</returns>
public static ErrorObjectFactory Decorate( IProblemDetailsFactory decorated ) => new( decorated );

/// <inheritdoc />
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<string, object>( 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<string, object>( capacity: 1 )
{
["message"] = problemDetails.Detail,
};
}

return errorObject;
}

private static Task ChangeContentType( object state )
{
var response = (HttpResponse) state;
response.ContentType = "application/json";
return Task.CompletedTask;
}
}
Original file line number Diff line number Diff line change
@@ -1 +1 @@

Add backward compatibility for error objects
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Copyright (c) .NET Foundation and contributors. All rights reserved.

// Ignore Spelling: Mvc
namespace Microsoft.Extensions.DependencyInjection;

using Asp.Versioning;
Expand All @@ -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;
Expand Down Expand Up @@ -76,7 +78,18 @@ private static void AddServices( IServiceCollection services )
services.TryAddEnumerable( Transient<IApiControllerSpecification, ApiBehaviorSpecification>() );
services.TryAddEnumerable( Singleton<IApiVersionMetadataCollationProvider, ActionApiVersionMetadataCollationProvider>() );
services.Replace( WithUrlHelperFactoryDecorator( services ) );
services.TryReplace( typeof( DefaultProblemDetailsFactory ), Singleton<IProblemDetailsFactory, MvcProblemDetailsFactory>() );

if ( !services.TryReplace(
typeof( DefaultProblemDetailsFactory ),
Singleton<IProblemDetailsFactory, MvcProblemDetailsFactory>() ) )
{
services.TryReplace(
typeof( ErrorObjectFactory ),
Singleton<IProblemDetailsFactory, ErrorObjectFactory>(
sp => ErrorObjectFactory.Decorate(
new MvcProblemDetailsFactory(
sp.GetRequiredService<ProblemDetailsFactory>() ) ) ) );
}
}

private static object CreateInstance( this IServiceProvider services, ServiceDescriptor descriptor )
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Copyright (c) .NET Foundation and contributors. All rights reserved.

// Ignore Spelling: Mvc
#pragma warning disable CA1812

namespace Asp.Versioning;
Expand All @@ -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;
}
}

0 comments on commit 936aa57

Please sign in to comment.