ID-146 Escaping search parameters rework - #296
Conversation
|
I think there are some fixes made in this PR that should address the failing 610 tests. |
karlnaden
left a comment
There was a problem hiding this comment.
A few things to clean up
| def escape_search_value(value) | ||
| value&.gsub(FHIRSearchEscaping::SPECIAL_CHARACTERS) { |character| "\\#{character}" } | ||
| end | ||
|
|
||
| def unescape_search_value(value) | ||
| value&.gsub(/\\(.)/m) { Regexp.last_match(1) } | ||
| end | ||
|
|
||
| # Build a token search value, escaping the system and code so that any | ||
| # special characters they contain are not mistaken for the unescaped `|` | ||
| # that separates the system from the code. | ||
| def token_search_value(system, code, include_system) | ||
| return escape_search_value(code) unless include_system | ||
|
|
||
| "#{escape_search_value(system)}|#{escape_search_value(code)}" | ||
| end | ||
|
|
||
| def parse_escaped_token(escaped_search_value) | ||
| system, code = escaped_search_value.split(FHIRSearchEscaping::UNESCAPED_PIPE, 2) | ||
| [unescape_search_value(system), unescape_search_value(code)] | ||
| end | ||
|
|
||
| # Split an escaped search value on the unescaped occurrences of `delimiter`, | ||
| # then unescape each of the resulting values. | ||
| def split_escaped_search_value(escaped_search_value, delimiter) |
There was a problem hiding this comment.
I think these methods specific to escaping should go into the new FHIRSearchEscaping module instead of here.
| # then unescape each of the resulting values. | ||
| def split_escaped_search_value(escaped_search_value, delimiter) | ||
| escaped_search_value | ||
| .split(/#{FHIRSearchEscaping::UNESCAPED}#{Regexp.escape(delimiter)}/) |
There was a problem hiding this comment.
you could cache the Regexp.escape(delimiter) value so that repeated calls don't require building a new object each time.
| # they must be escaped by prepending a backslash. | ||
| SPECIAL_CHARACTERS = /[\\|$,]/.freeze | ||
|
|
||
| UNESCAPED = /(?<!(?<!\\)\\)/.freeze |
There was a problem hiding this comment.
Current approach only handles 0, 1, or 2 backslashes. Update to handle arbitrary numbers of them:
| UNESCAPED = /(?<!(?<!\\)\\)/.freeze | |
| # Handle arbitrary number of backslashes: odd number is escaped, even is unescaped | |
| # Use the "consume complete backslash pairs, then check the delimiter" idiom, | |
| # using \K to discard the consumed pairs from the match | |
| UNESCAPED = /(?<!\\)(?:\\\\)*\K/.freeze |
| if escaped_search_value&.match?(FHIRSearchEscaping::UNESCAPED_PIPE) | ||
| system, value = parse_escaped_token(escaped_search_value) | ||
| values_found.any? { |identifier| identifier.system == system && identifier.value == value } |
There was a problem hiding this comment.
maintain the interpolated approach to continue previous behavior. Also add a spec test that verifies behavior for a search string like |id1234.
| if escaped_search_value&.match?(FHIRSearchEscaping::UNESCAPED_PIPE) | |
| system, value = parse_escaped_token(escaped_search_value) | |
| values_found.any? { |identifier| identifier.system == system && identifier.value == value } | |
| if escaped_search_value&.match?(FHIRSearchEscaping::UNESCAPED_PIPE) | |
| system, value = parse_escaped_token(escaped_search_value) | |
| values_found.any? { |identifier| "#{identifier.system}|#{identifier.value}" == "#{system}|#{value}" } |
| @@ -633,17 +633,17 @@ def search_param_value(name, resource, include_system: false) | |||
| element.reference | |||
There was a problem hiding this comment.
I think this should be escaped. I don't think that valid references will actually contain characters to escape, but if they do, they need to be escaped. So better to be safe. Additionally, prior to this change, at least commas were escaped by the catch-all at the end of the method.
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: