Add 'default response' to API Response Type

Also some cleanup and unit tests.
This commit is contained in:
Ryan Nowak 2017-10-04 08:07:04 -07:00
parent ece5d6a690
commit 7ba167fcd8
12 changed files with 392 additions and 92 deletions

View File

@ -39,5 +39,15 @@ namespace Microsoft.AspNetCore.Mvc.ApiExplorer
/// Gets or sets the HTTP response status code.
/// </summary>
public int StatusCode { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the response type represents a default response.
/// </summary>
/// <remarks>
/// If an <see cref="ApiDescription"/> has a default response, then the <see cref="StatusCode"/> property should be ignored. This response
/// will be used when a more specific response format does not apply. The common use of a default response is to specify the format
/// for communicating error conditions.
/// </remarks>
public bool IsDefaultResponse { get; set; }
}
}

View File

@ -0,0 +1,116 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.Internal;
using Microsoft.AspNetCore.Mvc.ModelBinding;
namespace Microsoft.AspNetCore.Mvc.ApiExplorer
{
public class ApiBehaviorApiDescriptionProvider : IApiDescriptionProvider
{
private readonly IModelMetadataProvider _modelMetadaProvider;
public ApiBehaviorApiDescriptionProvider(IModelMetadataProvider modelMetadataProvider)
{
_modelMetadaProvider = modelMetadataProvider;
}
/// <remarks>
/// The order is set to execute after the default provider.
/// </remarks>
public int Order => -1000 + 10;
public void OnProvidersExecuted(ApiDescriptionProviderContext context)
{
}
public void OnProvidersExecuting(ApiDescriptionProviderContext context)
{
foreach (var description in context.Results)
{
if (!AppliesTo(description))
{
continue;
}
foreach (var responseType in CreateProblemResponseTypes(description))
{
description.SupportedResponseTypes.Add(responseType);
}
}
}
public bool AppliesTo(ApiDescription description)
{
return description.ActionDescriptor.FilterDescriptors.Any(f => f.Filter is IApiBehaviorMetadata);
}
// Check if the parameter is named "id" (e.g. int id) or ends in Id (e.g. personId)
public bool IsIdParameter(ParameterDescriptor parameter)
{
if (parameter.Name == null)
{
return false;
}
if (string.Equals("id", parameter.Name, StringComparison.Ordinal))
{
return true;
}
// We're looking for a name ending with Id, but preceded by a lower case letter. This should match
// the normal PascalCase naming conventions.
if (parameter.Name.Length >= 3 &&
parameter.Name.EndsWith("Id", StringComparison.Ordinal) &&
char.IsLower(parameter.Name, parameter.Name.Length - 3))
{
return true;
}
return false;
}
public IEnumerable<ApiResponseType> CreateProblemResponseTypes(ApiDescription description)
{
if (description.ActionDescriptor.Parameters.Any() || description.ActionDescriptor.BoundProperties.Any())
{
// For validation errors.
yield return CreateProblemResponse(StatusCodes.Status400BadRequest);
if (description.ActionDescriptor.Parameters.Any(p => IsIdParameter(p)))
{
yield return CreateProblemResponse(StatusCodes.Status404NotFound);
}
}
yield return CreateProblemResponse(statusCode: 0, isDefaultResponse: true);
}
private ApiResponseType CreateProblemResponse(int statusCode, bool isDefaultResponse = false)
{
return new ApiResponseType
{
ApiResponseFormats = new List<ApiResponseFormat>
{
new ApiResponseFormat
{
MediaType = "application/problem+json",
},
new ApiResponseFormat
{
MediaType = "application/problem+xml",
},
},
IsDefaultResponse = isDefaultResponse,
ModelMetadata = _modelMetadaProvider.GetMetadataForType(typeof(ProblemDetails)),
StatusCode = statusCode,
Type = typeof(ProblemDetails),
};
}
}
}

View File

