LEGLINK-888: ValueSet $validate-code rejects a blank "system" with a 400 - #1794
Conversation
An empty "system" was folded into the absent case by string.IsNullOrEmpty and answered by searching every code system in the value set, so TestRail 10992's request returned 200 result=true. An absent system is legitimate FHIR and keeps that meaning; an empty string is not valid for any FHIR primitive, so the request is malformed and is now rejected rather than reinterpreted into a broader question than the caller asked. - Adds NormalizeSystem and validates every client-supplied system up front in ValidateCodeInValueSet: the query parameter, the body "system" parameter, coding.system and each codeableConcept.coding.system. Validating before the merges matters because they treat an empty string as "not supplied" and would overwrite a blank before anything could see it, and it keeps the verdict for a malformed codeableConcept independent of which coding happens to match first. - Whitespace-only is treated as blank, matching the FHIR rule that a primitive carries at least one character of non-whitespace content. - The literal "null" and "undefined" are treated as an omitted parameter. This is deliberate leniency for clients that interpolate an unset variable into a request rather than FHIR behavior, so it is pinned by tests. - The string.IsNullOrEmpty check in ValidateCodeInCodeGroup is left alone: it is shared with ValidateCodeInCodeSystem, which passes the code group's own Url there, and that is cache content rather than client input. - Registers PreserveEmptyStringMetadataProvider. MVC converts an empty query value to null before an action runs, so "?system=" reached the action indistinguishable from an omitted parameter and returned 200 regardless of the validation above. MvcOptions exposes no setting for this and DisplayFormatAttribute cannot target a parameter, so a display metadata provider is the supported route. Body binding is unaffected; FhirModelBinder deserializes with System.Text.Json and never consults MVC metadata. - Adds FhirControllerHttpTests, which drives the four QA requests (TestRail 10992, 11015, 11342, 11343) over real HTTP through the configured binding pipeline. Calling the action directly cannot reach the query-string case: a direct call passing string.Empty exercises a state real traffic cannot produce. Testing: 97 unit tests pass in UnitTests.Terminology, up from 77, 20 of them new; the full .NET unit suite passes at 1128. Confirmed the metadata provider is load-bearing by removing it and observing only the "?system=" test fail. Verified against the local docker-compose stack: before the change all four QA requests returned 200 result=true; after rebuilding the image each returns application/problem+json carrying type, title, status, the expected detail and a W3C traceId. An omitted system and a valid system are unchanged at 200; "?system=null" now searches all systems rather than being looked up as a system URL, so it returns result=true where it previously returned result=false.
|
Important Review skippedAuto incremental reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (2)
DotNet/Terminology/Services/FhirService.cs (1)
597-611: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low valueConsider trimming the system before comparison.
NormalizeSystemrejects a whitespace-only system, but it keeps surrounding whitespace on a non-blank value. A request with?system=%20http://xtherefore reaches the lookup with a leading space and returnsresult=falseinstead of matching the system. A trim also makes the placeholder comparison catch" null ".♻️ Proposed refactor
- // FHIR requires at least one character of non-whitespace content, so " " is as malformed as "". - if (string.IsNullOrWhiteSpace(system)) - { - throw new ArgumentException($"The '{parameterName}' parameter cannot be blank"); - } - - return SystemPlaceholders.Contains(system, StringComparer.OrdinalIgnoreCase) ? null : system; + // FHIR requires at least one character of non-whitespace content, so " " is as malformed as "". + if (string.IsNullOrWhiteSpace(system)) + { + throw new ArgumentException($"The '{parameterName}' parameter cannot be blank"); + } + + var trimmed = system.Trim(); + + return SystemPlaceholders.Contains(trimmed, StringComparer.OrdinalIgnoreCase) ? null : trimmed;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@DotNet/Terminology/Services/FhirService.cs` around lines 597 - 611, Update NormalizeSystem to trim the validated system value before comparing it against SystemPlaceholders and returning it, while preserving null and whitespace-only handling. Ensure values such as " http://x " match lookups and whitespace-padded placeholders resolve to null.DotNet/ServiceTests/UnitTests/Terminology/Controllers/FhirControllerHttpTests.cs (1)
250-254: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winAssert the
resultvalue, not only the presence of the parameter.The placeholder test states that
?system=nullis treated as an absent system. The assertions only check the status code and that the payload contains"result". A response withresult=falsealso passes, so the test does not prove the stated behavior. Parse the payload and assert the boolean value. The same weakness applies to Line 224-225, whereAssert.Contains("true", payload)matches any occurrence oftruein the JSON.💚 Proposed fix
var (status, payload) = await PostAsync(query, body); Assert.Equal(HttpStatusCode.OK, status); - Assert.Contains("\"result\"", payload); + AssertValidationResult(payload, true); }Add a shared helper next to
AssertBadRequestDetail, and use it inValidateCodeInValueSet_AbsentSystem_SearchesAllSystemsAndSucceedsas well:private static void AssertValidationResult(string body, bool expected) { using var document = JsonDocument.Parse(body); var parameter = document.RootElement .GetProperty("parameter") .EnumerateArray() .Single(p => p.GetProperty("name").GetString() == "result"); Assert.Equal(expected, parameter.GetProperty("valueBoolean").GetBoolean()); }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@DotNet/ServiceTests/UnitTests/Terminology/Controllers/FhirControllerHttpTests.cs` around lines 250 - 254, Add a shared AssertValidationResult helper beside AssertBadRequestDetail that parses the JSON payload and asserts the result parameter’s valueBoolean equals the expected value. Replace the broad payload assertions in ValidateCodeInValueSet_AbsentSystem_SearchesAllSystemsAndSucceeds and the test around the existing Assert.Contains("true", payload) with this helper, passing the expected boolean.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In
`@DotNet/ServiceTests/UnitTests/Terminology/Controllers/FhirControllerHttpTests.cs`:
- Around line 250-254: Add a shared AssertValidationResult helper beside
AssertBadRequestDetail that parses the JSON payload and asserts the result
parameter’s valueBoolean equals the expected value. Replace the broad payload
assertions in ValidateCodeInValueSet_AbsentSystem_SearchesAllSystemsAndSucceeds
and the test around the existing Assert.Contains("true", payload) with this
helper, passing the expected boolean.
In `@DotNet/Terminology/Services/FhirService.cs`:
- Around line 597-611: Update NormalizeSystem to trim the validated system value
before comparing it against SystemPlaceholders and returning it, while
preserving null and whitespace-only handling. Ensure values such as " http://x "
match lookups and whitespace-padded placeholders resolve to null.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: 247fb1f8-12c5-44e3-863d-58b8cb5058a2
📒 Files selected for processing (6)
DotNet/ServiceTests/UnitTests/Terminology/Controllers/FhirControllerHttpTests.csDotNet/ServiceTests/UnitTests/Terminology/Controllers/FhirControllerTests.csDotNet/ServiceTests/UnitTests/Terminology/Services/FhirServiceTests.csDotNet/Terminology/Application/Formatters/PreserveEmptyStringMetadataProvider.csDotNet/Terminology/Program.csDotNet/Terminology/Services/FhirService.cs
CodeRabbit finding on PR #1794. NormalizeSystem returned the value verbatim, but the lookup in ValidateCodeInSystem is an exact dictionary match, so a whitespace-padded " http://x " answered "Code system not found in ValueSet" for a system that is present -- the same confidently-wrong answer to a malformed request that this method exists to prevent. A padded placeholder likewise failed to resolve to null. Reproduced against the running service before changing anything: "?system=%20<sys>%20", a padded coding.system in the body, and "?system=%20null%20" all returned result=false with "Code system not found in ValueSet". - Trims after the whitespace-only guard, so " " is still rejected with a 400 rather than trimmed to empty and slipping through as "not supplied". - The trimmed value is what gets placeholder-matched and returned, so " null " resolves to null and " http://x " matches the loaded code system. Padded values are well-formed FHIR rather than malformed input, so this is normalization and not the silent repair of a bad request that LEGLINK-888 otherwise argues against: the Firely validating deserializer accepts " http://hl7.org/fhir/address-type " without complaint. Not addressed here: "url", "code" and "display" share the same exact-match behavior -- "?url=%20<valueset>%20" returns "Value set not found". Widening the treatment to those changes behavior across the endpoint and belongs in its own ticket with QA coverage. Testing: 1188 unit tests pass, 3 of them new -- a padded system still matching its code system, and the placeholder theory extended with " null " and "\tundefined\t".
…yload text CodeRabbit finding on PR #1794. Both 200-response tests in FhirControllerHttpTests checked the raw payload with Assert.Contains rather than reading the result parameter. The placeholder test was the one that mattered. It asserted only that the body contained "result", never the value -- but "?system=null" being treated as an omitted parameter (search every system, code found, result=true) differs from it being looked up as a code system literally named "null" (result=false) in exactly that boolean. The assertion passed either way, so a test named TreatedAsAbsent was not testing that the value was treated as absent. That leniency is the one deliberately non-FHIR behaviour in this change and has no TestRail coverage, so this unit test was its only guard. - Adds AssertValidationResult beside AssertBadRequestDetail: parses the payload, checks it is a FHIR Parameters resource, locates the result parameter and asserts its valueBoolean. Reports a missing result parameter as a named failure rather than throwing on a null reference. - Both call sites now use it, replacing Assert.Contains("\"result\"", payload) and the Assert.Contains("true", payload) substring match over the whole body. Verified the new assertion can fail for the right reason: with the placeholder branch temporarily removed from NormalizeSystem, both PlaceholderSystem_TreatedAsAbsent cases fail where the previous assertions passed. Testing: 1188 unit tests pass, unchanged in count. Test-only change; no production code touched.
…ter that needs it Reviewer feedback on PR #1794. PreserveEmptyStringMetadataProvider turned ConvertEmptyStringToNull off for every bound model in the service, when only ValueSet/$validate-code's "system" needs the distinction between an omitted parameter and a blank one. - Adds PreserveEmptyStringAttribute, a parameter-only marker, and narrows the provider to parameters carrying it. Every other bound value in the service goes back to MVC's default behavior. - Marks FhirController.ValidateCodeInValueSet's "system" with it. The requirement is now visible on the parameter itself rather than in a provider the reader has to go and find, and it survives a rename that a reflection match on the parameter name would not. - Drops the provider's dependency on the Controllers namespace, so the Application layer no longer reaches into Presentation. ValidateCodeInCodeSystem takes no "system" parameter -- the code system's system is its url -- so ValueSet/$validate-code is the whole surface. Verified the attribute is load-bearing: removing it fails exactly one test, ValidateCodeInValueSet_BlankSystemQueryParameter_Returns400, and restoring it passes again. Testing: full ServiceTests suite passes -- 1761 passed, 1 skipped, 0 failed.
🛠️ Description of Changes
ValueSet/$validate-codetreated a blanksystemas if none had been supplied.string.IsNullOrEmptysent both""andnulldown the same branch, so a malformed request was answered by searching every code system in the value set and reporting200 result=false/result=true— an answer to a broader question than the caller asked, with no indication in the payload of which system matched.An absent
systemis legitimate FHIR and keeps its "search every system" meaning. An empty string is not valid for any FHIR primitive, so the request is now rejected rather than reinterpreted.NormalizeSystemand validates every client-supplied system up front inValidateCodeInValueSet: the query parameter, the bodysystemparameter,coding.system, and eachcodeableConcept.coding.system. Validating before the merges matters because they treat an empty string as "not supplied" and would overwrite a blank before anything could see it; doing it up front also keeps the verdict for a malformedcodeableConceptindependent of which coding happens to match first.string.IsNullOrEmptycheck inValidateCodeInCodeGroupis deliberately left alone. It is shared withValidateCodeInCodeSystem, which passes the code group's ownUrlinto that slot — cache content, not client input — so an empty one must keep falling back to a search rather than being blamed on the caller.Two things worth a reviewer's attention:
1. Deliberate non-FHIR leniency. The literal strings
"null"and"undefined"are treated as an omitted parameter, for clients that interpolate an unset variable into a request. This is not FHIR behaviour and no spec text will explain it, so it is pinned by tests and called out here. Agreed with QA on LEGLINK-888.2. A service-wide MVC change.
PreserveEmptyStringMetadataProviderturns off MVC'sConvertEmptyStringToNull. Without it,?system=arrives at the action asnulland is indistinguishable from an omitted parameter, so the validation above could never fire for the query-string form — validation cannot restore a distinction the binder has already erased.MvcOptionsexposes no setting for this andDisplayFormatAttributecannot target an action parameter, so a display-metadata provider is the supported route.Blast radius is contained: 8 string query parameters across the two Terminology controllers, all guarded by
IsNullOrEmpty/IsNullOrWhiteSpace, andConfigController'sversionalready normalises blank to null itself. Body binding is unaffected —FhirModelBinderdeserialises with System.Text.Json and never consults MVC metadata.🧪 Testing Performed
Unit tests: 1185 pass, 0 fail (
dotnet test DotNet/ServiceTests/ServiceTests.csproj --filter FullyQualifiedName~UnitTests). Terminology suite is 97, up from 77.Confirmed the metadata provider is load-bearing by removing it and re-running: exactly one test fails, the
?system=case. Without it that scenario silently returns 200.Verified against the local docker-compose stack, before and after rebuilding the Terminology image:
coding.systemblank in body200 result=true400—The 'coding.system' parameter cannot be blank.?system=on the query string200 result=true400—The 'system' parameter cannot be blank.systemblank in body200 result=true400—The 'system' parameter cannot be blank.codeableConcept200 result=true400—The 'codeableConcept.coding.system' parameter cannot be blank.system200 result=truesystem200 result=true?system=null200 result=false200 result=true(now searches all systems)Each 400 returns
application/problem+jsoncarryingtype,title,status,detailand a W3CtraceId.QA cases: TestRail 10992, 11015, 11342 and 11343 were updated by QA to match these responses, and all four have been exercised end to end from Postman against the DEV terminology service.
🧑🔬 Unit Testing
📓 Documentation Updated
No documentation changes are required by this PR:
app-config.yamlis unchanged.The
"null"/"undefined"leniency in item 1 above is behaviour the spec will not document. It is recorded on LEGLINK-888 so QA can cover it; there is currently no TestRail case for it.Summary by CodeRabbit
Bug Fixes
$validate-codehandling for blank, omitted,"null", and"undefined"system values.Tests