Add JsonExtensionData to ProblemDetails

Fixes https://github.com/aspnet/AspNetCore/issues/6202
This commit is contained in:
Pranav K 2019-06-21 18:15:59 -07:00
parent b31bdd4373
commit bed3542a9b
No known key found for this signature in database
GPG Key ID: 1963DA6D96C3057A
5 changed files with 174 additions and 54 deletions

View File

@ -59,7 +59,12 @@ namespace Microsoft.AspNetCore.Mvc.Formatters
var writeStream = GetWriteStream(httpContext, selectedEncoding);
try
{
await JsonSerializer.WriteAsync(writeStream, context.Object, context.ObjectType, SerializerOptions);
// context.ObjectType reflects the declared model type when specified.
// For polymorphic scenarios where the user declares a return type, but returns a derived type,
// we want to serialize all the properties on the derived type. This keeps parity with
// the behavior you get when the user does not declare the return type and with Json.Net at least at the top level.
var objectType = context.Object?.GetType() ?? context.ObjectType;
await JsonSerializer.WriteAsync(writeStream, context.Object, objectType, SerializerOptions);
await writeStream.FlushAsync();
}
finally

View File

@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Microsoft.AspNetCore.Mvc
{
@ -52,6 +53,7 @@ namespace Microsoft.AspNetCore.Mvc
/// The round-tripping behavior for <see cref="Extensions"/> is determined by the implementation of the Input \ Output formatters.
/// In particular, complex types or collection types may not round-trip to the original type when using the built-in JSON or XML formatters.
/// </remarks>
[JsonExtensionData]
public IDictionary<string, object> Extensions { get; } = new Dictionary<string, object>(StringComparer.Ordinal);
}
}

View File