@ -1,78 +0,0 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Internal;
using Microsoft.AspNetCore.Mvc.ModelBinding;
namespace Microsoft.AspNetCore.Mvc.ApiExplorer
{
public class ApiControllerApiDescriptionProvider : IApiDescriptionProvider
{
private readonly IModelMetadataProvider _modelMetadaProvider;
public ApiControllerApiDescriptionProvider(IModelMetadataProvider modelMetadataProvider)
{
_modelMetadaProvider = modelMetadataProvider;
}
/// <remarks>
/// The order is set to execute after the <see cref="DefaultApiDescriptionProvider"/>.
/// </remarks>
public int Order => -1000 + 10;
public void OnProvidersExecuted(ApiDescriptionProviderContext context)
{
}
public void OnProvidersExecuting(ApiDescriptionProviderContext context)
{
foreach (var apiDescription in context.Results)
{
if (!apiDescription.ActionDescriptor.FilterDescriptors.Any(f => f.Filter is IApiBehaviorMetadata))
{
continue;
}
var parameters = apiDescription.ActionDescriptor.Parameters.Concat(apiDescription.ActionDescriptor.BoundProperties);
if (parameters.Any())
{
apiDescription.SupportedResponseTypes.Add(CreateProblemResponse(StatusCodes.Status400BadRequest));
if (parameters.Any(p => p.Name.EndsWith("id", StringComparison.OrdinalIgnoreCase)))
{
apiDescription.SupportedResponseTypes.Add(CreateProblemResponse(StatusCodes.Status404NotFound));
}
}
// We don't have a good way to signal a "default" response type. We'll use 0 to indicate this until we come up
// with something better.
apiDescription.SupportedResponseTypes.Add(CreateProblemResponse(statusCode: 0));
}
}
private ApiResponseType CreateProblemResponse(int statusCode)
{
return new ApiResponseType
{
ApiResponseFormats = new List<ApiResponseFormat>
{
new ApiResponseFormat
{
MediaType = "application/problem+json",
},
new ApiResponseFormat
{
MediaType = "application/problem+xml",
},
},
ModelMetadata = _modelMetadaProvider.GetMetadataForType(typeof(ProblemDetails)),
StatusCode = statusCode,
Type = typeof(ProblemDetails),
};
}
}
}

View File

@ -27,7 +27,7 @@ namespace Microsoft.Extensions.DependencyInjection
services.TryAddEnumerable(
ServiceDescriptor.Transient<IApiDescriptionProvider, DefaultApiDescriptionProvider>());
services.TryAddEnumerable(
ServiceDescriptor.Transient<IApiDescriptionProvider, ApiControllerApiDescriptionProvider>());
ServiceDescriptor.Transient<IApiDescriptionProvider, ApiBehaviorApiDescriptionProvider>());
}
}
}

View File

@ -160,7 +160,7 @@ namespace Microsoft.Extensions.DependencyInjection
services.TryAddEnumerable(
ServiceDescriptor.Transient<IApplicationModelProvider, DefaultApplicationModelProvider>());
services.TryAddEnumerable(
ServiceDescriptor.Transient<IApplicationModelProvider, ApiControllerApplicationModelProvider>());
ServiceDescriptor.Transient<IApplicationModelProvider, ApiBehaviorApplicationModelProvider>());
services.TryAddEnumerable(
ServiceDescriptor.Transient<IActionDescriptorProvider, ControllerActionDescriptorProvider>());

View File

