Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/workflows/ruby.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ on:

jobs:
build:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
strategy:
matrix:
ruby-version: ['3.3.6']
Expand All @@ -23,7 +23,7 @@ jobs:

test:
needs: build
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
strategy:
matrix:
ruby-version: ['3.3.6']
Expand All @@ -39,7 +39,7 @@ jobs:

lint:
needs: build
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
strategy:
matrix:
ruby-version: ['3.3.6']
Expand All @@ -55,7 +55,7 @@ jobs:

requirements:
needs: build
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
strategy:
matrix:
ruby-version: ['3.3.6']
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/run_inferno_execution_scripts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ on:
jobs:

find-execution-scripts:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
outputs:
scripts: ${{ steps.list.outputs.scripts }}
steps:
Expand All @@ -24,7 +24,7 @@ jobs:

test:
needs: find-execution-scripts
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
strategy:
fail-fast: false # let all files run even if one fails
matrix:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ responses, to send and respond with as directed by the IG. Inferno will verify t
provided event notification is conformant and that it matches the requirements for the
Subscription sent by the client system.

Inferno responds to the `$status` operation via GET or POST, both on the Subscription
instance (`Subscription/[id]/$status`) and on the type (`Subscription/$status`). As the
IG specifies, the `id` and `status` inputs are ignored on the instance. On the type,
Inferno returns the status of the Subscription created by the client system if at least
one value of each input given matches it, and an empty Bundle otherwise. Inputs sent in a
`Parameters` body must use `valueId` and `valueCode`.

The content of all Subscription interactions sent by the client,
as well as tester-provided notifications, will be checked
for conformance to the Subscriptions IG requirements individually and used in
Expand Down
123 changes: 94 additions & 29 deletions lib/subscriptions_test_kit/endpoints/subscription_status_endpoint.rb
Comment thread
ljtucker marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@
require_relative '../suites/subscriptions_r5_backport_r4_client/common/subscription_simulation_utils'

module SubscriptionsTestKit
# Handles $status invoked on a Subscription instance (`Subscription/[id]/$status`) or on the type
# (`Subscription/$status`). The `id` and `status` inputs are ignored at the instance level.
class SubscriptionStatusEndpoint < Inferno::DSL::SuiteEndpoint
include SubscriptionsR5BackportR4Client::SubscriptionSimulationUtils

INPUT_VALUE_TYPES = { 'id' => 'valueId', 'status' => 'valueCode' }.freeze

error_response_format :operation_outcome

def test_run_identifier
Expand All @@ -13,55 +17,116 @@ def test_run_identifier

def make_response
response.format = :json
subscription = find_current_subscription(test_run.test_session_id)
type_level? ? make_type_response : make_instance_response
end

subscription_id = request.params[:id]
if subscription.blank? || (subscription_id.present? && subscription_id != subscription.id)
def make_instance_response
subscription = find_current_subscription(test_run.test_session_id)
if subscription.blank? || request.params[:id] != subscription.id
fhir_error_response(404, 'not-found')
return
end

status_code = current_subscription_status_code(subscription)

# Handle resource-level status params
unless subscription_id.present?
begin
params = FHIR.from_contents(request.body.string)
rescue StandardError
fhir_error_response(400, 'invalid', 'Invalid Parameters in request body')
return
end

unless subscription_params_match?(params, subscription, status_code)
fhir_error_response(404, 'not-found')
return
end
respond_with_status(subscription)
end

# Search semantics: no matching Subscription yields an empty searchset Bundle
def make_type_response
inputs = request.get? ? query_inputs : body_inputs
return if inputs.nil?

subscription = find_current_subscription(test_run.test_session_id)
if subscription.present? && inputs_match?(inputs, subscription)
respond_with_status(subscription)
return
end

response.status = 200
response.body = empty_status_bundle(request.url).to_json
end

# Repeated values of an input are joined via OR
def inputs_match?(inputs, subscription)
(inputs['id'].empty? || inputs['id'].include?(subscription.id)) &&
(inputs['status'].empty? || inputs['status'].include?(current_subscription_status_code(subscription)))
end

def respond_with_status(subscription)
notification_json = notification_bundle_input(result)
subscription_url = "#{base_subscription_url}/#{subscription.id}"
subscription_topic = subscription.criteria
event_count = determine_event_count(test_run.test_session_id)
response.status = 200
response.body = derive_status_bundle(notification_json, subscription_url, subscription_topic, status_code,
event_count, request.url).to_json
response.body = derive_status_bundle(notification_json, subscription_url, subscription_topic,
current_subscription_status_code(subscription), event_count,
request.url).to_json
end

