ID-146 Escaping search parameters rework#296
Conversation
|
|
||
| # A `|` which separates the system from the code in a token search value, | ||
| # as opposed to one escaped as `\|` because it is part of a value. | ||
| UNESCAPED_PIPE = /(?<!\\)\|/ |
There was a problem hiding this comment.
This is largely solid, but it does leave room for some edge cases where \ appears multiple times in a row.
For example, "foo\\|bar" should split into ["foo\\", "bar"] but instead splits into the single value "foo\|bar". To fix this, replace the current regex with UNESCAPED_PIPE = /(?<!(?<!\\)\\)\|/
Please add a test case to validate this. Claude recommends testing this command: resource_matches_param?(patient, 'identifier', 'sys|val\\\\|rest')
| # then unescape each of the resulting values. | ||
| def split_escaped_search_value(escaped_search_value, delimiter) | ||
| escaped_search_value | ||
| .split(/(?<!\\)#{Regexp.escape(delimiter)}/) |
There was a problem hiding this comment.
This has the same regex bug as mentioned in UNESCAPED_PIPE. I think this is a good opportunity to consolidate these two regex strings because they are so similar.
# The lookbehind portion shared by both patterns
UNESCAPED = /(?<!(?<!\\)\\)/ # matches a position not preceded by an odd run of backslashes
# Reuse it in the two derived constants
UNESCAPED_PIPE = /#{UNESCAPED}\|/
def split_escaped_search_value(escaped_search_value, delimiter)
escaped_search_value
.split(/#{UNESCAPED}#{Regexp.escape(delimiter)}/)
.map { |value| unescape_search_value(value) }
end
| .map { |value| unescape_search_value(value) } | ||
| end | ||
|
|
||
| def resource_matches_param?(resource, search_param_name, escaped_search_value, values_found = []) |
There was a problem hiding this comment.
This function is almost completely duplicated in search_test.rb, and you even had to make the exact same changes in both versions of the script. We should consolidate this function to just be written once. This is the parent class so this is the place the functon should come from. However, there is one or two things the search_test.rb function does that this version does not. When removing the search_test version, you will need to be sure to migrate the enhancements in that version of the function to this class. I have had an agent spin up details, please review the findings and run your own evaluation as well to confirm we make this change without any regression: https://github.com/inferno-framework/us-core-test-kit/tasks/ba448ab9-e144-4e0d-9fd7-ab488c075d9c
| # search parameter value, so when they appear as part of an actual value | ||
| # they must be escaped by prepending a backslash. | ||
| # https://hl7.org/fhir/R4/search.html#escaping | ||
| FHIR_SEARCH_SPECIAL_CHARACTERS = /[\\|$,]/ |
There was a problem hiding this comment.
A note on the placement of this constant and all the others you added:
The established pattern: constants are always defined inside a class or a named module with a namespace, and almost always with .freeze. For example, see the definition of constants within well_known_code_systems.rb
There was a problem hiding this comment.
The issue with the PR's approach: FHIR_SEARCH_SPECIAL_CHARACTERS and UNESCAPED_PIPE are defined directly inside module ResourceSearchParamChecker — which is a module, so it follows the general pattern at first glance. However, the distinction is that ResourceSearchParamChecker is a mixin module (included into multiple classes via include), not a standalone namespace module like WellKnownCodeSystems or SpecialCases. When you define a constant inside a mixin module, it belongs to the module namespace (ResourceSearchParamChecker::FHIR_SEARCH_SPECIAL_CHARACTERS), not to the including class — which is fine technically, but the codebase's other regex-like or shared constants that are meant to be used by a mixin are typically either inlined at point-of-use or extracted into a dedicated namespace module.
| end | ||
| when FHIR::Reference | ||
| element.reference | ||
| escape_search_value(element.reference) |
There was a problem hiding this comment.
References cannot have these escape characters, so adding the function here is unnecessary
Summary
Fixes an issue where previously only escaped commas were searched after all token pieces have already been joined. FHIR requires escaping four characters:
\ | $ ,The pipe
|is a special character because it's also the literal separator between a token's system and code pieces, so escaping after joining these pieces together in the end destroys that distinction and you would no longer be able to tell a real separator from a | that was actually part of the data.Changes made:
resource_search_param_checker.rb:
\-prepend and/-strip helper methods that are applied and executed per-string (code and system separately) rather than at the end when they're already joined.|. This is what lets a literal pipe inside a code(co|de)survive as | without being confused with the real separator.Applied on the write side in search_param_value in search_test.rb and the read side in both copies of resource_matches_param?
Testing Guidance
You can start a fhir server to hold test data with:
docker run -d --rm --name hapi-fhir -p 8080:8080 hapiproject/hapi:latestThen you can seed a patient with escapable characters - Here is an example:
Start inferno:
bundle exec inferno services startbundle exec inferno startIn your local host browser, pick a US core server and for the example provided in step 2, run the patient tests group.
Your fhir endpoint will be:
http://localhost:8080/fhirYour patient ID will be:
escape-demoGo to group 2.2.03 (Patient search by name), click on the requests tab, then click details to view the response body.
You should see that a backslash
%5Cis prepended to the pipe%7CandPatient?name=Smith%5C%7CJrdecodes toname=Smith|Jr. It correctly read\|as a literal pipe rather than the actual separator.As a plus, with the same example if you go to the 2.2.02 group (patient search by identifier), you will see that the URL sent consists of
Patient?identifier=abc%5C%24123, which is decoded toidentifier=abc$123.%5C = \and%24 = $.Full response body from that example test: