Class: Inferno::DSL::SuiteEndpoint

Inherits:
Hanami::Action
  • Object
show all
Defined in:
lib/inferno/dsl/suite_endpoint.rb

Overview

A base class for creating endpoints to test client requests. This class is based on Hanami::Action, and may be used similarly to a normal Hanami endpoint.

Examples:

class AuthorizedEndpoint < Inferno::DSL::SuiteEndpoint
  # Identify the incoming request based on a bearer token
  def test_run_identifier
    request.headers['authorization']&.delete_prefix('Bearer ')
  end

  error_response_format :operation_outcome

  # Return a json FHIR Patient resource
  def make_response
    response.status = 200
    response.body = FHIR::Patient.new(id: 'abcdef').to_json
    response.format = :json
  end

  # Update the waiting test to pass when the incoming request is received.
  # This will resume the test run.
  def update_result
    results_repo.update(result.id, result: 'pass')
  end

  # Apply the 'authorized' tag to the incoming request so that it may be
  # used by later tests.
  def tags
    ['authorized']
  end
end

class AuthorizedRequestSuite < Inferno::TestSuite
  id :authorized_suite
  suite_endpoint :get, '/authorized_endpoint', AuthorizedEndpoint

  group do
    title 'Authorized Request Group'

    test do
      title 'Wait for authorized request'

      input :bearer_token

      run do
        wait(
          identifier: bearer_token,
          message: "Waiting to receive a request with bearer_token: #{bearer_token}" \
                   "at `#{Inferno::Application['base_url']}/custom/authorized_suite/authorized_endpoint`"
        )
      end
    end
  end
end

Constant Summary collapse

ERROR_RESPONSE_FORMATS =

The built-in options for error_response_format

[:text, :operation_outcome].freeze

Instance Attribute Summary collapse

Overrides These methods should be overridden by subclasses to define the behavior of the endpoint collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(config: self.class.config) ⇒ SuiteEndpoint

Returns a new instance of SuiteEndpoint.



236
237
238
# File 'lib/inferno/dsl/suite_endpoint.rb', line 236

def initialize(config: self.class.config) # rubocop:disable Lint/MissingSuper
  @config = config
end

Instance Attribute Details

#reqObject (readonly)

Returns the value of attribute req.



65
66
67
# File 'lib/inferno/dsl/suite_endpoint.rb', line 65

def req
  @req
end

#resObject (readonly)

Returns the value of attribute res.



65
66
67
# File 'lib/inferno/dsl/suite_endpoint.rb', line 65

def res
  @res
end

Class Method Details

.callObject



211
212
213
# File 'lib/inferno/dsl/suite_endpoint.rb', line 211

def self.call(...)
  new.call(...)
end

.error_response_format(format) ⇒ void

This method returns an undefined value.

Select one of Inferno's standard response formats to be returned whenever Inferno has to render an error response of its own due to problems finding the target session or an unhandled exception. You can override #no_session_response to customize the response in the no-session case.

  • :text (default): a 500 response with a plain text message
  • :operation_outcome: a 500 response with a FHIR OperationOutcome serialized as application/fhir+json

Examples:

class MyEndpoint < Inferno::DSL::SuiteEndpoint
  error_response_format :operation_outcome
end

Parameters:

  • format (Symbol)

    :text or :operation_outcome



88
89
90
91
92
93
94
95
96
# File 'lib/inferno/dsl/suite_endpoint.rb', line 88

def error_response_format(format)
  unless ERROR_RESPONSE_FORMATS.include?(format)
    raise ArgumentError,
          "Unknown error_response_format `#{format.inspect}`. " \
          "Must be one of #{ERROR_RESPONSE_FORMATS.join(', ')}."
  end

  @error_response_format_value = format
end

.error_response_format_valueObject



99
100
101
# File 'lib/inferno/dsl/suite_endpoint.rb', line 99

def error_response_format_value
  @error_response_format_value ||= :text
end

Instance Method Details

#add_persistence_callbackObject



438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
# File 'lib/inferno/dsl/suite_endpoint.rb', line 438

