Skip to content

Commit bed3542

Browse files
committed
Add JsonExtensionData to ProblemDetails
Fixes #6202
1 parent b31bdd4 commit bed3542

File tree

5 files changed

+174
-54
lines changed

5 files changed

+174
-54
lines changed

src/Mvc/Mvc.Core/src/Formatters/SystemTextJsonOutputFormatter.cs

+6-1
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,12 @@ public sealed override async Task WriteResponseBodyAsync(OutputFormatterWriteCon
5959
var writeStream = GetWriteStream(httpContext, selectedEncoding);
6060
try
6161
{
62-
await JsonSerializer.WriteAsync(writeStream, context.Object, context.ObjectType, SerializerOptions);
62+
// context.ObjectType reflects the declared model type when specified.
63+
// For polymorphic scenarios where the user declares a return type, but returns a derived type,
64+
// we want to serialize all the properties on the derived type. This keeps parity with
65+
// the behavior you get when the user does not declare the return type and with Json.Net at least at the top level.
66+
var objectType = context.Object?.GetType() ?? context.ObjectType;
67+
await JsonSerializer.WriteAsync(writeStream, context.Object, objectType, SerializerOptions);
6368
await writeStream.FlushAsync();
6469
}
6570
finally

src/Mvc/Mvc.Core/src/ProblemDetails.cs

+2
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
using System;
55
using System.Collections.Generic;
6+
using System.Text.Json.Serialization;
67

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

src/Mvc/test/Mvc.FunctionalTests/ApiBehaviorTest.cs

+144-48
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
using System.Collections.Generic;
5-
using System.Diagnostics;
65
using System.Linq;
76
using System.Net;
87
using System.Net.Http;
@@ -17,24 +16,21 @@
1716

1817
namespace Microsoft.AspNetCore.Mvc.FunctionalTests
1918
{
20-
public class ApiBehaviorTest : IClassFixture<MvcTestFixture<BasicWebSite.StartupWithoutEndpointRouting>>
19+
public abstract class ApiBehaviorTestBase<TStartup> : IClassFixture<MvcTestFixture<TStartup>> where TStartup : class
2120
{
22-
public ApiBehaviorTest(MvcTestFixture<BasicWebSite.StartupWithoutEndpointRouting> fixture)
21+
protected ApiBehaviorTestBase(MvcTestFixture<TStartup> fixture)
2322
{
24-
Client = fixture.CreateDefaultClient();
25-
26-
var factory = fixture.WithWebHostBuilder(ConfigureWebHostBuilder);
27-
CustomInvalidModelStateClient = factory.CreateDefaultClient();
23+
var factory = fixture.Factories.FirstOrDefault() ?? fixture.WithWebHostBuilder(ConfigureWebHostBuilder);
24+
Client = factory.CreateDefaultClient();
2825
}
2926

3027
private static void ConfigureWebHostBuilder(IWebHostBuilder builder) =>
31-
builder.UseStartup<BasicWebSite.StartupWithCustomInvalidModelStateFactory>();
28+
builder.UseStartup<TStartup>();
3229

3330
public HttpClient Client { get; }
34-
public HttpClient CustomInvalidModelStateClient { get; }
3531

3632
[Fact]
37-
public async Task ActionsReturnBadRequest_WhenModelStateIsInvalid()
33+
public virtual async Task ActionsReturnBadRequest_WhenModelStateIsInvalid()
3834
{
3935
// Arrange
4036
using (new ActivityReplacer())
@@ -122,34 +118,6 @@ public async Task ActionsReturnUnsupportedMediaType_WhenEncodingIsUnsupported()
122118
Assert.Equal("Unsupported Media Type", problemDetails.Title);
123119
}
124120

125-
[Fact]
126-
public async Task ActionsReturnBadRequest_UsesProblemDescriptionProviderAndApiConventionsToConfigureErrorResponse()
127-
{
128-
// Arrange
129-
var contactModel = new Contact
130-
{
131-
Name = "Abc",
132-
City = "Redmond",
133-
State = "WA",
134-
Zip = "Invalid",
135-
};
136-
var expected = new Dictionary<string, string[]>
137-
{
138-
{"Name", new[] {"The field Name must be a string with a minimum length of 5 and a maximum length of 30."}},
139-
{"Zip", new[] { @"The field Zip must match the regular expression '\d{5}'."}}
140-
};
141-
142-
// Act
143-
var response = await CustomInvalidModelStateClient.PostAsJsonAsync("/contact/PostWithVnd", contactModel);
144-
145-
// Assert
146-
await response.AssertStatusCodeAsync(HttpStatusCode.BadRequest);
147-
Assert.Equal("application/vnd.error+json", response.Content.Headers.ContentType.MediaType);
148-
var content = await response.Content.ReadAsStringAsync();
149-
var actual = JsonConvert.DeserializeObject<Dictionary<string, string[]>>(content);
150-
Assert.Equal(expected, actual);
151-
}
152-
153121
[Fact]
154122
public Task ActionsWithApiBehavior_InferFromBodyParameters()
155123
=> ActionsWithApiBehaviorInferFromBodyParameters("ActionWithInferredFromBodyParameter");
@@ -171,7 +139,7 @@ private async Task ActionsWithApiBehaviorInferFromBodyParameters(string action)
171139
var response = await Client.PostAsJsonAsync($"/contact/{action}", input);
172140

173141
// Assert
174-
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
142+
await response.AssertStatusCodeAsync(HttpStatusCode.OK);
175143
var result = JsonConvert.DeserializeObject<Contact>(await response.Content.ReadAsStringAsync());
176144
Assert.Equal(input.ContactId, result.ContactId);
177145
Assert.Equal(input.Name, result.Name);
@@ -188,7 +156,7 @@ public async Task ActionsWithApiBehavior_InferQueryAndRouteParameters()
188156
var response = await Client.PostAsync(url, new StringContent(string.Empty));
189157

190158
// Assert
191-
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
159+
await response.AssertStatusCodeAsync(HttpStatusCode.OK);
192160
var result = JsonConvert.DeserializeObject<Contact>(await response.Content.ReadAsStringAsync());
193161
Assert.Equal(id, result.ContactId);
194162
Assert.Equal(name, result.Name);
@@ -208,7 +176,7 @@ public async Task ActionsWithApiBehavior_InferEmptyPrefixForComplexValueProvider
208176
var response = await Client.GetAsync(url);
209177

210178
// Assert
211-
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
179+
await response.AssertStatusCodeAsync(HttpStatusCode.OK);
212180

213181
var result = await response.Content.ReadAsAsync<Contact>();
214182
Assert.Equal(id, result.ContactId);
@@ -229,7 +197,7 @@ public async Task ActionsWithApiBehavior_InferEmptyPrefixForComplexValueProvider
229197
var response = await Client.GetAsync(url);
230198

231199
// Assert
232-
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
200+
await response.AssertStatusCodeAsync(HttpStatusCode.OK);
233201

234202
var result = await response.Content.ReadAsAsync<Contact>();
235203
Assert.Equal(id, result.ContactId);
@@ -247,7 +215,7 @@ public async Task ActionsWithApiBehavior_InferModelBinderType()
247215
var response = await Client.GetAsync("/contact/ActionWithInferredModelBinderType?foo=Hello!");
248216

249217
// Assert
250-
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
218+
await response.AssertStatusCodeAsync(HttpStatusCode.OK);
251219
var result = await response.Content.ReadAsStringAsync();
252220
Assert.Equal(expected, result);
253221
}
@@ -262,13 +230,13 @@ public async Task ActionsWithApiBehavior_InferModelBinderTypeWithExplicitModelNa
262230
var response = await Client.GetAsync("/contact/ActionWithInferredModelBinderTypeWithExplicitModelName?bar=Hello!");
263231

264232
// Assert
265-
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
233+
await response.AssertStatusCodeAsync(HttpStatusCode.OK);
266234
var result = await response.Content.ReadAsStringAsync();
267235
Assert.Equal(expected, result);
268236
}
269237

270238
[Fact]
271-
public async Task ClientErrorResultFilterExecutesForStatusCodeResults()
239+
public virtual async Task ClientErrorResultFilterExecutesForStatusCodeResults()
272240
{
273241
using (new ActivityReplacer())
274242
{
@@ -296,7 +264,7 @@ public async Task ClientErrorResultFilterExecutesForStatusCodeResults()
296264
}
297265

298266
[Fact]
299-
public async Task SerializingProblemDetails_IgnoresNullValuedProperties()
267+
public virtual async Task SerializingProblemDetails_IgnoresNullValuedProperties()
300268
{
301269
// Arrange
302270
var expected = new[] { "status", "title", "traceId", "type" };
@@ -314,7 +282,7 @@ public async Task SerializingProblemDetails_IgnoresNullValuedProperties()
314282
}
315283

316284
[Fact]
317-
public async Task SerializingProblemDetails_WithAllValuesSpecified()
285+
public virtual async Task SerializingProblemDetails_WithAllValuesSpecified()
318286
{
319287
// Arrange
320288
var expected = new[] { "detail", "instance", "status", "title", "tracking-id", "type" };
@@ -330,7 +298,7 @@ public async Task SerializingProblemDetails_WithAllValuesSpecified()
330298
}
331299

332300
[Fact]
333-
public async Task SerializingValidationProblemDetails_WithExtensionData()
301+
public virtual async Task SerializingValidationProblemDetails_WithExtensionData()
334302
{
335303
// Act
336304
var response = await Client.GetAsync("/contact/ActionReturningValidationProblemDetails");
@@ -364,4 +332,132 @@ public async Task SerializingValidationProblemDetails_WithExtensionData()
364332
});
365333
}
366334
}
335+
336+
public class ApiBehaviorTest : ApiBehaviorTestBase<BasicWebSite.StartupWithSystemTextJson>
337+
{
338+
public ApiBehaviorTest(MvcTestFixture<BasicWebSite.StartupWithSystemTextJson> fixture)
339+
: base(fixture)
340+
{
341+
}
342+
343+
[Fact]
344+
public override async Task ActionsReturnBadRequest_WhenModelStateIsInvalid()
345+
{
346+
// Arrange
347+
using var _ = new ActivityReplacer();
348+
349+
var contactModel = new Contact
350+
{
351+
Name = "Abc",
352+
City = "Redmond",
353+
State = "WA",
354+
Zip = "Invalid",
355+
};
356+
var contactString = JsonConvert.SerializeObject(contactModel);
357+
358+
// Act
359+
var response = await Client.PostAsJsonAsync("/contact", contactModel);
360+
361+
// Assert
362+
await response.AssertStatusCodeAsync(HttpStatusCode.BadRequest);
363+
Assert.Equal("application/problem+json", response.Content.Headers.ContentType.MediaType);
364+
var problemDetails = JsonConvert.DeserializeObject<ValidationProblemDetails>(
365+
await response.Content.ReadAsStringAsync(),
366+
new JsonSerializerSettings
367+
{
368+
Converters = { new ValidationProblemDetailsConverter() }
369+
});
370+
Assert.Collection(
371+
problemDetails.Errors.OrderBy(kvp => kvp.Key),
372+
kvp =>
373+
{
374+
Assert.Equal("Name", kvp.Key);
375+
var error = Assert.Single(kvp.Value);
376+
Assert.Equal("The field Name must be a string with a minimum length of 5 and a maximum length of 30.", error);
377+
},
378+
kvp =>
379+
{
380+
Assert.Equal("Zip", kvp.Key);
381+
var error = Assert.Single(kvp.Value);
382+
Assert.Equal("The field Zip must match the regular expression '\\d{5}'.", error);
383+
}
384+
);
385+
386+
Assert.Collection(
387+
problemDetails.Extensions,
388+
kvp =>
389+
{
390+
Assert.Equal("extensions", kvp.Key);
391+
var jObject = Assert.IsType<JObject>(kvp.Value);
392+
Assert.Equal("traceId", Assert.Single(jObject.Properties()).Name);
393+
});
394+
}
395+
396+
[Fact(Skip = "https://github.com/dotnet/corefx/issues/38769")]
397+
public override Task ClientErrorResultFilterExecutesForStatusCodeResults()
398+
{
399+
return base.ClientErrorResultFilterExecutesForStatusCodeResults();
400+
}
401+
402+
[Fact(Skip = "https://github.com/dotnet/corefx/issues/38769")]
403+
public override Task SerializingProblemDetails_IgnoresNullValuedProperties()
404+
{
405+
return base.SerializingProblemDetails_IgnoresNullValuedProperties();
406+
}
407+
408+
[Fact(Skip = "https://github.com/dotnet/corefx/issues/38769")]
409+
public override Task SerializingProblemDetails_WithAllValuesSpecified()
410+
{
411+
return base.SerializingProblemDetails_WithAllValuesSpecified();
412+
}
413+
414+
[Fact(Skip = "https://github.com/dotnet/corefx/issues/38769")]
415+
public override Task SerializingValidationProblemDetails_WithExtensionData()
416+
{
417+
return base.SerializingValidationProblemDetails_WithExtensionData();
418+
}
419+
}
420+
421+
public class ApiBehaviorTestNewtonsoftJson : ApiBehaviorTestBase<BasicWebSite.StartupWithoutEndpointRouting>
422+
{
423+
public ApiBehaviorTestNewtonsoftJson(MvcTestFixture<BasicWebSite.StartupWithoutEndpointRouting> fixture)
424+
: base(fixture)
425+
{
426+
var factory = fixture.WithWebHostBuilder(ConfigureWebHostBuilder);
427+
CustomInvalidModelStateClient = factory.CreateDefaultClient();
428+
}
429+
430+
private static void ConfigureWebHostBuilder(IWebHostBuilder builder) =>
431+
builder.UseStartup<BasicWebSite.StartupWithCustomInvalidModelStateFactory>();
432+
433+
public HttpClient CustomInvalidModelStateClient { get; }
434+
435+
[Fact]
436+
public async Task ActionsReturnBadRequest_UsesProblemDescriptionProviderAndApiConventionsToConfigureErrorResponse()
437+
{
438+
// Arrange
439+
var contactModel = new Contact
440+
{
441+
Name = "Abc",
442+
City = "Redmond",
443+
State = "WA",
444+
Zip = "Invalid",
445+
};
446+
var expected = new Dictionary<string, string[]>
447+
{
448+
{"Name", new[] {"The field Name must be a string with a minimum length of 5 and a maximum length of 30."}},
449+
{"Zip", new[] { @"The field Zip must match the regular expression '\d{5}'."}}
450+
};
451+
452+
// Act
453+
var response = await CustomInvalidModelStateClient.PostAsJsonAsync("/contact/PostWithVnd", contactModel);
454+
455+
// Assert
456+
await response.AssertStatusCodeAsync(HttpStatusCode.BadRequest);
457+
Assert.Equal("application/vnd.error+json", response.Content.Headers.ContentType.MediaType);
458+
var content = await response.Content.ReadAsStringAsync();
459+
var actual = JsonConvert.DeserializeObject<Dictionary<string, string[]>>(content);
460+
Assert.Equal(expected, actual);
461+
}
462+
}
367463
}