@ -2,7 +2,6 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Net;
using System.Net.Http;
@ -17,24 +16,21 @@ using Xunit;
namespace Microsoft.AspNetCore.Mvc.FunctionalTests
{
public class ApiBehaviorTest : IClassFixture<MvcTestFixture<BasicWebSite.StartupWithoutEndpointRouting>>
public abstract class ApiBehaviorTestBase<TStartup> : IClassFixture<MvcTestFixture<TStartup>> where TStartup : class
{
public ApiBehaviorTest(MvcTestFixture<BasicWebSite.StartupWithoutEndpointRouting> fixture)
protected ApiBehaviorTestBase(MvcTestFixture<TStartup> fixture)
{
Client = fixture.CreateDefaultClient();
var factory = fixture.WithWebHostBuilder(ConfigureWebHostBuilder);
CustomInvalidModelStateClient = factory.CreateDefaultClient();
var factory = fixture.Factories.FirstOrDefault() ?? fixture.WithWebHostBuilder(ConfigureWebHostBuilder);
Client = factory.CreateDefaultClient();
}
private static void ConfigureWebHostBuilder(IWebHostBuilder builder) =>
builder.UseStartup<BasicWebSite.StartupWithCustomInvalidModelStateFactory>();
builder.UseStartup<TStartup>();
public HttpClient Client { get; }
public HttpClient CustomInvalidModelStateClient { get; }
[Fact]
public async Task ActionsReturnBadRequest_WhenModelStateIsInvalid()
public virtual async Task ActionsReturnBadRequest_WhenModelStateIsInvalid()
{
// Arrange
using (new ActivityReplacer())
@ -122,34 +118,6 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
Assert.Equal("Unsupported Media Type", problemDetails.Title);
}
[Fact]
public async Task ActionsReturnBadRequest_UsesProblemDescriptionProviderAndApiConventionsToConfigureErrorResponse()
{
// Arrange
var contactModel = new Contact
{
Name = "Abc",
City = "Redmond",
State = "WA",
Zip = "Invalid",
};
var expected = new Dictionary<string, string[]>
{
{"Name", new[] {"The field Name must be a string with a minimum length of 5 and a maximum length of 30."}},
{"Zip", new[] { @"The field Zip must match the regular expression '\d{5}'."}}
};
// Act
var response = await CustomInvalidModelStateClient.PostAsJsonAsync("/contact/PostWithVnd", contactModel);
// Assert
await response.AssertStatusCodeAsync(HttpStatusCode.BadRequest);
Assert.Equal("application/vnd.error+json", response.Content.Headers.ContentType.MediaType);
var content = await response.Content.ReadAsStringAsync();
var actual = JsonConvert.DeserializeObject<Dictionary<string, string[]>>(content);
Assert.Equal(expected, actual);
}
[Fact]
public Task ActionsWithApiBehavior_InferFromBodyParameters()
=> ActionsWithApiBehaviorInferFromBodyParameters("ActionWithInferredFromBodyParameter");
@ -171,7 +139,7 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
var response = await Client.PostAsJsonAsync($"/contact/{action}", input);
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
await response.AssertStatusCodeAsync(HttpStatusCode.OK);
var result = JsonConvert.DeserializeObject<Contact>(await response.Content.ReadAsStringAsync());
Assert.Equal(input.ContactId, result.ContactId);
Assert.Equal(input.Name, result.Name);
@ -188,7 +156,7 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
var response = await Client.PostAsync(url, new StringContent(string.Empty));
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
await response.AssertStatusCodeAsync(HttpStatusCode.OK);
var result = JsonConvert.DeserializeObject<Contact>(await response.Content.ReadAsStringAsync());
Assert.Equal(id, result.ContactId);
Assert.Equal(name, result.Name);
@ -208,7 +176,7 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
var response = await Client.GetAsync(url);
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
await response.AssertStatusCodeAsync(HttpStatusCode.OK);
var result = await response.Content.ReadAsAsync<Contact>();
Assert.Equal(id, result.ContactId);
@ -229,7 +197,7 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
var response = await Client.GetAsync(url);
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
await response.AssertStatusCodeAsync(HttpStatusCode.OK);
var result = await response.Content.ReadAsAsync<Contact>();
Assert.Equal(id, result.ContactId);
@ -247,7 +215,7 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
var response = await Client.GetAsync("/contact/ActionWithInferredModelBinderType?foo=Hello!");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
await response.AssertStatusCodeAsync(HttpStatusCode.OK);
var result = await response.Content.ReadAsStringAsync();
Assert.Equal(expected, result);
}
@ -262,13 +230,13 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
var response = await Client.GetAsync("/contact/ActionWithInferredModelBinderTypeWithExplicitModelName?bar=Hello!");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
await response.AssertStatusCodeAsync(HttpStatusCode.OK);
var result = await response.Content.ReadAsStringAsync();
Assert.Equal(expected, result);
}
[Fact]
public async Task ClientErrorResultFilterExecutesForStatusCodeResults()
public virtual async Task ClientErrorResultFilterExecutesForStatusCodeResults()
{
using (new ActivityReplacer())
{
@ -296,7 +264,7 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
}
[Fact]
public async Task SerializingProblemDetails_IgnoresNullValuedProperties()
public virtual async Task SerializingProblemDetails_IgnoresNullValuedProperties()
{
// Arrange
var expected = new[] { "status", "title", "traceId", "type" };
@ -314,7 +282,7 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
}
[Fact]
public async Task SerializingProblemDetails_WithAllValuesSpecified()
public virtual async Task SerializingProblemDetails_WithAllValuesSpecified()
{
// Arrange
var expected = new[] { "detail", "instance", "status", "title", "tracking-id", "type" };
@ -330,7 +298,7 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
}
[Fact]
public async Task SerializingValidationProblemDetails_WithExtensionData()
public virtual async Task SerializingValidationProblemDetails_WithExtensionData()
{
// Act
var response = await Client.GetAsync("/contact/ActionReturningValidationProblemDetails");
@ -364,4 +332,132 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
});
}
}
public class ApiBehaviorTest : ApiBehaviorTestBase<BasicWebSite.StartupWithSystemTextJson>
{
public ApiBehaviorTest(MvcTestFixture<BasicWebSite.StartupWithSystemTextJson> fixture)
: base(fixture)
{
}
[Fact]
public override async Task ActionsReturnBadRequest_WhenModelStateIsInvalid()
{
// Arrange
using var _ = new ActivityReplacer();
var contactModel = new Contact
{
Name = "Abc",
City = "Redmond",
State = "WA",
Zip = "Invalid",
};
var contactString = JsonConvert.SerializeObject(contactModel);
// Act
var response = await Client.PostAsJsonAsync("/contact", contactModel);
// Assert
await response.AssertStatusCodeAsync(HttpStatusCode.BadRequest);
Assert.Equal("application/problem+json", response.Content.Headers.ContentType.MediaType);
var problemDetails = JsonConvert.DeserializeObject<ValidationProblemDetails>(
await response.Content.ReadAsStringAsync(),
new JsonSerializerSettings
{
Converters = { new ValidationProblemDetailsConverter() }
});
Assert.Collection(
problemDetails.Errors.OrderBy(kvp => kvp.Key),
kvp =>
{
Assert.Equal("Name", kvp.Key);
var error = Assert.Single(kvp.Value);
Assert.Equal("The field Name must be a string with a minimum length of 5 and a maximum length of 30.", error);
},
kvp =>
{
Assert.Equal("Zip", kvp.Key);
var error = Assert.Single(kvp.Value);
Assert.Equal("The field Zip must match the regular expression '\\d{5}'.", error);
}
);
Assert.Collection(
problemDetails.Extensions,
kvp =>
{
Assert.Equal("extensions", kvp.Key);
var jObject = Assert.IsType<JObject>(kvp.Value);
Assert.Equal("traceId", Assert.Single(jObject.Properties()).Name);
});
}
[Fact(Skip = "https://github.com/dotnet/corefx/issues/38769")]
public override Task ClientErrorResultFilterExecutesForStatusCodeResults()
{
return base.ClientErrorResultFilterExecutesForStatusCodeResults();
}
[Fact(Skip = "https://github.com/dotnet/corefx/issues/38769")]
public override Task SerializingProblemDetails_IgnoresNullValuedProperties()
{
return base.SerializingProblemDetails_IgnoresNullValuedProperties();
}
[Fact(Skip = "https://github.com/dotnet/corefx/issues/38769")]
public override Task SerializingProblemDetails_WithAllValuesSpecified()
{
return base.SerializingProblemDetails_WithAllValuesSpecified();
}
[Fact(Skip = "https://github.com/dotnet/corefx/issues/38769")]
public override Task SerializingValidationProblemDetails_WithExtensionData()
{
return base.SerializingValidationProblemDetails_WithExtensionData();
}
}
public class ApiBehaviorTestNewtonsoftJson : ApiBehaviorTestBase<BasicWebSite.StartupWithoutEndpointRouting>
{
public ApiBehaviorTestNewtonsoftJson(MvcTestFixture<BasicWebSite.StartupWithoutEndpointRouting> fixture)
: base(fixture)
{
var factory = fixture.WithWebHostBuilder(ConfigureWebHostBuilder);
CustomInvalidModelStateClient = factory.CreateDefaultClient();
}
private static void ConfigureWebHostBuilder(IWebHostBuilder builder) =>
builder.UseStartup<BasicWebSite.StartupWithCustomInvalidModelStateFactory>();
public HttpClient CustomInvalidModelStateClient { get; }
[Fact]
public async Task ActionsReturnBadRequest_UsesProblemDescriptionProviderAndApiConventionsToConfigureErrorResponse()
{
// Arrange
var contactModel = new Contact
{
Name = "Abc",
City = "Redmond",
State = "WA",
Zip = "Invalid",
};
var expected = new Dictionary<string, string[]>
{
{"Name", new[] {"The field Name must be a string with a minimum length of 5 and a maximum length of 30."}},
{"Zip", new[] { @"The field Zip must match the regular expression '\d{5}'."}}
};
// Act
var response = await CustomInvalidModelStateClient.PostAsJsonAsync("/contact/PostWithVnd", contactModel);
// Assert
await response.AssertStatusCodeAsync(HttpStatusCode.BadRequest);
Assert.Equal("application/vnd.error+json", response.Content.Headers.ContentType.MediaType);
var content = await response.Content.ReadAsStringAsync();
var actual = JsonConvert.DeserializeObject<Dictionary<string, string[]>>(content);
Assert.Equal(expected, actual);
}
}
}

