diff --git a/.rubocop.yml b/.rubocop.yml index 50c8cd944..7c88f8b36 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -11,6 +11,7 @@ AllCops: - 'vendor/**/*' - 'lib/inferno/db/schema.rb' - 'tmp/**/*' + - 'lib/inferno/ext/rack.rb' Layout/LineLength: Max: 120 diff --git a/Gemfile.lock b/Gemfile.lock index 57b4b4840..129b2684d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -6,16 +6,16 @@ PATH base62-rb (= 0.3.1) blueprinter (= 0.25.2) dotenv (~> 2.7) - dry-configurable (= 0.13.0) - dry-container (= 0.9.0) - dry-core (= 0.8.1) - dry-inflector (= 0.3) - dry-system (= 0.20.0) + dry-configurable (= 1.0.0) + dry-container (= 0.10.0) + dry-core (= 1.0.0) + dry-inflector (= 1.0.0) + dry-system (= 1.0.0) faraday (~> 1.2) faraday_middleware (~> 1.2) fhir_client (>= 5.0.3) fhir_models (>= 4.2.2) - hanami-controller (= 2.0.0.beta1) + hanami-controller (= 2.0.0) hanami-router (= 2.0.0) oj (= 3.11.0) pry @@ -67,40 +67,25 @@ GEM docile (1.4.0) domain_name (0.6.20240107) dotenv (2.8.1) - dry-auto_inject (0.9.0) - dry-container (>= 0.3.4) - dry-configurable (0.13.0) - concurrent-ruby (~> 1.0) - dry-core (~> 0.6) - dry-container (0.9.0) - concurrent-ruby (~> 1.0) - dry-configurable (~> 0.13, >= 0.13.0) - dry-core (0.8.1) - concurrent-ruby (~> 1.0) - dry-inflector (0.3.0) - dry-logic (1.2.0) + dry-auto_inject (1.0.1) + dry-core (~> 1.0) + zeitwerk (~> 2.6) + dry-configurable (1.0.0) + dry-core (~> 1.0, < 2) + zeitwerk (~> 2.6) + dry-container (0.10.0) concurrent-ruby (~> 1.0) - dry-core (~> 0.5, >= 0.5) - dry-struct (1.4.0) - dry-core (~> 0.5, >= 0.5) - dry-types (~> 1.5) - ice_nine (~> 0.11) - dry-system (0.20.0) + dry-core (1.0.0) concurrent-ruby (~> 1.0) - dry-auto_inject (>= 0.4.0) - dry-configurable (~> 0.13, >= 0.13.0) - dry-container (~> 0.9, >= 0.9.0) - dry-core (~> 0.5, >= 0.5) - dry-inflector (~> 0.1, >= 0.1.2) - dry-struct (~> 1.0) + zeitwerk (~> 2.6) + dry-inflector (1.0.0) + dry-system (1.0.0) + dry-auto_inject (~> 1.0.0.rc1, < 2) + dry-configurable (~> 1.0, < 2) + dry-core (~> 1.0, < 2) + dry-inflector (~> 1.0, < 2) dry-transformer (1.0.1) zeitwerk (~> 2.6) - dry-types (1.5.1) - concurrent-ruby (~> 1.0) - dry-container (~> 0.3) - dry-core (~> 0.5, >= 0.5) - dry-inflector (~> 0.1, >= 0.1.2) - dry-logic (~> 1.0, >= 1.0.2) factory_bot (6.2.0) activesupport (>= 5.0.0) faraday (1.10.3) @@ -154,10 +139,12 @@ GEM date_time_precision (>= 0.8) mime-types (>= 3.0) nokogiri (>= 1.11.4) - hanami-controller (2.0.0.beta1) - dry-configurable (~> 0.13, >= 0.13.0) - hanami-utils (~> 2.0.beta) + hanami-controller (2.0.0) + dry-configurable (~> 1.0, < 2) + dry-core (~> 1.0) + hanami-utils (~> 2.0) rack (~> 2.0) + zeitwerk (~> 2.6) hanami-router (2.0.0) mustermann (~> 1.0) mustermann-contrib (~> 1.0) @@ -172,7 +159,6 @@ GEM domain_name (~> 0.5) i18n (1.12.0) concurrent-ruby (~> 1.0) - ice_nine (0.11.2) io-console (0.6.0) irb (1.6.2) reline (>= 0.3.0) diff --git a/dev_suites/dev_demo_ig_stu1/demo_endpoint.rb b/dev_suites/dev_demo_ig_stu1/demo_endpoint.rb new file mode 100644 index 000000000..77bc0ec42 --- /dev/null +++ b/dev_suites/dev_demo_ig_stu1/demo_endpoint.rb @@ -0,0 +1,25 @@ +module DemoIG_STU1 # rubocop:disable Naming/ClassAndModuleCamelCase + class DemoEndpoint < Inferno::DSL::SuiteEndpoint + def test_run_identifier + request.headers['authorization']&.delete_prefix('Bearer ') + end + + def make_response + response.status = 200 + response.body = { abc: 123 }.to_json + response.format = :json + end + + def tags + ['abc', 'def'] + end + + def name + 'custom_request' + end + + def update_result + results_repo.update(result.id, result: 'pass') + end + end +end diff --git a/dev_suites/dev_demo_ig_stu1/demo_suite.rb b/dev_suites/dev_demo_ig_stu1/demo_suite.rb index 220d184c9..f56890599 100644 --- a/dev_suites/dev_demo_ig_stu1/demo_suite.rb +++ b/dev_suites/dev_demo_ig_stu1/demo_suite.rb @@ -1,4 +1,5 @@ # require 'onc_certification_g10_test_kit' +require_relative 'demo_endpoint' require_relative 'groups/demo_group' module DemoIG_STU1 # rubocop:disable Naming/ClassAndModuleCamelCase @@ -299,5 +300,47 @@ class DemoSuite < Inferno::TestSuite end end end + + group do + title 'Custom Suite Endpoints' + description %( + This group demonstrates custom suite endpoint functionality. + ) + + input :custom_bearer_token, + description: 'This bearer token will be used to identify the incoming request' + + suite_endpoint :post, '/suite_endpoint', DemoEndpoint + + test do + title 'Wait for request to suite endpoint' + + run do + wait( + identifier: custom_bearer_token, + message: "Waiting for a POST with bearer token: `#{custom_bearer_token}` to " \ + "`#{Inferno::Application['base_url']}/custom/demo/suite_endpoint`" + ) + end + end + + test do + title 'Named request from suite endpoint' + uses_request :custom_request + + run do + assert request.present?, 'Named request not found' + end + end + + test do + title 'Tagged request from suite endpoint' + + run do + load_tagged_requests('abc', 'def') + assert request.present?, 'Tagged request not found' + end + end + end end end diff --git a/inferno_core.gemspec b/inferno_core.gemspec index 06149615d..445f2c4a0 100644 --- a/inferno_core.gemspec +++ b/inferno_core.gemspec @@ -15,16 +15,16 @@ Gem::Specification.new do |spec| spec.add_runtime_dependency 'base62-rb', '0.3.1' spec.add_runtime_dependency 'blueprinter', '0.25.2' spec.add_runtime_dependency 'dotenv', '~> 2.7' - spec.add_runtime_dependency 'dry-configurable', '0.13.0' - spec.add_runtime_dependency 'dry-container', '0.9.0' - spec.add_runtime_dependency 'dry-core', '0.8.1' - spec.add_runtime_dependency 'dry-inflector', '0.3' - spec.add_runtime_dependency 'dry-system', '0.20.0' + spec.add_runtime_dependency 'dry-configurable', '1.0.0' + spec.add_runtime_dependency 'dry-container', '0.10.0' + spec.add_runtime_dependency 'dry-core', '1.0.0' + spec.add_runtime_dependency 'dry-inflector', '1.0.0' + spec.add_runtime_dependency 'dry-system', '1.0.0' spec.add_runtime_dependency 'faraday', '~> 1.2' spec.add_runtime_dependency 'faraday_middleware', '~> 1.2' spec.add_runtime_dependency 'fhir_client', '>= 5.0.3' spec.add_runtime_dependency 'fhir_models', '>= 4.2.2' - spec.add_runtime_dependency 'hanami-controller', '2.0.0.beta1' + spec.add_runtime_dependency 'hanami-controller', '2.0.0' spec.add_runtime_dependency 'hanami-router', '2.0.0' spec.add_runtime_dependency 'oj', '3.11.0' spec.add_runtime_dependency 'pry' diff --git a/lib/inferno/apps/web/controllers/controller.rb b/lib/inferno/apps/web/controllers/controller.rb index 466d31185..b606a9971 100644 --- a/lib/inferno/apps/web/controllers/controller.rb +++ b/lib/inferno/apps/web/controllers/controller.rb @@ -1,3 +1,5 @@ +require 'hanami/action/mime/request_mime_weight' + module Inferno module Web module Controllers @@ -11,12 +13,14 @@ def self.inherited(subclass) subclass.include Import[repo: "inferno.repositories.#{subclass.resource_name}"] - subclass.config.default_request_format = :json - subclass.config.default_response_format = :json - subclass.define_method(:serialize) do |*args| Inferno::Web::Serializers.const_get(self.class.resource_class).render(*args) end + + # Hanami Controller 2.0.0 removes the ability to set a default + # Content-Type response header, so set it manually if it hasn't been + # set. + subclass.after { |_req, res| res.format = :json if res.format == :all && res.body&.first&.first == '{' } end def self.resource_name diff --git a/lib/inferno/apps/web/controllers/test_sessions/client_show.rb b/lib/inferno/apps/web/controllers/test_sessions/client_show.rb index 2c0a82d36..de88dc8d2 100644 --- a/lib/inferno/apps/web/controllers/test_sessions/client_show.rb +++ b/lib/inferno/apps/web/controllers/test_sessions/client_show.rb @@ -3,8 +3,6 @@ module Web module Controllers module TestSessions class ClientShow < Controller - config.default_response_format = :html - CLIENT_PAGE = ERB.new( File.read( @@ -31,6 +29,7 @@ def handle(req, res) halt 404 if test_suite.nil? + res.format = :html res.body = CLIENT_PAGE end end diff --git a/lib/inferno/config/application.rb b/lib/inferno/config/application.rb index 1dc5b0c00..5c268cd86 100644 --- a/lib/inferno/config/application.rb +++ b/lib/inferno/config/application.rb @@ -1,6 +1,6 @@ require 'active_support/all' require 'dotenv' -require 'dry/system/container' +require 'dry/system' require 'sequel' require_relative 'boot' @@ -25,7 +25,7 @@ class Application < Dry::System::Container configure do |config| config.root = File.expand_path('../../..', __dir__) - config.bootable_dirs = [File.join('lib', 'inferno', 'config', 'boot')] + config.provider_dirs = [File.join('lib', 'inferno', 'config', 'boot')] config.component_dirs.add 'lib' end end diff --git a/lib/inferno/config/boot/db.rb b/lib/inferno/config/boot/db.rb index 66051710b..0537af122 100644 --- a/lib/inferno/config/boot/db.rb +++ b/lib/inferno/config/boot/db.rb @@ -1,9 +1,9 @@ require 'sequel' require 'erb' -Inferno::Application.boot(:db) do - init do - use :logging +Inferno::Application.register_provider(:db) do + prepare do + target_container.start :logging require 'yaml' diff --git a/lib/inferno/config/boot/logging.rb b/lib/inferno/config/boot/logging.rb index d7033f82f..840d65552 100644 --- a/lib/inferno/config/boot/logging.rb +++ b/lib/inferno/config/boot/logging.rb @@ -1,5 +1,5 @@ -Inferno::Application.boot(:logging) do - init do +Inferno::Application.register_provider(:logging) do + prepare do logger = if Inferno::Application.env == :test log_file_directory = File.join(Dir.pwd, 'tmp') diff --git a/lib/inferno/config/boot/presets.rb b/lib/inferno/config/boot/presets.rb index 45c379824..e68c5b593 100644 --- a/lib/inferno/config/boot/presets.rb +++ b/lib/inferno/config/boot/presets.rb @@ -1,8 +1,8 @@ require_relative '../../repositories/presets' -Inferno::Application.boot(:presets) do - init do - use :suites +Inferno::Application.register_provider(:presets) do + prepare do + target_container.start :suites files_to_load = Dir.glob(['config/presets/*.json', 'config/presets/*.json.erb']) files_to_load.map! { |path| File.realpath(path) } diff --git a/lib/inferno/config/boot/sidekiq.rb b/lib/inferno/config/boot/sidekiq.rb index 290cc71bc..7f2ae654b 100644 --- a/lib/inferno/config/boot/sidekiq.rb +++ b/lib/inferno/config/boot/sidekiq.rb @@ -1,7 +1,7 @@ require 'sidekiq' -Inferno::Application.boot(:sidekiq) do - init do +Inferno::Application.register_provider(:sidekiq) do + prepare do if Inferno::Application['async_jobs'] Sidekiq.configure_server do |config| config.redis = { url: ENV.fetch('REDIS_URL', 'redis://127.0.0.1:6379/0') } diff --git a/lib/inferno/config/boot/suites.rb b/lib/inferno/config/boot/suites.rb index 0e621b774..e8d9e225b 100644 --- a/lib/inferno/config/boot/suites.rb +++ b/lib/inferno/config/boot/suites.rb @@ -1,6 +1,6 @@ -Inferno::Application.boot(:suites) do - init do - use :logging +Inferno::Application.register_provider(:suites) do + prepare do + target_container.start :logging require 'inferno/entities/test' require 'inferno/entities/test_group' diff --git a/lib/inferno/config/boot/validator.rb b/lib/inferno/config/boot/validator.rb index 02da32c3e..3732380c7 100644 --- a/lib/inferno/config/boot/validator.rb +++ b/lib/inferno/config/boot/validator.rb @@ -1,6 +1,6 @@ -Inferno::Application.boot(:validator) do - init do - use :suites +Inferno::Application.register_provider(:validator) do + prepare do + target_container.start :suites # This process should only run once, to start one job per validator, # so skipping it on workers will start it only once from the "web" process diff --git a/lib/inferno/config/boot/web.rb b/lib/inferno/config/boot/web.rb index 04149b4c9..b890786a0 100644 --- a/lib/inferno/config/boot/web.rb +++ b/lib/inferno/config/boot/web.rb @@ -1,5 +1,5 @@ -Inferno::Application.boot(:web) do |_app| - init do +Inferno::Application.register_provider(:web) do |_app| + prepare do require 'blueprinter' require 'hanami/router' require 'hanami/controller' diff --git a/lib/inferno/dsl.rb b/lib/inferno/dsl.rb index 712425e87..a7c7690a6 100644 --- a/lib/inferno/dsl.rb +++ b/lib/inferno/dsl.rb @@ -5,6 +5,7 @@ require_relative 'dsl/http_client' require_relative 'dsl/results' require_relative 'dsl/runnable' +require_relative 'dsl/suite_endpoint' module Inferno # The DSL for writing tests. diff --git a/lib/inferno/dsl/resume_test_route.rb b/lib/inferno/dsl/resume_test_route.rb index c1b44ffdb..f92e18c0c 100644 --- a/lib/inferno/dsl/resume_test_route.rb +++ b/lib/inferno/dsl/resume_test_route.rb @@ -1,4 +1,4 @@ -require 'hanami/controller' +require_relative 'suite_endpoint' module Inferno module DSL @@ -6,58 +6,47 @@ module DSL # an incoming request. # @private # @see Inferno::DSL::Runnable#resume_test_route - class ResumeTestRoute < Hanami::Action - include Import[ - requests_repo: 'inferno.repositories.requests', - results_repo: 'inferno.repositories.results', - test_runs_repo: 'inferno.repositories.test_runs', - tests_repo: 'inferno.repositories.tests' - ] - - def self.call(...) - new.call(...) + class ResumeTestRoute < SuiteEndpoint + # The incoming request + # + # @return [Inferno::Entities::Request] + def request + @request ||= Inferno::Entities::Request.from_hanami_request(req) end # @private - def test_run_identifier_block - self.class.singleton_class.instance_variable_get(:@test_run_identifier_block) + def test_run_identifier + @test_run_identifier ||= instance_exec(request, &test_run_identifier_block) end # @private def tags - self.class.singleton_class.instance_variable_get(:@tags) + self.class.singleton_class.instance_variable_get(:@tags) || [] end # @private - def result - self.class.singleton_class.instance_variable_get(:@result) + def update_result + results_repo.update_result(result.id, new_result) end # @private - def find_test_run(test_run_identifier) - test_runs_repo.find_latest_waiting_by_identifier(test_run_identifier) + def make_response + res.redirect_to redirect_route(test_run, test) end # @private - def find_waiting_result(test_run) - results_repo.find_waiting_result(test_run_id: test_run.id) + def name + test.config.request_name(test.incoming_request_name) end # @private - def update_result(waiting_result) - results_repo.update_result(waiting_result.id, result) + def test_run_identifier_block + self.class.singleton_class.instance_variable_get(:@test_run_identifier_block) end # @private - def persist_request(request, test_run, waiting_result, test) - requests_repo.create( - request.to_hash.merge( - test_session_id: test_run.test_session_id, - result_id: waiting_result.id, - name: test.config.request_name(test.incoming_request_name), - tags: - ) - ) + def new_result + self.class.singleton_class.instance_variable_get(:@new_result) end # @private @@ -65,38 +54,10 @@ def redirect_route(test_run, test) "#{Application['base_url']}/test_sessions/#{test_run.test_session_id}##{resume_ui_at_id(test_run, test)}" end - # @private - def find_test(waiting_result) - tests_repo.find(waiting_result.test_id) - end - # @private def resume_ui_at_id(test_run, test) test_run.test_suite_id || test_run.test_group_id || test.parent.id end - - # @private - def handle(req, res) - request = Inferno::Entities::Request.from_hanami_request(req) - - test_run_identifier = instance_exec(request, &test_run_identifier_block) - - test_run = find_test_run(test_run_identifier) - - halt 500, "Unable to find test run with identifier '#{test_run_identifier}'." if test_run.nil? - - test_runs_repo.mark_as_no_longer_waiting(test_run.id) - - waiting_result = find_waiting_result(test_run) - test = find_test(waiting_result) - - update_result(waiting_result) - persist_request(request, test_run, waiting_result, test) - - Jobs.perform(Jobs::ResumeTestRun, test_run.id) - - res.redirect_to redirect_route(test_run, test) - end end end end diff --git a/lib/inferno/dsl/runnable.rb b/lib/inferno/dsl/runnable.rb index 3083ce0e5..2bcc3320c 100644 --- a/lib/inferno/dsl/runnable.rb +++ b/lib/inferno/dsl/runnable.rb @@ -354,12 +354,30 @@ def resume_test_route(method, path, tags: [], result: 'pass', &block) route_class = Class.new(ResumeTestRoute) do |klass| klass.singleton_class.instance_variable_set(:@test_run_identifier_block, block) klass.singleton_class.instance_variable_set(:@tags, tags) - klass.singleton_class.instance_variable_set(:@result, result) + klass.singleton_class.instance_variable_set(:@new_result, result) end route(method, path, route_class) end + # Create an endpoint to receive incoming requests during a Test Run. + # + # @see Inferno::DSL::SuiteEndpoint + # @example + # suite_endpoint :post, '/my_suite_endpoint', MySuiteEndpoint + # @param method [Symbol] the HTTP request type (:get, :post, etc.) for the + # incoming request + # @param path [String] the path for this request. The route will be served + # with a prefix of `/custom/TEST_SUITE_ID` to prevent path conflicts. + # [Any of the path options available in Hanami + # Router](https://github.com/hanami/router/tree/f41001d4c3ee9e2d2c7bb142f74b43f8e1d3a265#a-beautiful-dsl) + # can be used here. + # @param [Class] a subclass of Inferno::DSL::SuiteEndpoint + # @return [void] + def suite_endpoint(method, path, endpoint_class) + route(method, path, endpoint_class) + end + # Create a route to handle a request # # @param method [Symbol] the HTTP request type (:get, :post, etc.) for the diff --git a/lib/inferno/dsl/suite_endpoint.rb b/lib/inferno/dsl/suite_endpoint.rb new file mode 100644 index 000000000..ecb2bd632 --- /dev/null +++ b/lib/inferno/dsl/suite_endpoint.rb @@ -0,0 +1,330 @@ +require 'hanami/controller' +require 'rack/request' +require_relative '../ext/rack' + +module Inferno + module DSL + # 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](https://github.com/hanami/controller/tree/v2.0.0). + # + # @example + # class AuthorizedEndpoint < Inferno::DSL::SuiteEndpoint + # # Identify the incoming request based on a bearer token + # def test_run_identifier + # request.header['authorization']&.delete_prefix('Bearer ') + # end + # + # # 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 + class SuiteEndpoint < Hanami::Action + attr_reader :req, :res + + # @!group Overrides These methods should be overridden by subclasses to + # define the behavior of the endpoint + + # Override this method to determine a test run's identifier based on an + # incoming request. + # + # @return [String] + # + # @example + # def test_run_identifier + # # Identify the test session of an incoming request based on the bearer + # # token + # request.headers['authorization']&.delete_prefix('Bearer ') + # end + def test_run_identifier + nil + end + + # Override this method to build the response. + # + # @return [Void] + # + # @example + # def make_response + # response.status = 200 + # response.body = { abc: 123 }.to_json + # response.format = :json + # end + def make_response + nil + end + + # Override this method to define the tags which will be applied to the + # request. + # + # @return [Array] + def tags + @tags ||= [] + end + + # Override this method to assign a name to the request + # + # @return [String] + def name + result&.runnable&.incoming_request_name + end + + # Override this method to update the current waiting result. To resume the + # test run, set the result to something other than 'waiting'. + # + # @return [Void] + # + # @example + # def update_result + # results_repo.update(result.id, result: 'pass') + # end + def update_result + nil + end + + # Override this method to specify whether this request should be + # persisted. Defaults to true. + # + # @return [Boolean] + def persist_request? + true + end + + # @!endgroup + + # @private + def self.call(...) + new.call(...) + end + + # @return [Inferno::Repositories::Requests] + def requests_repo + @requests_repo ||= Inferno::Repositories::Requests.new + end + + # @return [Inferno::Repositories::Results] + def results_repo + @results_repo ||= Inferno::Repositories::Results.new + end + + # @return [Inferno::Repositories::TestRuns] + def test_runs_repo + @test_runs_repo ||= Inferno::Repositories::TestRuns.new + end + + # @return [Inferno::Repositories::Tests] + def tests_repo + @tests_repo ||= Inferno::Repositories::Tests.new + end + + # @private + def initialize(config: self.class.config) # rubocop:disable Lint/MissingSuper + @config = config + end + + # The incoming request as a `Hanami::Action::Request` + # + # @return [Hanami::Action::Request] + # + # @example + # request.params # Get url/query params + # request.body.read # Get body + # request.headers['accept'] # Get Accept header + def request + req + end + + # The response as a `Hanami::Action::Response`. Modify this to build the + # response to the incoming request. + # + # @return [Hanami::Action::Response] + # + # @example + # response.status = 200 # Set the status + # response.body = 'Ok' # Set the body + # # Set headers + # response.headers.merge!('X-Custom-Header' => 'CUSTOM_HEADER_VALUE') + def response + res + end + + # The test run which is waiting for incoming requests + # + # @return [Inferno::Entities::TestRun] + def test_run + @test_run ||= + test_runs_repo.find_latest_waiting_by_identifier(find_test_run_identifier).tap do |test_run| + halt 500, "Unable to find test run with identifier '#{test_run_identifier}'." if test_run.nil? + end + end + + # The result which is waiting for incoming requests for the current test + # run + # + # @return [Inferno::Entities::Result] + def result + @result ||= find_result + end + + # The test which is currently waiting for incoming requests + # + # @return [Inferno::Entities::Test] + def test + @test ||= tests_repo.find(result.test_id) + end + + # @return [Logger] Inferno's logger + def logger + @logger ||= Application['logger'] + end + + # @private + def find_test_run_identifier + @test_run_identifier ||= test_run_identifier + rescue StandardError => e + halt 500, "Unable to determine test run identifier:\n#{e.full_message}" + end + + # @private + def find_result + results_repo.find_waiting_result(test_run_id: test_run.id) + end + + # @private + # 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. + 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 + + # @private + def resume_test_run? + find_result&.result != 'waiting' + end + + # @private + # Inferno::Utils::Middleware::RequestRecorder actually resumes the + # TestRun. If it were resumed here, it would be resuming prior to the + # Request being persisted. + def resume + req.env['inferno.resume_test_run'] = true + req.env['inferno.test_run_id'] = test_run.id + end + + # @private + 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 + halt 500, e.full_message + end + + # @private + def add_persistence_callback # rubocop:disable Metrics/CyclomaticComplexity + logger = Application['logger'] + 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'] + logger.info('get body') + 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) + end + rescue StandardError => e + logger.error(e.full_message) + end + end + end + end +end diff --git a/lib/inferno/ext/rack.rb b/lib/inferno/ext/rack.rb new file mode 100644 index 000000000..45a30ec1f --- /dev/null +++ b/lib/inferno/ext/rack.rb @@ -0,0 +1,88 @@ +# Patch based on https://github.com/rack/rack/pull/1881 +# This should be removed when/if this functionality makes its way into a rack +# release (possible rack 3.1.0) +module Rack + class Request + def headers + @headers ||= Headers.new(@env) + end + + class Headers + def initialize(env) + @env = env + end + + def [](k) + @env[header_to_env_key(k)] + end + + def []=(k, v) + @env[header_to_env_key(k)] = v + end + + def add(k, v) + k = header_to_env_key(k) + case existing = @env[k] + when nil + @env[k] = v + when String + @env[k] = [existing, v] + when Array + existing << v + end + end + + def delete(k) + @env.delete(header_to_env_key(k)) + end + + def each + return to_enum(:each) unless block_given? + + @env.each do |k, v| + next unless k = env_to_header_key(k) + yield k, v + end + end + + def fetch(k, &block) + @env.fetch(header_to_env_key(k), &block) + end + + def has_key?(k) + @env.has_key?(header_to_env_key(k)) + end + + def to_h + h = {} + each{|k, v| h[k] = v} + h + end + + private + + def env_to_header_key(k) + case k + when /\AHTTP_/ + k = k[5..-1] + k.downcase! + k.tr!('_', '-') + k + when "CONTENT_LENGTH", "CONTENT_TYPE" + k = k.downcase + k.tr!('_', '-') + k + end + end + + def header_to_env_key(k) + k = k.upcase + k.tr!('-', '_') + unless k == "CONTENT_LENGTH" || k == "CONTENT_TYPE" + k = "HTTP_#{k}" + end + k + end + end + end +end diff --git a/lib/inferno/utils/middleware/request_logger.rb b/lib/inferno/utils/middleware/request_logger.rb index 3ca27c17e..432ecd3ed 100644 --- a/lib/inferno/utils/middleware/request_logger.rb +++ b/lib/inferno/utils/middleware/request_logger.rb @@ -1,3 +1,5 @@ +require 'puma/null_io' + module Inferno module Utils # @private @@ -28,6 +30,12 @@ def call(env) raise e end + env['inferno.response'] = response + + # rack.after_reply is handled by puma, which doesn't process requests + # in unit tests, so we manually run them when in the test environment + env['rack.after_reply']&.each(&:call) if (ENV['APP_ENV'] = 'test') + response end diff --git a/spec/inferno/dsl/runnable_spec.rb b/spec/inferno/dsl/runnable_spec.rb index 5c097214e..c27566cfd 100644 --- a/spec/inferno/dsl/runnable_spec.rb +++ b/spec/inferno/dsl/runnable_spec.rb @@ -73,6 +73,7 @@ updated_run = Inferno::Repositories::TestRuns.new.find(test_run.id) + expect(updated_run.status).to eq('done') expect(updated_run.identifier).to be_nil end diff --git a/spec/inferno/utils/preset_template_generator_spec.rb b/spec/inferno/utils/preset_template_generator_spec.rb index ca9befbf6..5f9c13163 100644 --- a/spec/inferno/utils/preset_template_generator_spec.rb +++ b/spec/inferno/utils/preset_template_generator_spec.rb @@ -58,7 +58,9 @@ _description: 'Example of locked, filled, optional field', value: 'example text', _locked: true, _optional: true }, { name: 'cancel_pause_time', _type: 'text', value: '30' }, - { name: 'url1', _type: 'text', value: nil } + { name: 'url1', _type: 'text', value: nil }, + { name: 'custom_bearer_token', _type: 'text', + _description: 'This bearer token will be used to identify the incoming request', value: nil } ] } end diff --git a/spec/request_helper.rb b/spec/request_helper.rb index 16410364c..0853de2de 100644 --- a/spec/request_helper.rb +++ b/spec/request_helper.rb @@ -1,10 +1,14 @@ require 'spec_helper' require 'rack/test' require_relative '../lib/inferno/apps/web/application' +require_relative '../lib/inferno/utils/middleware/request_logger' module RequestHelpers def app - Inferno::Web.app + Rack::Builder.new do + use Inferno::Utils::Middleware::RequestLogger + run Inferno::Web.app + end end def post_json(path, data) diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index bf2fab24b..ceb3784c0 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -13,6 +13,7 @@ add_filter '/lib/inferno/db/migrations' add_filter '/lib/inferno/db/schema.rb' add_filter '/lib/inferno/apps/cli' + add_filter '/lib/inferno/ext/rack.rb' end if ENV['GITHUB_ACTIONS']