src/Mvc/test/Mvc.FunctionalTests/SystemTextJsonOutputFormatterTest.cs

+20-5
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using System.Net;
55
using System.Threading.Tasks;
66
using FormatterWebSite.Controllers;
7+
using Newtonsoft.Json.Linq;
78
using Xunit;
89

910
namespace Microsoft.AspNetCore.Mvc.FunctionalTests
@@ -15,7 +16,7 @@ public SystemTextJsonOutputFormatterTest(MvcTestFixture<FormatterWebSite.Startup
1516
{
1617
}
1718

18-
[Fact(Skip = "Dictionary serialization does not correctly work.")]
19+
[Fact(Skip = "https://github.com/aspnet/AspNetCore/issues/11459")]
1920
public override Task SerializableErrorIsReturnedInExpectedFormat() => base.SerializableErrorIsReturnedInExpectedFormat();
2021

2122
[Fact]
@@ -29,13 +30,27 @@ public override async Task Formatting_StringValueWithUnicodeContent()
2930
Assert.Equal("\"Hello Mr. \\ud83e\\udd8a\"", await response.Content.ReadAsStringAsync());
3031
}
3132

32-
[Fact(Skip = "Dictionary serialization does not correctly work.")]
33+
[Fact]
3334
public override Task Formatting_DictionaryType() => base.Formatting_DictionaryType();
3435