def add_persistence_callback # rubocop:disable Metrics/CyclomaticComplexity
  env = req.env
  env['rack.after_reply'] ||= []
  env['rack.after_reply'] << proc do
    repo = Inferno::Repositories::Requests.new

    uri = URI('http://example.com')
    uri.scheme = env['rack.url_scheme']
    uri.host = env['SERVER_NAME']
    uri.port = env['SERVER_PORT']
    uri.path = env['REQUEST_PATH'] || ''
    uri.query = env['rack.request.query_string'] if env['rack.request.query_string'].present?
    url = uri&.to_s
    verb = env['REQUEST_METHOD']
    request_body = env['rack.input']
    request_body.rewind if env['rack.input'].respond_to? :rewind
    request_body = request_body.instance_of?(Puma::NullIO) ? nil : request_body.string

    request_headers = ::Rack::Request.new(env).headers.to_h.map { |name, value| { name:, value: } }

    status, response_headers, response_body = env['inferno.response']

    response_headers = response_headers.map { |name, value| { name:, value: } }

    repo.create(
      verb:,
      url:,
      direction: 'incoming',
      name: env['inferno.name'],
      status:,
      request_body:,
      response_body: response_body.join,
      result_id: env['inferno.result_id'],
      test_session_id: env['inferno.test_session_id'],
      request_headers:,
      response_headers:,
      tags: env['inferno.tags']
    )

    if env['inferno.resume_test_run']
      test_run_id = env['inferno.test_run_id']
      Inferno::Repositories::TestRuns.new.mark_as_no_longer_waiting(test_run_id)

      Inferno::Jobs.perform(
        Jobs::ResumeTestRun,
        test_run_id,
        tags: [
          'source:suite_endpoint',
          "session:#{env['inferno.test_session_id']}",
          "run:#{env['inferno.run_identifier']}",
          "test:#{env['inferno.waiting_test_id']}"
        ]
      )
    end
  rescue StandardError => e
    log_error(e, url:)
  end
end

#error_response(message, code:, diagnostics: nil) ⇒ Object