def subscription_params_match?(params, subscription, status_code)
id_params = find_params(params, 'id')
def type_level?
request.path.end_with?(FHIR_SUBSCRIPTION_RESOURCE_STATUS_PATH)
end

# request.params keeps only the last value of a repeated query parameter.
# Renders an error response and returns nil if any input is given without a value.
def query_inputs
query = ::Rack::Utils.parse_query(request.query_string)
inputs = INPUT_VALUE_TYPES.keys.to_h { |name| [name, query.key?(name) ? [query[name]].flatten : []] }

return false if id_params&.any? && id_params&.none? { |p| p.valueString == subscription.id }
blank_inputs = inputs.select { |_name, values| values.any?(&:blank?) }.keys
return inputs if blank_inputs.empty?

status_params = find_params(params, 'status')
status_params.blank? || status_params.any? { |p| p.valueString == status_code }
fhir_error_response(400, 'invalid', blank_inputs.map do |name|
"The `#{name}` query parameter must have a value."
end.join(' '))
nil
end

def tags
[SUBSCRIPTION_STATUS_TAG]
# Renders an error response and returns nil if the body is not a Parameters resource with correctly typed inputs
def body_inputs
params = parse_parameters_body
return if params.nil?

type_errors = input_type_errors(params)
if type_errors.any?
fhir_error_response(400, 'invalid', type_errors.join(' '))
return
end

INPUT_VALUE_TYPES.to_h do |name, value_type|
[name, find_all_parameters(params, name).map { |p| p.public_send(value_type) }]
end
end

def input_type_errors(params)
INPUT_VALUE_TYPES.filter_map do |name, value_type|
next if find_all_parameters(params, name).all? { |p| valid_input_value?(p, value_type) }

"The `#{name}` parameter must have a non-empty `#{value_type}` and no other value."
end
end

def find_params(params, name)
params&.parameter&.filter { |p| p.name == name }
def valid_input_value?(param, value_type)
value = param.public_send(value_type)
value.is_a?(String) && value.present? &&
param.to_hash.keys.grep(/\Avalue[A-Z]/) == [value_type]
end

def parse_parameters_body
return FHIR::Parameters.new if request.body.string.blank?

params = begin
FHIR.from_contents(request.body.string)
rescue StandardError
nil
end
return params if params.is_a?(FHIR::Parameters)

fhir_error_response(400, 'invalid', 'Invalid Parameters in request body')
nil
end

def tags
[SUBSCRIPTION_STATUS_TAG]
end

def base_subscription_url
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,15 @@ def derive_status_bundle(notification_json, subscription_url, subscription_topic
)
end

def empty_status_bundle(request_url)
FHIR::Bundle.new(
link: FHIR::Bundle::Link.new(relation: 'self', url: request_url),
total: 0,
type: 'searchset',
timestamp: Time.now.utc.iso8601
)
end

def operation_outcome(severity, code, text = nil)
oo = FHIR::OperationOutcome.new(issue: FHIR::OperationOutcome::Issue.new(severity:, code:))
oo.issue.first.details = FHIR::CodeableConcept.new(text:) if text.present?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ class SubscriptionsR5BackportR4ClientSuite < Inferno::TestSuite
suite_endpoint :post, FHIR_SUBSCRIPTION_INSTANCE_STATUS_PATH, SubscriptionStatusEndpoint
suite_endpoint :get, FHIR_SUBSCRIPTION_INSTANCE_STATUS_PATH, SubscriptionStatusEndpoint
suite_endpoint :post, FHIR_SUBSCRIPTION_RESOURCE_STATUS_PATH, SubscriptionStatusEndpoint
suite_endpoint :get, FHIR_SUBSCRIPTION_RESOURCE_STATUS_PATH, SubscriptionStatusEndpoint

resume_test_route :get, RESUME_PASS_PATH do |request|
request.query_parameters['test_run_identifier']
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
[:patch, '/123'],
[:delete, '/123'],
[:get, '/123/$status'],
[:get, '/$status'],
[:post, '/123/$status'],
[:post, '/$status']
].each do |verb, path|
Expand Down
Loading
Loading