View File

@ -4,6 +4,7 @@
using System.Net;
using System.Threading.Tasks;
using FormatterWebSite.Controllers;
using Newtonsoft.Json.Linq;
using Xunit;
namespace Microsoft.AspNetCore.Mvc.FunctionalTests
@ -15,7 +16,7 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
{
}
[Fact(Skip = "Dictionary serialization does not correctly work.")]
[Fact(Skip = "https://github.com/aspnet/AspNetCore/issues/11459")]
public override Task SerializableErrorIsReturnedInExpectedFormat() => base.SerializableErrorIsReturnedInExpectedFormat();
[Fact]
@ -29,13 +30,27 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
Assert.Equal("\"Hello Mr. \\ud83e\\udd8a\"", await response.Content.ReadAsStringAsync());
}
[Fact(Skip = "Dictionary serialization does not correctly work.")]
[Fact]
public override Task Formatting_DictionaryType() => base.Formatting_DictionaryType();
[Fact(Skip = "Dictionary serialization does not correctly work.")]
public override Task Formatting_ProblemDetails() => base.Formatting_ProblemDetails();
[Fact]
public override async Task Formatting_ProblemDetails()
{
using var _ = new ActivityReplacer();
[Fact(Skip = "https://github.com/dotnet/corefx/issues/36166")]
// Act
var response = await Client.GetAsync($"/JsonOutputFormatter/{nameof(JsonOutputFormatterController.ProblemDetailsResult)}");
// Assert
await response.AssertStatusCodeAsync(HttpStatusCode.NotFound);
var obj = JObject.Parse(await response.Content.ReadAsStringAsync());
Assert.Equal("https://tools.ietf.org/html/rfc7231#section-6.5.4", obj.Value<string>("type"));
Assert.Equal("Not Found", obj.Value<string>("title"));
Assert.Equal("404", obj.Value<string>("status"));
}
[Fact]
public override Task Formatting_PolymorphicModel() => base.Formatting_PolymorphicModel();
}
}

View File

@ -14,6 +14,8 @@ namespace BasicWebSite
services
.AddMvc()
.SetCompatibilityVersion(CompatibilityVersion.Latest);
services.AddSingleton<ContactsRepository>();
}
public void Configure(IApplicationBuilder app)