Skip to content

Commit 908f46f

Browse files
LEGLINK-887: Terminology FHIR endpoints return Problem Details on error (#1779)
* LEGLINK-887: Terminology FHIR endpoints return Problem Details on error FhirController answered every error with a bare string body and text/plain, so a rejected request carried no type, title, status or traceId. LEGLINK-886 corrected the status code for a request naming no value set, but the payload was still a plain string; this makes the body match the RFC 9457 format ConfigController has returned since LEGLINK-591. - Adds TerminologyProblem plus BadRequestProblem, NotFoundProblem and InternalServerErrorProblem helpers, and routes all 13 error returns across the six actions through them (7 BadRequest, 3 NotFound, 3 raw 500). - traceId comes from the existing AddTerminologyProblemDetails customization. - detail is prose, so the helper appends a terminating period. Exception messages stay fragments because they are also read from logs and asserted in unit tests. - 500 responses no longer place the exception message in the body; the customization substitutes a generic detail so internal state is not exposed to the caller. Testing: 73 unit tests pass in UnitTests.Terminology, one new covering LEGLINK-887's reported request (empty valueUri in the POST body). Verified against the local docker-compose stack that an empty valueUri in the body, an empty url query parameter, a markup-only display and an unknown ValueSet id all return application/problem+json carrying type, title, status, detail and a W3C traceId, and that the $validate-code success and failure payloads are unchanged. * LEGLINK-887: Cover the NotFound and 500 Problem Details contracts Follow-up to review feedback on #1779: AssertBadRequestProblem was the only assertion helper, so NotFoundProblem and InternalServerErrorProblem shipped untested. - Generalises the helper to AssertProblem(result, status, title, type, detail) and keeps AssertBadRequestProblem as a wrapper, leaving the existing call sites unchanged. - Adds 404 coverage via GetValueSetById with an id the cache does not hold, and 500 coverage via a code group cached under the ValueSet type whose resource is a CodeSystem. - Exercises the configured CustomizeProblemDetails callback directly, with no host and no HTTP call, to assert a 5xx detail is replaced by the generic message and a traceId is added. A paired client-error test pins the scrubbing to 5xx so an over-broad change cannot silently erase the actionable 4xx detail this change exists to deliver. The controller-level 500 test asserts status, title and type only: ProblemDetailsFactory is null in unit tests, so ControllerBase.Problem builds a plain ProblemDetails and the scrubbing belongs to the customization test. Testing: 77 unit tests pass in UnitTests.Terminology, up from 73. Test project only; no production code changed.
1 parent e80df17 commit 908f46f

2 files changed

Lines changed: 188 additions & 21 deletions

File tree

DotNet/ServiceTests/UnitTests/Terminology/Controllers/FhirControllerTests.cs

Lines changed: 141 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
using Hl7.Fhir.Model;
2+
using LantanaGroup.Link.Terminology.Application.Extensions;
23
using LantanaGroup.Link.Terminology.Application.Interfaces;
34
using LantanaGroup.Link.Terminology.Application.Models;
45
using LantanaGroup.Link.Terminology.Controllers;
56
using LantanaGroup.Link.Terminology.Services;
7+
using Microsoft.AspNetCore.Hosting;
8+
using Microsoft.AspNetCore.Http;
69
using Microsoft.AspNetCore.Mvc;
10+
using Microsoft.Extensions.DependencyInjection;
11+
using Microsoft.Extensions.Hosting;
712
using Microsoft.Extensions.Logging;
13+
using Microsoft.Extensions.Options;
814
using Moq;
915
using Xunit;
1016
using Code = LantanaGroup.Link.Terminology.Application.Models.Code;
@@ -53,6 +59,33 @@ private static Parameters AssertOkParameters(ActionResult<Parameters> result)
5359
return Assert.IsType<Parameters>(okResult.Value);
5460
}
5561