message is a short, human-readable summary (goes in the OperationOutcome issue's details.text, or stands alone as the whole plain text body). diagnostics, if given, is technical detail — e.g. an exception's full backtrace — that goes in the issue's diagnostics element, or is appended to the plain text body.



349
350
351
352
353
354
355
356
# File 'lib/inferno/dsl/suite_endpoint.rb', line 349

def error_response(message, code:, diagnostics: nil)
  case self.class.error_response_format_value
  when :operation_outcome
    operation_outcome_error_response(message, code:, diagnostics:)
  else
    text_error_response(message, diagnostics:)
  end
end

#find_resultObject



379
380
381
# File 'lib/inferno/dsl/suite_endpoint.rb', line 379

def find_result
  results_repo.find_waiting_result(test_run_id: test_run.id)
end

#find_test_run_identifierObject



303
304
305
306
307
308
309
310
311
312
313
314
315
316
# File 'lib/inferno/dsl/suite_endpoint.rb', line 303

def find_test_run_identifier
  return @test_run_identifier if defined?(@test_run_identifier) # handle memoization in the nil case

  @test_run_identifier = test_run_identifier
rescue StandardError => e
  log_error(e)
  render_error_and_halt do
    error_response(
      'An error occurred while determining the test run identifier for this request.',
      code: 'exception',
      diagnostics: e.full_message
    )
  end
end

#handle(req, res) ⇒ Object



414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
# File 'lib/inferno/dsl/suite_endpoint.rb', line 414

def handle(req, res)
  @req = req
  @res = res
  test_run

  persist_request if persist_request?

  update_result

  resume if resume_test_run?

  make_response
rescue StandardError => e
  log_error(e)
  render_error_and_halt do
    error_response(
      'An error occurred while processing this request.',
      code: 'exception',
      diagnostics: e.full_message
    )
  end
end

#log_error(error, url: request.url) ⇒ Object



297
298
299
300
# File 'lib/inferno/dsl/suite_endpoint.rb', line 297

def log_error(error, url: request.url)
  session_prefix = @test_run ? " session=#{@test_run.test_session_id}" : ''
  logger.error("[#{url}]#{session_prefix} #{error.full_message}")
end

#loggerLogger

Returns Inferno's logger.

Returns:

  • (Logger)

    Inferno's logger



292
293
294
# File 'lib/inferno/dsl/suite_endpoint.rb', line 292

def logger
  @logger ||= Application['logger']
end

#make_responseVoid

Override this method to build the response.

Examples:

def make_response
  response.status = 200
  response.body = { abc: 123 }.to_json
  response.format = :json
end

Returns:

  • (Void)


148
149
150
# File 'lib/inferno/dsl/suite_endpoint.rb', line 148

def make_response
  nil
end

#nameString

Override this method to assign a name to the request

Returns:

  • (String)


163
164
165
# File 'lib/inferno/dsl/suite_endpoint.rb', line 163

def name
  result&.runnable&.incoming_request_name
end

#no_session_messageObject



319
320
321
322
323
324
325
326
327
328
329
330
331
# File 'lib/inferno/dsl/suite_endpoint.rb', line 319

def no_session_message
  base_message = "Unable to find test run for request to '#{request.url}'"
  location = test_run_identifier_location_description
  identifier = find_test_run_identifier

  if identifier.blank?
    detail = location.present? ? " in #{location}" : ''
    "#{base_message}: no identifier found#{detail}."
  else
    detail = location.present? ? ", found in #{location}," : ''
    "#{base_message}: identifier '#{identifier}'#{detail} is not associated with a waiting session."
  end
end

#no_session_responseVoid

Override this method to fully customize the response returned when no waiting test run/session can be found for the incoming request. Set response.status and response.body (and response.content_type, if needed) — Inferno halts the request with those values. By default, this renders one of Inferno's standard responses based on the format selected with error_response_format (a plain text 500 response if none was selected).

Examples:

def no_session_response
  response.status = 404
  response.format = :json
  response.body = { error: 'no matching session' }.to_json
end

Returns:

  • (Void)


204
205
206
# File 'lib/inferno/dsl/suite_endpoint.rb', line 204

def no_session_response
  error_response(no_session_message, code: 'not-found')
end

#operation_outcome_error_response(message, code:, diagnostics: nil) ⇒ Object



365
366
367
368
369
370
371
372
373
374
375
376
# File 'lib/inferno/dsl/suite_endpoint.rb', line 365

def operation_outcome_error_response(message, code:, diagnostics: nil)
  issue = FHIR::OperationOutcome::Issue.new(
    severity: 'fatal',
    code:,
    details: FHIR::CodeableConcept.new(text: message)
  )
  issue.diagnostics = diagnostics if diagnostics

  response.status = 500
  response.content_type = 'application/fhir+json'
  response.body = FHIR::OperationOutcome.new(issue: [issue]).to_json
end

#persist_requestObject

The actual persisting happens in Inferno::Utils::Middleware::RequestRecorder, which allows the response to include response headers added by other parts of the rack stack rather than only the response headers explicitly added in the endpoint.



388
389
390
391
392
393
394
395
# File 'lib/inferno/dsl/suite_endpoint.rb', line 388

def persist_request
  req.env['inferno.test_session_id'] = test_run.test_session_id
  req.env['inferno.result_id'] = result.id
  req.env['inferno.tags'] = tags
  req.env['inferno.name'] = name if name.present?

  add_persistence_callback
end

#persist_request?Boolean

Override this method to specify whether this request should be persisted. Defaults to true.

Returns:

  • (Boolean)


184
185
186
# File 'lib/inferno/dsl/suite_endpoint.rb', line 184

def persist_request?
  true
end

#render_error_and_haltObject

Yields to build the response, then halts with whatever ended up in response.status/response.body. Centralizing the halt here means overrides of the response-building hooks (e.g. #no_session_response) never need to remember to call halt themselves.



338
339
340
341
# File 'lib/inferno/dsl/suite_endpoint.rb', line 338

def render_error_and_halt
  yield
  halt response.status, response.body.join
end

#requestHanami::Action::Request

The incoming request as a Hanami::Action::Request

Examples:

request.params               # Get url/query params
request.body.read            # Get body
request.headers['accept']    # Get Accept header

Returns:

  • (Hanami::Action::Request)


248
249
250
# File 'lib/inferno/dsl/suite_endpoint.rb', line 248

def request
  req
end

#requests_repoInferno::Repositories::Requests



216
217
218
# File 'lib/inferno/dsl/suite_endpoint.rb', line 216

def requests_repo
  @requests_repo ||= Inferno::Repositories::Requests.new
end

#responseHanami::Action::Response

The response as a Hanami::Action::Response. Modify this to build the response to the incoming request.

Examples:

response.status = 200        # Set the status
response.body = 'Ok'         # Set the body
# Set headers
response.headers.merge!('X-Custom-Header' => 'CUSTOM_HEADER_VALUE')

Returns:

  • (Hanami::Action::Response)


262
263
264
# File 'lib/inferno/dsl/suite_endpoint.rb', line 262

def response
  res
end

#resultInferno::Entities::Result

The result which is waiting for incoming requests for the current test run



280
281
282
# File 'lib/inferno/dsl/suite_endpoint.rb', line 280

def result
  @result ||= find_result
end

#results_repoInferno::Repositories::Results



221
222
223
# File 'lib/inferno/dsl/suite_endpoint.rb', line 221

def results_repo
  @results_repo ||= Inferno::Repositories::Results.new
end

#resumeObject

Inferno::Utils::Middleware::RequestRecorder actually resumes the TestRun. If it were resumed here, it would be resuming prior to the Request being persisted.



406
407
408
409
410
411
# File 'lib/inferno/dsl/suite_endpoint.rb', line 406

def resume
  req.env['inferno.resume_test_run'] = true
  req.env['inferno.test_run_id'] = test_run.id
  req.env['inferno.run_identifier'] = test_run.test_suite_id || test_run.test_group_id || test_run.test_id
  req.env['inferno.waiting_test_id'] = test.id
end

#resume_test_run?Boolean

Returns:

  • (Boolean)


398
399
400
# File 'lib/inferno/dsl/suite_endpoint.rb', line 398

def resume_test_run?
  find_result&.result != 'wait'
end

#tagsArray<String>

Override this method to define the tags which will be applied to the request.

Returns:

  • (Array<String>)


156
157
158
# File 'lib/inferno/dsl/suite_endpoint.rb', line 156

def tags
  @tags ||= []
end

#testInferno::Entities::Test

The test which is currently waiting for incoming requests



287
288
289
# File 'lib/inferno/dsl/suite_endpoint.rb', line 287

def test
  @test ||= tests_repo.find(result.test_id)
end

#test_runInferno::Entities::TestRun

The test run which is waiting for incoming requests



269
270
271
272
273
274
# File 'lib/inferno/dsl/suite_endpoint.rb', line 269

def test_run
  @test_run ||=
    test_runs_repo.find_latest_waiting_by_identifier(find_test_run_identifier).tap do |test_run|
      render_error_and_halt { no_session_response } if test_run.nil?
    end
end

#test_run_identifierString

Override this method to determine a test run's identifier based on an incoming request.

Examples:

def test_run_identifier
  # Identify the test session of an incoming request based on the bearer
  # token
  request.headers['authorization']&.delete_prefix('Bearer ')
end

Returns:

  • (String)


118
119
120
# File 'lib/inferno/dsl/suite_endpoint.rb', line 118

def test_run_identifier
  nil
end

#test_run_identifier_location_descriptionString

Override this method to provide a short narrative description of where the test run identifier is expected to be found in an incoming request. When provided, this description is appended to the #no_session_message to help implementers debug requests that don't match a waiting test run.

Examples:

def test_run_identifier_location_description
  "the 'code' query parameter"
end

Returns:

  • (String)


134
135
136
# File 'lib/inferno/dsl/suite_endpoint.rb', line 134

def test_run_identifier_location_description
  ''
end

#test_runs_repoInferno::Repositories::TestRuns



226
227
228
# File 'lib/inferno/dsl/suite_endpoint.rb', line 226

def test_runs_repo
  @test_runs_repo ||= Inferno::Repositories::TestRuns.new
end

#tests_repoInferno::Repositories::Tests



231
232
233
# File 'lib/inferno/dsl/suite_endpoint.rb', line 231

def tests_repo
  @tests_repo ||= Inferno::Repositories::Tests.new
end

#text_error_response(message, diagnostics: nil) ⇒ Object



359
360
361
362
# File 'lib/inferno/dsl/suite_endpoint.rb', line 359

def text_error_response(message, diagnostics: nil)
  response.status = 500
  response.body = diagnostics ? "#{message}\n#{diagnostics}" : message
end

#update_resultVoid

Override this method to update the current waiting result. To resume the test run, set the result to something other than 'waiting'.

Examples:

def update_result
  results_repo.update(result.id, result: 'pass')
end

Returns:

  • (Void)


176
177
178
# File 'lib/inferno/dsl/suite_endpoint.rb', line 176

def update_result
  nil
end