Skip to content

Commit 8c5c117

Browse files
authored
Fix: restore ProblemDetails errors-dict fix on main (was lost in branch ordering) (#56)
* Fix: AddMvc() no longer invokes AddMvcCore — preserves ProblemDetails errors `ServiceLevelIndicatorServiceCollectionExtensions.AddMvc()` previously called `services.AddMvcCore(options => options.Conventions.Add(new ServiceLevelIndicatorConvention()))` just to register a single MVC convention. Calling `AddMvcCore()` after the host has already wired up `AddControllers()` + `AddProblemDetails()` re-registers the MVC services pipeline (ApplicationPartManager, MvcMarkerService, etc.) and breaks the polymorphic JSON serialization performed by `IProblemDetailsService` for `HttpValidationProblemDetails`. Symptom (live repro on a template that combines AddProblemDetails + AddControllers + AddApiVersioning().AddOpenApi() + AddServiceLevelIndicator().AddMvc()): PUT /api/Todos/{id} → HTTP 422 { "type": "...", "title": "One or more validation errors occurred.", "status": 422, "traceId": "..." // missing: "errors": { ... } ← stripped because writer used base ProblemDetails type } Fix: register the convention with `services.Configure<MvcOptions>(...)` instead of `AddMvcCore(...)`. The convention is wired the same way, but no MVC services are re-introduced and the ProblemDetails writer pipeline is left intact. Adds `ProblemDetailsInteropTests`: * `AddMvc_registers_..._without_calling_AddMvcCore` — guards against regression by asserting `AddMvc()` does not introduce any `Microsoft.AspNetCore.Mvc.*` service types that `AddMvcCore()` would. Verified to FAIL on the previous implementation (catches `ApplicationPartManager` leak) and PASS with the fix. * `AddMvc_validation_problem_includes_errors_when_written_via_ProblemDetailsService` — integration smoke test that exercises `Results.ValidationProblem(...).ExecuteAsync` through `IProblemDetailsService` and asserts the `errors` dict survives.
1 parent 32bba26 commit 8c5c117

2 files changed

Lines changed: 168 additions & 1 deletion

File tree

Trellis.ServiceLevelIndicators.Asp/src/ServiceLevelIndicatorServiceCollectionExtensions.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
namespace Trellis.ServiceLevelIndicators;
22

3+
using Microsoft.AspNetCore.Mvc;
34
using Microsoft.Extensions.DependencyInjection;
45
using Microsoft.Extensions.DependencyInjection.Extensions;
56

@@ -11,7 +12,7 @@ public static class ServiceLevelIndicatorServiceCollectionExtensions
1112
public static IServiceLevelIndicatorBuilder AddMvc(this IServiceLevelIndicatorBuilder builder)
1213
{
1314
ArgumentNullException.ThrowIfNull(builder);
14-
builder.Services.AddMvcCore(static options => options.Conventions.Add(new ServiceLevelIndicatorConvention()));
15+
builder.Services.Configure<MvcOptions>(static options => options.Conventions.Add(new ServiceLevelIndicatorConvention()));
1516
return builder;
1617
}
1718

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
namespace Trellis.ServiceLevelIndicators.Asp.Tests;
2+
3+
using System.Diagnostics.Metrics;
4+
using System.Net;
5+
using System.Reflection;
6+
using System.Text.Json;
7+
using FluentAssertions;
8+
using Microsoft.AspNetCore.Builder;
9+
using Microsoft.AspNetCore.Hosting;
10+
using Microsoft.AspNetCore.Http;
11+
using Microsoft.AspNetCore.Mvc;
12+
using Microsoft.AspNetCore.Mvc.ApplicationModels;
13+
using Microsoft.AspNetCore.TestHost;
14+
using Microsoft.Extensions.DependencyInjection;
15+
using Microsoft.Extensions.Hosting;
16+
using Microsoft.Extensions.Options;
17+
18+
/// <summary>
19+
/// Regression tests covering interop between <see cref="ServiceLevelIndicatorServiceCollectionExtensions.AddMvc"/>
20+
/// and the ASP.NET Core <c>IProblemDetailsService</c> pipeline registered via <c>AddProblemDetails()</c>.
21+
///
22+
/// Historical bug: <c>AddMvc()</c> previously called <c>services.AddMvcCore(...)</c> just to register a single
23+
/// MVC convention. Re-invoking the MVC services pipeline after the host had already called
24+
/// <c>AddControllers()</c> + <c>AddProblemDetails()</c> + <c>AddApiVersioning()...AddOpenApi()</c>
25+
/// caused the <c>IProblemDetailsService</c> writer to drop the runtime-typed <c>Errors</c> dictionary
26+
/// from <see cref="HttpValidationProblemDetails"/> responses (the writer fell back to the static base
27+
/// <see cref="ProblemDetails"/> type for serialization). Extensions on the base type (<c>traceId</c>, custom
28+
/// keys) survived; the validation <c>errors</c> dictionary did not.
29+
///
30+
/// The fix replaces <c>AddMvcCore(...)</c> with <c>Configure&lt;MvcOptions&gt;(...)</c>, which registers the
31+
/// convention without re-invoking the MVC services pipeline.
32+
/// </summary>
33+
public class ProblemDetailsInteropTests
34+
{
35+
[Fact]
36+
public void AddMvc_registers_ServiceLevelIndicatorConvention_without_calling_AddMvcCore()
37+
{
38+
// Baseline: capture the set of services AddMvcCore would have introduced.
39+
var withMvcCore = new ServiceCollection();
40+
withMvcCore.AddMvcCore();
41+
var mvcCoreOnlyTypes = withMvcCore
42+
.Select(d => d.ServiceType.FullName)
43+
.Where(name => name is not null)
44+
.ToHashSet();
45+
46+
// Subject: a service collection with ONLY AddServiceLevelIndicator().AddMvc().
47+
using var meter = new Meter(nameof(ProblemDetailsInteropTests));
48+
var services = new ServiceCollection();
49+
services.AddOptions();
50+
services.AddServiceLevelIndicator(options =>
51+
{
52+
options.Meter = meter;
53+
options.CustomerResourceId = "TestCustomerResourceId";
54+
options.LocationId = ServiceLevelIndicator.CreateLocationId("public", "West US 3");
55+
}).AddMvc();
56+
57+
// The convention must be registered (functional check). MvcOptions.Conventions is
58+
// IList<IApplicationModelConvention>; ASP.NET wraps an IParameterModelConvention in an
59+
// internal adapter when added, so we can't pattern-match the stored instance directly.
60+
// Instead, inspect each convention's fields for a wrapped ServiceLevelIndicatorConvention.
61+
using var provider = services.BuildServiceProvider();
62+
var mvcOptions = provider.GetRequiredService<IOptions<MvcOptions>>().Value;
63+
mvcOptions.Conventions.Should().Contain(
64+
convention => WrapsServiceLevelIndicatorConvention(convention),
65+
"AddMvc() must register the ServiceLevelIndicatorConvention.");
66+
67+
// AddMvc() must NOT pull in the rest of MvcCore's service registrations. If any MvcCore-only
68+
// service types appear in our subject collection, AddMvc() is calling AddMvcCore() under the
69+
// hood and will interfere with the host's already-configured MVC + ProblemDetails pipeline.
70+
var subjectTypes = services
71+
.Select(d => d.ServiceType.FullName)
72+
.Where(name => name is not null)
73+
.ToHashSet();
74+
75+
var leakedMvcCoreServices = subjectTypes
76+
.Intersect(mvcCoreOnlyTypes)
77+
.Where(t => t!.StartsWith("Microsoft.AspNetCore.Mvc", StringComparison.Ordinal))
78+
.ToList();
79+
80+
leakedMvcCoreServices.Should().BeEmpty(
81+
"AddMvc() must register only the convention, not invoke AddMvcCore(). " +
82+
"Re-invoking the MVC services pipeline interferes with IProblemDetailsService " +
83+
"polymorphic serialization of HttpValidationProblemDetails.");
84+
85+
// Targeted regression check: AddMvcCore() registers MVC's IProblemDetailsWriter
86+
// (Microsoft.AspNetCore.Http namespace, so missed by the prefix filter above). That writer
87+
// is the exact service whose stale registration caused the original 'errors'-stripping bug.
88+
services.Should().NotContain(
89+
d => d.ServiceType.FullName == "Microsoft.AspNetCore.Http.IProblemDetailsWriter",
90+
"AddMvc() must not introduce IProblemDetailsWriter — that's what caused the 422 'errors' dict to be dropped.");
91+
}
92+
93+
[Fact]
94+
public async Task AddMvc_validation_problem_includes_errors_when_written_via_ProblemDetailsService()
95+
{
96+
using var meter = new Meter(nameof(ProblemDetailsInteropTests));
97+
using var host = await CreateHost(meter);
98+
99+
var ct = TestContext.Current.CancellationToken;
100+
var client = host.GetTestClient();
101+
var response = await client.PostAsync("/problem/validate", content: null, ct);
102+
103+
response.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
104+
var body = await response.Content.ReadAsStringAsync(ct);
105+
106+
using var doc = JsonDocument.Parse(body);
107+
var root = doc.RootElement;
108+
109+
root.TryGetProperty("status", out var status).Should().BeTrue();
110+
status.GetInt32().Should().Be(StatusCodes.Status422UnprocessableEntity);
111+
112+
root.TryGetProperty("errors", out var errors).Should().BeTrue(
113+
$"Validation 'errors' dictionary must round-trip through IProblemDetailsService. Body: {body}");
114+
errors.ValueKind.Should().Be(JsonValueKind.Object);
115+
errors.TryGetProperty("name", out _).Should().BeTrue($"Body: {body}");
116+
}
117+
118+
private static async Task<IHost> CreateHost(Meter meter) =>
119+
await new HostBuilder()
120+
.ConfigureWebHost(webBuilder => webBuilder
121+
.UseTestServer()
122+
.ConfigureServices(services =>
123+
{
124+
services.AddProblemDetails();
125+
services.AddControllers();
126+
services.AddServiceLevelIndicator(options =>
127+
{
128+
options.Meter = meter;
129+
options.CustomerResourceId = "TestCustomerResourceId";
130+
options.LocationId = ServiceLevelIndicator.CreateLocationId("public", "West US 3");
131+
}).AddMvc();
132+
})
133+
.Configure(app => app
134+
.UseRouting()
135+
.UseServiceLevelIndicator()
136+
.UseEndpoints(endpoints => endpoints.MapControllers())))
137+
.StartAsync();
138+
139+
private static bool WrapsServiceLevelIndicatorConvention(IApplicationModelConvention convention)
140+
{
141+
if ((object)convention is ServiceLevelIndicatorConvention)
142+
return true;
143+
144+
// ASP.NET wraps non-IApplicationModelConvention conventions (e.g. IParameterModelConvention)
145+
// in an internal adapter that holds the inner convention in a private field.
146+
const BindingFlags bindingFlags = BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public;
147+
return convention.GetType()
148+
.GetFields(bindingFlags)
149+
.Any(f => f.GetValue(convention) is ServiceLevelIndicatorConvention);
150+
}
151+
}
152+
153+
[ApiController]
154+
[Route("problem")]
155+
public sealed class ProblemDetailsTestController : ControllerBase
156+
{
157+
// Mirrors what frameworks like Trellis.Asp's ResponseFailureWriter do: invoke
158+
// Results.ValidationProblem(...).ExecuteAsync(httpContext), which writes via IProblemDetailsService.
159+
[HttpPost("validate")]
160+
public async Task Validate()
161+
{
162+
var errors = new Dictionary<string, string[]> { ["name"] = ["Name is required."] };
163+
var result = Results.ValidationProblem(errors, statusCode: StatusCodes.Status422UnprocessableEntity);
164+
await result.ExecuteAsync(HttpContext);
165+
}
166+
}

0 commit comments

Comments
 (0)