62+
/// <summary>
63+
/// Asserts that the action produced an RFC 9457 Problem Details result with the given status, title,
64+
/// type and detail.
65+
/// </summary>
66+
/// <remarks>
67+
/// No <see cref="HttpContext"/> is wired up, so the controller's <c>ProblemDetailsFactory</c> is null and
68+
/// <c>ControllerBase.Problem</c> builds a plain <see cref="ProblemDetails"/> from its arguments. The runtime
69+
/// <c>traceId</c> extension and the scrubbing of 5xx detail are applied by the configured customization,
70+
/// which is covered separately below (see ConfigControllerTests for the same note).
71+
/// </remarks>
72+
private static void AssertProblem(
73+
ActionResult? result, int expectedStatus, string expectedTitle, string expectedType, string expectedDetail)
74+
{
75+
var objectResult = Assert.IsType<ObjectResult>(result);
76+
Assert.Equal(expectedStatus, objectResult.StatusCode);
77+
78+
var problem = Assert.IsType<ProblemDetails>(objectResult.Value);
79+
Assert.Equal(expectedTitle, problem.Title);
80+
Assert.Equal(expectedStatus, problem.Status);
81+
Assert.Equal(expectedType, problem.Type);
82+
Assert.Equal(expectedDetail, problem.Detail);
83+
}
84+
85+
private static void AssertBadRequestProblem(ActionResult<Parameters> result, string expectedDetail) =>
86+
AssertProblem(result.Result, StatusCodes.Status400BadRequest, "Bad Request",
87+
"https://tools.ietf.org/html/rfc9110#section-15.5.1", expectedDetail);
88+
5689
[Fact]
5790
public void ValidateCodeInValueSet_WithDisplayContainingAmpersand_ReturnsTrue()
5891
{
@@ -99,8 +132,7 @@ public void ValidateCodeInValueSet_WithMarkupOnlyDisplay_ReturnsBadRequest()
99132

100133
// Assert - the display sanitizes away to nothing; passing the empty value on would skip the
101134
// display check and answer result=true, so the request is rejected instead
102-
var badRequest = Assert.IsType<BadRequestObjectResult>(result.Result);
103-
Assert.Equal("Invalid value supplied for 'display'", badRequest.Value);
135+
AssertBadRequestProblem(result, "Invalid value supplied for 'display'.");
104136
}
105137

106138
[Fact]
@@ -116,8 +148,7 @@ public void ValidateCodeInCodeSystem_WithMarkupOnlyDisplay_ReturnsBadRequest()
116148
CodeSystemUrl, null, LoincCode, "<script>alert('x')</script>", null);
117149

118150
// Assert
119-
var badRequest = Assert.IsType<BadRequestObjectResult>(result.Result);
120-
Assert.Equal("Invalid value supplied for 'display'", badRequest.Value);
151+
AssertBadRequestProblem(result, "Invalid value supplied for 'display'.");
121152
}
122153

123154
[Fact]
@@ -127,8 +158,7 @@ public void ValidateCodeInValueSet_WithNoUrlOrId_ReturnsBadRequest()
127158
var result = _controller.ValidateCodeInValueSet(null, null, null, LoincCode, null, null);
128159

129160
// Assert
130-
var badRequest = Assert.IsType<BadRequestObjectResult>(result.Result);
131-
Assert.Equal("No id or url parameter specified", badRequest.Value);
161+
AssertBadRequestProblem(result, "No id or url parameter specified.");
132162
}
133163

134164
[Fact]
@@ -138,7 +168,110 @@ public void ValidateCodeInValueSet_WithEmptyUrl_ReturnsBadRequest()
138168
var result = _controller.ValidateCodeInValueSet(string.Empty, null, null, LoincCode, null, null);
139169