@ -12,12 +12,12 @@ using Microsoft.Extensions.Options;
namespace Microsoft.AspNetCore.Mvc.Internal
{
public class ApiControllerApplicationModelProvider : IApplicationModelProvider
public class ApiBehaviorApplicationModelProvider : IApplicationModelProvider
{
private readonly ApiBehaviorOptions _apiBehaviorOptions;
private readonly ModelStateInvalidFilter _modelStateInvalidFilter;
public ApiControllerApplicationModelProvider(IOptions<ApiBehaviorOptions> apiBehaviorOptions, ILoggerFactory loggerFactory)
public ApiBehaviorApplicationModelProvider(IOptions<ApiBehaviorOptions> apiBehaviorOptions, ILoggerFactory loggerFactory)
{
_apiBehaviorOptions = apiBehaviorOptions.Value;
if (_apiBehaviorOptions.EnableModelStateInvalidFilter && _apiBehaviorOptions.InvalidModelStateResponseFactory == null)

View File

@ -0,0 +1,241 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc.Internal;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Moq;
using Xunit;
namespace Microsoft.AspNetCore.Mvc.ApiExplorer
{
public class ApiBehaviorApiDescriptionProviderTest
{
[Fact]
public void AppliesTo_ActionWithoutApiBehavior_ReturnsFalse()
{
// Arrange
var action = new ActionDescriptor()
{
FilterDescriptors = new List<FilterDescriptor>(),
};
var description = new ApiDescription()
{
ActionDescriptor = action,
};
var provider = new ApiBehaviorApiDescriptionProvider(new EmptyModelMetadataProvider());
// Act
var result = provider.AppliesTo(description);
// Assert
Assert.False(result);
}
[Fact]
public void AppliesTo_ActionWithApiBehavior_ReturnsTrue()
{
// Arrange
var action = new ActionDescriptor()
{
FilterDescriptors = new List<FilterDescriptor>()
{
new FilterDescriptor(Mock.Of<IApiBehaviorMetadata>(), FilterScope.Global),
}
};
var description = new ApiDescription()
{
ActionDescriptor = action,
};
var provider = new ApiBehaviorApiDescriptionProvider(new EmptyModelMetadataProvider());
// Act
var result = provider.AppliesTo(description);
// Assert
Assert.True(result);
}
[Theory]
[InlineData("id")]
[InlineData("personId")]
[InlineData("üId")]
public void IsIdParameter_ParameterNameMatchesConvention_ReturnsTrue(string name)
{
var parameter = new ParameterDescriptor()
{
Name = name,
};
var provider = new ApiBehaviorApiDescriptionProvider(new EmptyModelMetadataProvider());
// Act
var result = provider.IsIdParameter(parameter);
// Assert
Assert.True(result);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("i")]
[InlineData("Id")]
[InlineData("iD")]
[InlineData("persoNId")]
[InlineData("personid")]
[InlineData("ü Id")]
[InlineData("ÜId")]
public void IsIdParameter_ParameterNameDoesNotMatchConvention_ReturnsFalse(string name)
{
var parameter = new ParameterDescriptor()
{
Name = name,
};
var provider = new ApiBehaviorApiDescriptionProvider(new EmptyModelMetadataProvider());
// Act
var result = provider.IsIdParameter(parameter);
// Assert
Assert.False(result);
}
[Fact]
public void CreateProblemResponseTypes_NoParameters_IncludesDefaultResponse()
{
// Arrange
var action = new ActionDescriptor()
{
FilterDescriptors = new List<FilterDescriptor>()
{
new FilterDescriptor(Mock.Of<IApiBehaviorMetadata>(), FilterScope.Global),
},
BoundProperties = new List<ParameterDescriptor>(),
Parameters = new List<ParameterDescriptor>(),
};
var description = new ApiDescription()
{
ActionDescriptor = action,
};
var provider = new ApiBehaviorApiDescriptionProvider(new EmptyModelMetadataProvider());
// Act
var results = provider.CreateProblemResponseTypes(description);
// Assert
Assert.Collection(
results.OrderBy(r => r.StatusCode),
r =>
{
Assert.Equal(typeof(ProblemDetails), r.Type);
Assert.Equal(0, r.StatusCode);
Assert.True(r.IsDefaultResponse);
});
}
[Fact]
public void CreateProblemResponseTypes_WithBoundProperty_Includes400Response()
{
// Arrange
var action = new ActionDescriptor()
{
FilterDescriptors = new List<FilterDescriptor>()
{
new FilterDescriptor(Mock.Of<IApiBehaviorMetadata>(), FilterScope.Global),
},
BoundProperties = new List<ParameterDescriptor>()
{
new ParameterDescriptor()
},
Parameters = new List<ParameterDescriptor>(),
};
var description = new ApiDescription()
{
ActionDescriptor = action,
};
var provider = new ApiBehaviorApiDescriptionProvider(new EmptyModelMetadataProvider());
// Act
var results = provider.CreateProblemResponseTypes(description);
// Assert
Assert.Collection(
results.OrderBy(r => r.StatusCode),
r =>
{
Assert.Equal(typeof(ProblemDetails), r.Type);
Assert.Equal(0, r.StatusCode);
Assert.True(r.IsDefaultResponse);
},
r =>
{
Assert.Equal(typeof(ProblemDetails), r.Type);
Assert.Equal(400, r.StatusCode);
Assert.False(r.IsDefaultResponse);
});
}
[Fact]
public void CreateProblemResponseTypes_WithIdParameter_Includes404Response()
{
// Arrange
var action = new ActionDescriptor()
{
FilterDescriptors = new List<FilterDescriptor>()
{
new FilterDescriptor(Mock.Of<IApiBehaviorMetadata>(), FilterScope.Global),
},
BoundProperties = new List<ParameterDescriptor>()
{
},
Parameters = new List<ParameterDescriptor>()
{
new ParameterDescriptor()
{
Name = "customerId",
}
},
};
var description = new ApiDescription()
{
ActionDescriptor = action,
};
var provider = new ApiBehaviorApiDescriptionProvider(new EmptyModelMetadataProvider());
// Act
var results = provider.CreateProblemResponseTypes(description);
// Assert
Assert.Collection(
results.OrderBy(r => r.StatusCode),
r =>
{
Assert.Equal(typeof(ProblemDetails), r.Type);
Assert.Equal(0, r.StatusCode);
Assert.True(r.IsDefaultResponse);
},
r =>
{
Assert.Equal(typeof(ProblemDetails), r.Type);
Assert.Equal(400, r.StatusCode);
Assert.False(r.IsDefaultResponse);
},
r =>
{
Assert.Equal(typeof(ProblemDetails), r.Type);
Assert.Equal(404, r.StatusCode);
Assert.False(r.IsDefaultResponse);
});
}
}
}

View File

@ -295,7 +295,7 @@ namespace Microsoft.AspNetCore.Mvc
new Type[]
{
typeof(DefaultApplicationModelProvider),
typeof(ApiControllerApplicationModelProvider),
typeof(ApiBehaviorApplicationModelProvider),
}
},
};

View File

@ -11,7 +11,7 @@ using Xunit;
namespace Microsoft.AspNetCore.Mvc.Internal
{
public class ApiControllerApplicationModelProviderTest
public class ApiBehaviorApplicationModelProviderTest
{
[Fact]
public void OnProvidersExecuting_AddsModelStateInvalidFilter_IfTypeIsAnnotatedWithAttribute()
@ -23,7 +23,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal
InvalidModelStateResponseFactory = _ => null,
});
var provider = new ApiControllerApplicationModelProvider(options, NullLoggerFactory.Instance);
var provider = new ApiBehaviorApplicationModelProvider(options, NullLoggerFactory.Instance);
// Act
provider.OnProvidersExecuting(context);
@ -43,7 +43,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal
EnableModelStateInvalidFilter = false,
});
var provider = new ApiControllerApplicationModelProvider(options, NullLoggerFactory.Instance);
var provider = new ApiBehaviorApplicationModelProvider(options, NullLoggerFactory.Instance);
// Act
provider.OnProvidersExecuting(context);
@ -63,7 +63,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal
InvalidModelStateResponseFactory = _ => null,
});
var provider = new ApiControllerApplicationModelProvider(options, NullLoggerFactory.Instance);
var provider = new ApiBehaviorApplicationModelProvider(options, NullLoggerFactory.Instance);
// Act
provider.OnProvidersExecuting(context);
@ -91,7 +91,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal
EnableModelStateInvalidFilter = false,
});
var provider = new ApiControllerApplicationModelProvider(options, NullLoggerFactory.Instance);
var provider = new ApiBehaviorApplicationModelProvider(options, NullLoggerFactory.Instance);
// Act
provider.OnProvidersExecuting(context);
@ -119,7 +119,7 @@ namespace Microsoft.AspNetCore.Mvc.Internal
InvalidModelStateResponseFactory = _ => null,
});
var provider = new ApiControllerApplicationModelProvider(options, NullLoggerFactory.Instance);
var provider = new ApiBehaviorApplicationModelProvider(options, NullLoggerFactory.Instance);
// Act & Assert
var ex = Assert.Throws<InvalidOperationException>(() => provider.OnProvidersExecuting(context));