35-
[Fact(Skip = "Dictionary serialization does not correctly work.")]
36-
public override Task Formatting_ProblemDetails() => base.Formatting_ProblemDetails();
36+
[Fact]
37+
public override async Task Formatting_ProblemDetails()
38+
{
39+
using var _ = new ActivityReplacer();
40+
41+
// Act
42+
var response = await Client.GetAsync($"/JsonOutputFormatter/{nameof(JsonOutputFormatterController.ProblemDetailsResult)}");
43+
44+
// Assert
45+
await response.AssertStatusCodeAsync(HttpStatusCode.NotFound);
3746

38-
[Fact(Skip = "https://github.com/dotnet/corefx/issues/36166")]
47+
var obj = JObject.Parse(await response.Content.ReadAsStringAsync());
48+
Assert.Equal("https://tools.ietf.org/html/rfc7231#section-6.5.4", obj.Value<string>("type"));
49+
Assert.Equal("Not Found", obj.Value<string>("title"));
50+
Assert.Equal("404", obj.Value<string>("status"));
51+
}
52+
53+
[Fact]
3954
public override Task Formatting_PolymorphicModel() => base.Formatting_PolymorphicModel();
4055
}
4156
}

src/Mvc/test/WebSites/BasicWebSite/StartupWithSystemTextJson.cs

+2
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ public void ConfigureServices(IServiceCollection services)
1414
services
1515
.AddMvc()
1616
.SetCompatibilityVersion(CompatibilityVersion.Latest);
17+
18+
services.AddSingleton<ContactsRepository>();
1719
}
1820

1921
public void Configure(IApplicationBuilder app)

0 commit comments

Comments
 (0)