140170
// Assert
141-
var badRequest = Assert.IsType<BadRequestObjectResult>(result.Result);
142-
Assert.Equal("No id or url parameter specified", badRequest.Value);
171+
AssertBadRequestProblem(result, "No id or url parameter specified.");
172+
}
173+
174+
[Fact]
175+
public void ValidateCodeInValueSet_WithEmptyValueUriInBody_ReturnsBadRequest()
176+
{
177+
// Arrange - LEGLINK-887's reported request: the url arrives in the POST body as an empty valueUri
178+
var parameters = new Parameters();
179+
parameters.Add("url", new FhirUri(string.Empty));
180+
parameters.Add("code", new FhirString(LoincCode));
181+
182+
// Act
183+
var result = _controller.ValidateCodeInValueSet(null, null, null, null, null, parameters);
184+
185+
// Assert
186+
AssertBadRequestProblem(result, "No id or url parameter specified.");
187+
}
188+
189+
[Fact]
190+
public void GetValueSetById_WhenValueSetNotLoaded_ReturnsNotFoundProblem()
191+
{
192+
// Arrange - the cache has no value set under this id
193+
_mockCacheService
194+
.Setup(x => x.GetCodeGroupById(CodeGroup.CodeGroupTypes.ValueSet, "missing-vs", It.IsAny<string>()))
195+
.Returns((CodeGroup?)null);
196+
197+
// Act
198+
var result = _controller.GetValueSetById("missing-vs");
199+
200+
// Assert
201+
AssertProblem(result.Result, StatusCodes.Status404NotFound, "Not Found",
202+
"https://tools.ietf.org/html/rfc9110#section-15.5.5", "Value set not found with ID missing-vs.");
203+
}
204+
205+
[Fact]
206+
public void GetValueSets_WhenCachedResourceIsNotAValueSet_ReturnsInternalServerErrorProblem()
207+
{
208+
// Arrange - a code group cached under the ValueSet type whose resource is a CodeSystem
209+
var mismatched = BuildCodeGroup(CodeGroup.CodeGroupTypes.ValueSet, CodeSystemUrl);
210+
mismatched.Resource = new CodeSystem { Id = "not-a-value-set", Url = CodeSystemUrl };
211+
212+
_mockCacheService
213+
.Setup(x => x.GetCodeGroup(CodeGroup.CodeGroupTypes.ValueSet, ValueSetUrl, It.IsAny<string>()))
214+
.Returns(mismatched);
215+
216+
// Act
217+
var result = _controller.GetValueSets(ValueSetUrl, null);
218+
219+
// Assert - the controller sets the 5xx contract; the customization scrubs the detail at runtime
220+
AssertProblem(result.Result, StatusCodes.Status500InternalServerError, "Internal Server Error",
221+
"https://tools.ietf.org/html/rfc9110#section-15.6.1", "Code group found is not a ValueSet.");
222+
}
223+
224+
/// <summary>
225+
/// Builds the <c>CustomizeProblemDetails</c> callback the service registers at startup, so the
226+
/// runtime-only behaviour can be exercised without standing up a host or issuing an HTTP request.
227+
/// </summary>
228+
private static Action<ProblemDetailsContext> GetConfiguredCustomization()
229+
{
230+
var environment = new Mock<IWebHostEnvironment>();
231+
environment.SetupGet(e => e.EnvironmentName).Returns(Environments.Production);
232+
233+
var options = new ServiceCollection()
234+
.AddTerminologyProblemDetails(environment.Object)
235+
.BuildServiceProvider()
236+
.GetRequiredService<IOptions<ProblemDetailsOptions>>();
237+
238+
return Assert.IsType<Action<ProblemDetailsContext>>(options.Value.CustomizeProblemDetails);
239+
}
240+
241+
private static ProblemDetailsContext BuildContext(int status, string detail) => new()
242+
{
243+
HttpContext = new DefaultHttpContext(),
244+
ProblemDetails = new ProblemDetails { Status = status, Detail = detail }
245+
};
246+
247+
[Fact]
248+
public void ProblemDetailsCustomization_ForServerError_ReplacesRawExceptionDetail()
249+
{
250+
// Arrange - the raw message a 500 would otherwise carry out of the controller
251+
var context = BuildContext(StatusCodes.Status500InternalServerError, "Value set could not be copied.");
252+
253+
// Act
254+
GetConfiguredCustomization()(context);
255+
256+
// Assert - internal state is replaced by a generic message, and a traceId is added to correlate
257+
Assert.Equal(
258+
"An error occurred in our API. Please use the trace id when requesting assistance.",
259+
context.ProblemDetails.Detail);
260+
Assert.DoesNotContain("Value set could not be copied", context.ProblemDetails.Detail);
261+
Assert.True(context.ProblemDetails.Extensions.ContainsKey("traceId"));
262+
}
263+
264+
[Fact]
265+
public void ProblemDetailsCustomization_ForClientError_PreservesDetail()
266+
{
267+
// Arrange - scrubbing must be limited to 5xx; a 4xx detail is actionable and must survive
268+
var context = BuildContext(StatusCodes.Status404NotFound, "Value set not found with ID missing-vs.");
269+
270+
// Act
271+
GetConfiguredCustomization()(context);
272+
273+
// Assert
274+
Assert.Equal("Value set not found with ID missing-vs.", context.ProblemDetails.Detail);
275+
Assert.True(context.ProblemDetails.Extensions.ContainsKey("traceId"));
143276
}
144277
}

DotNet/Terminology/Controllers/FhirController.cs

Lines changed: 47 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,40 @@ public class FhirController(FhirService fhirService) : Controller
5454
return sanitized;
5555
}
5656