View File

@ -1079,6 +1079,7 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
response =>
{
Assert.Equal(0, response.StatusCode);
Assert.True(response.IsDefaultResponse);
AssertProblemDetails(response);
});
}
@ -1097,12 +1098,14 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
response =>
{
Assert.Equal(0, response.StatusCode);
Assert.True(response.IsDefaultResponse);
AssertProblemDetails(response);
},
response => Assert.Equal(200, response.StatusCode),
response =>
{
Assert.Equal(400, response.StatusCode);
Assert.False(response.IsDefaultResponse);
AssertProblemDetails(response);
});
}
@ -1123,17 +1126,20 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
response =>
{
Assert.Equal(0, response.StatusCode);
Assert.True(response.IsDefaultResponse);
AssertProblemDetails(response);
},
response => Assert.Equal(200, response.StatusCode),
response =>
{
Assert.Equal(400, response.StatusCode);
Assert.False(response.IsDefaultResponse);
AssertProblemDetails(response);
},
response =>
{
Assert.Equal(404, response.StatusCode);
Assert.False(response.IsDefaultResponse);
AssertProblemDetails(response);
});
}
@ -1199,6 +1205,8 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
public string ResponseType { get; set; }
public int StatusCode { get; set; }
public bool IsDefaultResponse { get; set; }
}
private class ApiExplorerResponseFormat

View File

@ -417,7 +417,7 @@ namespace Microsoft.AspNetCore.Mvc
typeof(CorsApplicationModelProvider),
typeof(AuthorizationApplicationModelProvider),
typeof(TempDataApplicationModelProvider),
typeof(ApiControllerApplicationModelProvider),
typeof(ApiBehaviorApplicationModelProvider),
}
},
{
@ -425,7 +425,7 @@ namespace Microsoft.AspNetCore.Mvc
new Type[]
{
typeof(DefaultApiDescriptionProvider),
typeof(ApiControllerApiDescriptionProvider),
typeof(ApiBehaviorApiDescriptionProvider),
typeof(JsonPatchOperationsArrayProvider),
}
},

View File

@ -89,7 +89,8 @@ namespace ApiExplorerWebSite
var responseType = new ApiExplorerResponseType()
{
StatusCode = response.StatusCode,
ResponseType = response.Type?.FullName
ResponseType = response.Type?.FullName,
IsDefaultResponse = response.IsDefaultResponse,
};
foreach(var responseFormat in response.ApiResponseFormats)
@ -152,6 +153,8 @@ namespace ApiExplorerWebSite
public string ResponseType { get; set; }
public int StatusCode { get; set; }
public bool IsDefaultResponse { get; set; }
}
private class ApiExplorerResponseFormat