57+
/// <summary>
58+
/// Builds an RFC 9457 Problem Details result for a failed terminology request, matching the shape
59+
/// <see cref="ConfigController"/> already returns. The <c>traceId</c> extension is added by the
60+
/// service-wide customization in <c>TerminologyProblemDetailsExtensions</c>.
61+
/// </summary>
62+
/// <remarks>
63+
/// Exception messages are written as fragments ("Value set not found with ID x") because they are
64+
/// also read from logs and asserted in unit tests. <c>detail</c> is prose, so a terminating period
65+
/// is added here rather than baked into every message at the throw site.
66+
/// </remarks>
67+
private ObjectResult TerminologyProblem(HttpStatusCode statusCode, string title, string type, string detail)
68+
{
69+
var sentence = detail.EndsWith('.') || detail.EndsWith('?') || detail.EndsWith('!')
70+
? detail
71+
: detail + ".";
72+
73+
return Problem(detail: sentence, statusCode: (int)statusCode, title: title, type: type);
74+
}
75+
76+
/// <summary>Client input failed validation. RFC 9110 section 15.5.1.</summary>
77+
private ObjectResult BadRequestProblem(string detail) => TerminologyProblem(
78+
HttpStatusCode.BadRequest, "Bad Request", "https://tools.ietf.org/html/rfc9110#section-15.5.1", detail);
79+
80+
/// <summary>The requested terminology resource is not loaded. RFC 9110 section 15.5.5.</summary>
81+
private ObjectResult NotFoundProblem(string detail) => TerminologyProblem(
82+
HttpStatusCode.NotFound, "Not Found", "https://tools.ietf.org/html/rfc9110#section-15.5.5", detail);
83+
84+
/// <summary>
85+
/// A loaded code group could not be used as requested. RFC 9110 section 15.6.1. The customization
86+
/// replaces <c>detail</c> with a generic message so internal state is not exposed to the caller.
87+
/// </summary>
88+
private ObjectResult InternalServerErrorProblem(string detail) => TerminologyProblem(
89+
HttpStatusCode.InternalServerError, "Internal Server Error", "https://tools.ietf.org/html/rfc9110#section-15.6.1", detail);
90+
5791
#region Value Sets
5892

5993
/// <summary>
@@ -75,11 +109,11 @@ public ActionResult<ValueSet> GetValueSetById([FromRoute] string id)
75109
}
76110
catch (ArgumentException ex)
77111
{
78-
return BadRequest(ex.Message);
112+
return BadRequestProblem(ex.Message);
79113
}
80114
catch (KeyNotFoundException ex)
81115
{
82-
return NotFound(ex.Message);
116+
return NotFoundProblem(ex.Message);
83117
}
84118
}
85119

@@ -105,11 +139,11 @@ public ActionResult<Bundle> GetValueSets([FromQuery] string? url,
105139
}
106140
catch (ArgumentException ex)
107141
{
108-
return BadRequest(ex.Message);
142+
return BadRequestProblem(ex.Message);
109143
}
110144
catch (InvalidOperationException ex)
111145
{
112-
return StatusCode(StatusCodes.Status500InternalServerError, ex.Message);
146+
return InternalServerErrorProblem(ex.Message);
113147
}
114148
}
115149

@@ -134,15 +168,15 @@ public ActionResult<ValueSet> ExpandValueSet([FromRoute] string? id, [FromQuery]
134168
}
135169
catch (ArgumentException ex)
136170
{
137-
return BadRequest(ex.Message);
171+
return BadRequestProblem(ex.Message);
138172
}
139173
catch (KeyNotFoundException ex)
140174
{
141-
return NotFound(ex.Message);
175+
return NotFoundProblem(ex.Message);
142176
}
143177
catch (InvalidOperationException ex)
144178
{
145-
return StatusCode(StatusCodes.Status500InternalServerError, ex.Message);
179+
return InternalServerErrorProblem(ex.Message);
146180
}
147181
}
148182

@@ -168,11 +202,11 @@ public ActionResult<CodeSystem> GetCodeSystemById([FromRoute] string id)
168202
}
169203
catch (ArgumentException ex)
170204
{
171-
return BadRequest(ex.Message);
205+
return BadRequestProblem(ex.Message);
172206
}
173207
catch (KeyNotFoundException ex)
174208
{
175-
return NotFound(ex.Message);
209+
return NotFoundProblem(ex.Message);
176210
}
177211
}
178212

@@ -198,11 +232,11 @@ public ActionResult<Bundle> GetCodeSystems([FromQuery] string? url, [FromQuery(N
198232
}
199233
catch (ArgumentException ex)
200234
{
201-
return BadRequest(ex.Message);
235+
return BadRequestProblem(ex.Message);
202236
}
203237
catch (InvalidOperationException ex)
204238
{
205-
return StatusCode(StatusCodes.Status500InternalServerError, ex.Message);
239+
return InternalServerErrorProblem(ex.Message);
206240
}
207241
}
208242

@@ -235,7 +269,7 @@ public ActionResult<Parameters> ValidateCodeInCodeSystem([FromQuery] string? url
235269
}
236270
catch (ArgumentException ex)
237271
{
238-
return BadRequest(ex.Message);
272+
return BadRequestProblem(ex.Message);
239273
}
240274
}
241275

@@ -271,7 +305,7 @@ public ActionResult<Parameters> ValidateCodeInValueSet([FromQuery] string? url,
271305
}
272306
catch (ArgumentException ex)
273307
{
274-
return BadRequest(ex.Message);
308+
return BadRequestProblem(ex.Message);
275309
}
276310
}
277311

0 commit comments

Comments
 (0)