From 1661b150704d9a53708f0f95986d6e9fec5a6bfe Mon Sep 17 00:00:00 2001 From: Stephen MacVicar Date: Mon, 4 Mar 2024 09:41:38 -0500 Subject: [PATCH 01/14] bump dry dependencies --- Gemfile.lock | 66 +++++++++++++++++--------------------------- inferno_core.gemspec | 12 ++++---- 2 files changed, 32 insertions(+), 46 deletions(-) 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/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' From 0ea4378e3c8968b4e468020f4007679a5b7b9f35 Mon Sep 17 00:00:00 2001 From: Stephen MacVicar Date: Mon, 4 Mar 2024 09:43:08 -0500 Subject: [PATCH 02/14] make dry system updates --- lib/inferno/config/application.rb | 4 ++-- lib/inferno/config/boot/db.rb | 6 +++--- lib/inferno/config/boot/logging.rb | 4 ++-- lib/inferno/config/boot/presets.rb | 6 +++--- lib/inferno/config/boot/sidekiq.rb | 4 ++-- lib/inferno/config/boot/suites.rb | 6 +++--- lib/inferno/config/boot/validator.rb | 6 +++--- lib/inferno/config/boot/web.rb | 4 ++-- 8 files changed, 20 insertions(+), 20 deletions(-) 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' From 07ed0bb88ef119910b9e68b5b1551b2f2f87e642 Mon Sep 17 00:00:00 2001 From: Stephen MacVicar Date: Wed, 6 Mar 2024 14:00:56 -0500 Subject: [PATCH 03/14] fix response content type --- lib/inferno/apps/web/controllers/controller.rb | 10 +++++++--- .../apps/web/controllers/test_sessions/client_show.rb | 3 +-- 2 files changed, 8 insertions(+), 5 deletions(-) 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 From 4be71c5f00440e17091e2c0e546e3eafbfd32b46 Mon Sep 17 00:00:00 2001 From: Stephen MacVicar Date: Mon, 18 Mar 2024 09:48:48 -0400 Subject: [PATCH 04/14] add generic suite endpoint --- lib/inferno/dsl/suite_endpoint.rb | 177 ++++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 lib/inferno/dsl/suite_endpoint.rb diff --git a/lib/inferno/dsl/suite_endpoint.rb b/lib/inferno/dsl/suite_endpoint.rb new file mode 100644 index 000000000..7216e57df --- /dev/null +++ b/lib/inferno/dsl/suite_endpoint.rb @@ -0,0 +1,177 @@ +require 'hanami/controller' + +module Inferno + module DSL + # A base class for creating endpoints to test client requests. + class SuiteEndpoint < 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' + ] + + attr_reader :req, :res + + # @private + def self.call(...) + new.call(...) + end + + # The incoming request as a `Hanami::Action::Request` + # + # @return [Hanami::Action::Request] + # + # @example + # request.params # Get url/query params + # request.body # Get body + # request.get_header('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 + + # 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.get_header('authorization')&.delete_prefix('Bearer ') + # end + def test_run_identifier + nil + end + + # Override this method to build the response. + # + # @return [Void] + # + # @example + # def build_response + # response.status = 200 + # response.body = { abc: 123 }.to_json + # response.format = :json + # end + def build_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 specify whether this request should be + # persisted. Defaults to true. + # + # @return [Boolean] + def persist_request? + true + 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 + + # 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 + + # @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 + def persist_request + request.env['inferno.persist_request'] = true + request.env['inferno.test_session_id'] = test_run.test_session_id + request.env['inferno.result_id'] = result.id + request.env['inferno.tags'] = tags + end + + # @private + def resume_test_run? + find_result&.result != 'waiting' + end + + # @private + def resume + test_runs_repo.mark_as_no_longer_waiting(test_run.id) + + Jobs.perform(Jobs::ResumeTestRun, test_run.id) + end + + # @private + def handle(req, res) + @req = req + @res = res + + build_response + + persist_request if persist_request? + + update_result + + resume if resume_test_run? + end + end + end +end From 5eb2374573a1a2b233472f1279f1be89e7b71480 Mon Sep 17 00:00:00 2001 From: Stephen MacVicar Date: Tue, 19 Mar 2024 08:35:10 -0400 Subject: [PATCH 05/14] add demo endpoint --- .rubocop.yml | 1 + config.ru | 1 + dev_suites/dev_demo_ig_stu1/demo_endpoint.rb | 25 ++++++ dev_suites/dev_demo_ig_stu1/demo_suite.rb | 41 +++++++++ lib/inferno/config/boot/web.rb | 1 + lib/inferno/dsl.rb | 1 + lib/inferno/dsl/runnable.rb | 4 + lib/inferno/dsl/suite_endpoint.rb | 58 +++++++++---- lib/inferno/ext/rack.rb | 85 +++++++++++++++++++ .../utils/middleware/request_recorder.rb | 68 +++++++++++++++ .../utils/preset_template_generator_spec.rb | 3 +- 11 files changed, 269 insertions(+), 19 deletions(-) create mode 100644 dev_suites/dev_demo_ig_stu1/demo_endpoint.rb create mode 100644 lib/inferno/ext/rack.rb create mode 100644 lib/inferno/utils/middleware/request_recorder.rb 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/config.ru b/config.ru index 8501fb5fe..e4e918619 100644 --- a/config.ru +++ b/config.ru @@ -7,5 +7,6 @@ use Rack::Static, Inferno::Application.finalize! use Inferno::Utils::Middleware::RequestLogger +use Inferno::Utils::Middleware::RequestRecorder run Inferno::Web.app 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..0263902dc 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,45 @@ 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 + + suite_endpoint :get, '/suite_endpoint', DemoEndpoint + + test do + title 'Wait for request to suite endpoint' + + run do + wait( + identifier: custom_bearer_token, + message: "Waiting for request with bearer token: #{custom_bearer_token}" + ) + 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/lib/inferno/config/boot/web.rb b/lib/inferno/config/boot/web.rb index b890786a0..f7bdf88b1 100644 --- a/lib/inferno/config/boot/web.rb +++ b/lib/inferno/config/boot/web.rb @@ -10,6 +10,7 @@ end require 'inferno/utils/middleware/request_logger' + require 'inferno/utils/middleware/request_recorder' require 'inferno/apps/web/application' end end 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/runnable.rb b/lib/inferno/dsl/runnable.rb index 3083ce0e5..87b5e4784 100644 --- a/lib/inferno/dsl/runnable.rb +++ b/lib/inferno/dsl/runnable.rb @@ -360,6 +360,10 @@ def resume_test_route(method, path, tags: [], result: 'pass', &block) route(method, path, route_class) end + 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 index 7216e57df..19daea7ee 100644 --- a/lib/inferno/dsl/suite_endpoint.rb +++ b/lib/inferno/dsl/suite_endpoint.rb @@ -1,16 +1,10 @@ require 'hanami/controller' +require_relative '../ext/rack' module Inferno module DSL # A base class for creating endpoints to test client requests. class SuiteEndpoint < 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' - ] - attr_reader :req, :res # @private @@ -18,6 +12,27 @@ def self.call(...) new.call(...) end + def requests_repo + @requests_repo ||= Inferno::Repositories::Requests.new + end + + def results_repo + @results_repo ||= Inferno::Repositories::Results.new + end + + def test_runs_repo + @test_runs_repo ||= Inferno::Repositories::TestRuns.new + end + + 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] @@ -25,7 +40,7 @@ def self.call(...) # @example # request.params # Get url/query params # request.body # Get body - # request.get_header('accept') # Get Accept header + # request.headers['accept'] # Get Accept header def request req end @@ -53,7 +68,7 @@ def response # def test_run_identifier # # Identify the test session of an incoming request based on the bearer # # token - # request.get_header('authorization')&.delete_prefix('Bearer ') + # request.headers['authorization']&.delete_prefix('Bearer ') # end def test_run_identifier nil @@ -64,12 +79,12 @@ def test_run_identifier # @return [Void] # # @example - # def build_response + # def make_response # response.status = 200 # response.body = { abc: 123 }.to_json # response.format = :json # end - def build_response + def make_response nil end @@ -81,12 +96,11 @@ def tags @tags ||= [] end - # Override this method to specify whether this request should be - # persisted. Defaults to true. + # Override this method to assign a name to the request # - # @return [Boolean] - def persist_request? - true + # @return [String] + def name + nil end # Override this method to update the current waiting result. To resume the @@ -102,6 +116,14 @@ 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 + # The test run which is waiting for incoming requests # # @return [Inferno::Entities::TestRun] @@ -145,6 +167,7 @@ def persist_request request.env['inferno.test_session_id'] = test_run.test_session_id request.env['inferno.result_id'] = result.id request.env['inferno.tags'] = tags + request.env['inferno.name'] = name if name.present? end # @private @@ -163,8 +186,7 @@ def resume def handle(req, res) @req = req @res = res - - build_response + make_response persist_request if persist_request? diff --git a/lib/inferno/ext/rack.rb b/lib/inferno/ext/rack.rb new file mode 100644 index 000000000..7b6098b1a --- /dev/null +++ b/lib/inferno/ext/rack.rb @@ -0,0 +1,85 @@ +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_recorder.rb b/lib/inferno/utils/middleware/request_recorder.rb new file mode 100644 index 000000000..277345a1a --- /dev/null +++ b/lib/inferno/utils/middleware/request_recorder.rb @@ -0,0 +1,68 @@ +module Inferno + module Utils + # @private + module Middleware + class RequestRecorder + attr_reader :app + + def initialize(app) + @app = app + end + + def logger + @logger ||= Application['logger'] + end + + def call(env) + env['rack.after_reply'] ||= [] + env['rack.after_reply'] << proc do + next unless env['inferno.persist_request'] + + 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'] + url = uri.to_s + verb = env['REQUEST_METHOD'] + logger.info('get body') + request_body = env['rack.input'] + 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 = @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'] + ) + rescue StandardError => e + logger.error(e.full_message) + end + + @response = app.call(env) + rescue StandardError => e + logger.error(e.full_message) + + @response + end + end + end + end +end diff --git a/spec/inferno/utils/preset_template_generator_spec.rb b/spec/inferno/utils/preset_template_generator_spec.rb index ca9befbf6..3898db6ba 100644 --- a/spec/inferno/utils/preset_template_generator_spec.rb +++ b/spec/inferno/utils/preset_template_generator_spec.rb @@ -58,7 +58,8 @@ _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', value: nil } ] } end From 77b3837b0c956a9efa2beff54b45e5b56867b488 Mon Sep 17 00:00:00 2001 From: Stephen MacVicar Date: Wed, 20 Mar 2024 14:21:10 -0400 Subject: [PATCH 06/14] move test resumption to middleware to prevent race condition --- dev_suites/dev_demo_ig_stu1/demo_suite.rb | 2 +- lib/inferno/dsl/suite_endpoint.rb | 7 ++++--- lib/inferno/utils/middleware/request_recorder.rb | 13 ++++++++++--- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/dev_suites/dev_demo_ig_stu1/demo_suite.rb b/dev_suites/dev_demo_ig_stu1/demo_suite.rb index 0263902dc..bcf0f30cc 100644 --- a/dev_suites/dev_demo_ig_stu1/demo_suite.rb +++ b/dev_suites/dev_demo_ig_stu1/demo_suite.rb @@ -309,7 +309,7 @@ class DemoSuite < Inferno::TestSuite input :custom_bearer_token - suite_endpoint :get, '/suite_endpoint', DemoEndpoint + suite_endpoint :post, '/suite_endpoint', DemoEndpoint test do title 'Wait for request to suite endpoint' diff --git a/lib/inferno/dsl/suite_endpoint.rb b/lib/inferno/dsl/suite_endpoint.rb index 19daea7ee..2186e9af4 100644 --- a/lib/inferno/dsl/suite_endpoint.rb +++ b/lib/inferno/dsl/suite_endpoint.rb @@ -177,15 +177,16 @@ def resume_test_run? # @private def resume - test_runs_repo.mark_as_no_longer_waiting(test_run.id) - - Jobs.perform(Jobs::ResumeTestRun, test_run.id) + request.env['inferno.resume_test_run'] = true + request.env['inferno.test_run_id'] = test_run.id end # @private def handle(req, res) @req = req @res = res + test_run + make_response persist_request if persist_request? diff --git a/lib/inferno/utils/middleware/request_recorder.rb b/lib/inferno/utils/middleware/request_recorder.rb index 277345a1a..2ee9d3441 100644 --- a/lib/inferno/utils/middleware/request_recorder.rb +++ b/lib/inferno/utils/middleware/request_recorder.rb @@ -34,7 +34,7 @@ def call(env) request_headers = Rack::Request.new(env).headers.to_h.map { |name, value| { name:, value: } } - status, response_headers, response_body = @response + status, response_headers, response_body = env['inferno.response'] response_headers = response_headers.map { |name, value| { name:, value: } } @@ -52,15 +52,22 @@ def call(env) 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 - @response = app.call(env) + env['inferno.response'] = app.call(env) rescue StandardError => e logger.error(e.full_message) - @response + env['inferno.response'] = response end end end From d6e80d32cbcc9c59ead301fcf64074e690da110a Mon Sep 17 00:00:00 2001 From: Stephen MacVicar Date: Wed, 20 Mar 2024 15:30:26 -0400 Subject: [PATCH 07/14] refactor resume test route to be based on suite endpoint --- dev_suites/dev_demo_ig_stu1/demo_suite.rb | 6 +- lib/inferno/dsl/resume_test_route.rb | 72 +++++-------------- lib/inferno/dsl/runnable.rb | 2 +- lib/inferno/dsl/suite_endpoint.rb | 22 +++--- .../utils/middleware/request_recorder.rb | 18 +++-- spec/inferno/dsl/runnable_spec.rb | 1 + .../utils/preset_template_generator_spec.rb | 3 +- spec/request_helper.rb | 6 +- 8 files changed, 59 insertions(+), 71 deletions(-) diff --git a/dev_suites/dev_demo_ig_stu1/demo_suite.rb b/dev_suites/dev_demo_ig_stu1/demo_suite.rb index bcf0f30cc..f56890599 100644 --- a/dev_suites/dev_demo_ig_stu1/demo_suite.rb +++ b/dev_suites/dev_demo_ig_stu1/demo_suite.rb @@ -307,7 +307,8 @@ class DemoSuite < Inferno::TestSuite This group demonstrates custom suite endpoint functionality. ) - input :custom_bearer_token + input :custom_bearer_token, + description: 'This bearer token will be used to identify the incoming request' suite_endpoint :post, '/suite_endpoint', DemoEndpoint @@ -317,7 +318,8 @@ class DemoSuite < Inferno::TestSuite run do wait( identifier: custom_bearer_token, - message: "Waiting for request with bearer token: #{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 diff --git a/lib/inferno/dsl/resume_test_route.rb b/lib/inferno/dsl/resume_test_route.rb index c1b44ffdb..7a68282e0 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,14 +6,8 @@ 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' - ] - + class ResumeTestRoute < SuiteEndpoint + # @private def self.call(...) new.call(...) end @@ -25,39 +19,32 @@ def test_run_identifier_block # @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 new_result + self.class.singleton_class.instance_variable_get(:@new_result) end # @private - def find_test_run(test_run_identifier) - test_runs_repo.find_latest_waiting_by_identifier(test_run_identifier) + def update_result + results_repo.update_result(result.id, new_result) end # @private - def find_waiting_result(test_run) - results_repo.find_waiting_result(test_run_id: test_run.id) + def make_response + res.redirect_to redirect_route(test_run, test) end # @private - def update_result(waiting_result) - results_repo.update_result(waiting_result.id, result) + def name + test.config.request_name(test.incoming_request_name) 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 test_run_identifier + @test_run_identifier ||= instance_exec(request, &test_run_identifier_block) end # @private @@ -65,37 +52,16 @@ 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) + # The incoming request + # + # @return [Inferno::Entities::Request] + def request + @request ||= Inferno::Entities::Request.from_hanami_request(req) end end end diff --git a/lib/inferno/dsl/runnable.rb b/lib/inferno/dsl/runnable.rb index 87b5e4784..d1624bdd0 100644 --- a/lib/inferno/dsl/runnable.rb +++ b/lib/inferno/dsl/runnable.rb @@ -354,7 +354,7 @@ 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) diff --git a/lib/inferno/dsl/suite_endpoint.rb b/lib/inferno/dsl/suite_endpoint.rb index 2186e9af4..7f689699c 100644 --- a/lib/inferno/dsl/suite_endpoint.rb +++ b/lib/inferno/dsl/suite_endpoint.rb @@ -12,18 +12,22 @@ 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 @@ -163,11 +167,11 @@ def find_result # @private def persist_request - request.env['inferno.persist_request'] = true - request.env['inferno.test_session_id'] = test_run.test_session_id - request.env['inferno.result_id'] = result.id - request.env['inferno.tags'] = tags - request.env['inferno.name'] = name if name.present? + req.env['inferno.persist_request'] = true + 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? end # @private @@ -177,8 +181,8 @@ def resume_test_run? # @private def resume - request.env['inferno.resume_test_run'] = true - request.env['inferno.test_run_id'] = test_run.id + req.env['inferno.resume_test_run'] = true + req.env['inferno.test_run_id'] = test_run.id end # @private @@ -187,13 +191,13 @@ def handle(req, res) @res = res test_run - make_response - persist_request if persist_request? update_result resume if resume_test_run? + + make_response end end end diff --git a/lib/inferno/utils/middleware/request_recorder.rb b/lib/inferno/utils/middleware/request_recorder.rb index 2ee9d3441..8bb4f4462 100644 --- a/lib/inferno/utils/middleware/request_recorder.rb +++ b/lib/inferno/utils/middleware/request_recorder.rb @@ -1,3 +1,5 @@ +require 'puma/null_io' + module Inferno module Utils # @private @@ -13,7 +15,7 @@ def logger @logger ||= Application['logger'] end - def call(env) + def call(env) # rubocop:disable Metrics/CyclomaticComplexity env['rack.after_reply'] ||= [] env['rack.after_reply'] << proc do next unless env['inferno.persist_request'] @@ -24,9 +26,9 @@ def call(env) uri.scheme = env['rack.url_scheme'] uri.host = env['SERVER_NAME'] uri.port = env['SERVER_PORT'] - uri.path = env['REQUEST_PATH'] + uri.path = env['REQUEST_PATH'] || '' uri.query = env['rack.request.query_string'] - url = uri.to_s + url = uri&.to_s verb = env['REQUEST_METHOD'] logger.info('get body') request_body = env['rack.input'] @@ -63,7 +65,15 @@ def call(env) logger.error(e.full_message) end - env['inferno.response'] = app.call(env) + response = app.call(env) + + 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') + + env['inferno.response'] rescue StandardError => e logger.error(e.full_message) 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 3898db6ba..5f9c13163 100644 --- a/spec/inferno/utils/preset_template_generator_spec.rb +++ b/spec/inferno/utils/preset_template_generator_spec.rb @@ -59,7 +59,8 @@ _locked: true, _optional: true }, { name: 'cancel_pause_time', _type: 'text', value: '30' }, { name: 'url1', _type: 'text', value: nil }, - { name: 'custom_bearer_token', _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..317ae44c5 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_recorder' module RequestHelpers def app - Inferno::Web.app + Rack::Builder.new do + use Inferno::Utils::Middleware::RequestRecorder + run Inferno::Web.app + end end def post_json(path, data) From 8459e1f45223c6dca46146aa0747ec83e6a49d30 Mon Sep 17 00:00:00 2001 From: Stephen MacVicar Date: Thu, 21 Mar 2024 08:28:49 -0400 Subject: [PATCH 08/14] remove rack extension from coverage measurements --- spec/spec_helper.rb | 1 + 1 file changed, 1 insertion(+) 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'] From acce0c9b6fa95e6a2ec8ba293ed0bf10d3ae394f Mon Sep 17 00:00:00 2001 From: Stephen MacVicar Date: Mon, 25 Mar 2024 11:44:01 -0400 Subject: [PATCH 09/14] document rack patch --- lib/inferno/ext/rack.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/inferno/ext/rack.rb b/lib/inferno/ext/rack.rb index 7b6098b1a..45a30ec1f 100644 --- a/lib/inferno/ext/rack.rb +++ b/lib/inferno/ext/rack.rb @@ -1,3 +1,6 @@ +# 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 From 66952788cdda3103d740e0c206475ce33727aa32 Mon Sep 17 00:00:00 2001 From: Stephen MacVicar Date: Mon, 25 Mar 2024 13:08:22 -0400 Subject: [PATCH 10/14] add more documentation --- lib/inferno/dsl/resume_test_route.rb | 33 ++-- lib/inferno/dsl/runnable.rb | 14 ++ lib/inferno/dsl/suite_endpoint.rb | 176 ++++++++++++------ .../utils/middleware/request_recorder.rb | 7 + 4 files changed, 155 insertions(+), 75 deletions(-) diff --git a/lib/inferno/dsl/resume_test_route.rb b/lib/inferno/dsl/resume_test_route.rb index 7a68282e0..f92e18c0c 100644 --- a/lib/inferno/dsl/resume_test_route.rb +++ b/lib/inferno/dsl/resume_test_route.rb @@ -7,14 +7,16 @@ module DSL # @private # @see Inferno::DSL::Runnable#resume_test_route class ResumeTestRoute < SuiteEndpoint - # @private - def self.call(...) - new.call(...) + # 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 @@ -22,11 +24,6 @@ def tags self.class.singleton_class.instance_variable_get(:@tags) || [] end - # @private - def new_result - self.class.singleton_class.instance_variable_get(:@new_result) - end - # @private def update_result results_repo.update_result(result.id, new_result) @@ -43,8 +40,13 @@ def name end # @private - def test_run_identifier - @test_run_identifier ||= instance_exec(request, &test_run_identifier_block) + def test_run_identifier_block + self.class.singleton_class.instance_variable_get(:@test_run_identifier_block) + end + + # @private + def new_result + self.class.singleton_class.instance_variable_get(:@new_result) end # @private @@ -56,13 +58,6 @@ def redirect_route(test_run, test) def resume_ui_at_id(test_run, test) test_run.test_suite_id || test_run.test_group_id || test.parent.id end - - # The incoming request - # - # @return [Inferno::Entities::Request] - def request - @request ||= Inferno::Entities::Request.from_hanami_request(req) - end end end end diff --git a/lib/inferno/dsl/runnable.rb b/lib/inferno/dsl/runnable.rb index d1624bdd0..2bcc3320c 100644 --- a/lib/inferno/dsl/runnable.rb +++ b/lib/inferno/dsl/runnable.rb @@ -360,6 +360,20 @@ def resume_test_route(method, path, tags: [], result: 'pass', &block) 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 diff --git a/lib/inferno/dsl/suite_endpoint.rb b/lib/inferno/dsl/suite_endpoint.rb index 7f689699c..1a6336eb5 100644 --- a/lib/inferno/dsl/suite_endpoint.rb +++ b/lib/inferno/dsl/suite_endpoint.rb @@ -3,65 +3,64 @@ module Inferno module DSL - # A base class for creating endpoints to test client requests. + # 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 - # @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 # 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 + # @!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. @@ -128,6 +127,64 @@ 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 # 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] @@ -166,6 +223,10 @@ def find_result 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.persist_request'] = true req.env['inferno.test_session_id'] = test_run.test_session_id @@ -180,6 +241,9 @@ def resume_test_run? 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 diff --git a/lib/inferno/utils/middleware/request_recorder.rb b/lib/inferno/utils/middleware/request_recorder.rb index 8bb4f4462..274a29566 100644 --- a/lib/inferno/utils/middleware/request_recorder.rb +++ b/lib/inferno/utils/middleware/request_recorder.rb @@ -4,6 +4,10 @@ module Inferno module Utils # @private module Middleware + # This middleware handles persisting the incoming requests to + # Inferno::DSL::SuiteEndpoint. It is also responsible for resuming test + # runs which those endpoints indicate should be resumed, because the test + # runs can't be resumed prior to the incoming request being persisted. class RequestRecorder attr_reader :app @@ -67,6 +71,9 @@ def call(env) # rubocop:disable Metrics/CyclomaticComplexity response = app.call(env) + # For some reason, response isn't in scope for the proc above. This + # ensures that the response is available to the proc so that all + # details of the response can be persisted. env['inferno.response'] = response # rack.after_reply is handled by puma, which doesn't process requests From d1cdb572eaa8d84bc379fed3046da2bef111faf3 Mon Sep 17 00:00:00 2001 From: Stephen MacVicar Date: Mon, 15 Apr 2024 08:37:58 -0400 Subject: [PATCH 11/14] handle errors on suite endpoint --- lib/inferno/dsl/suite_endpoint.rb | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/lib/inferno/dsl/suite_endpoint.rb b/lib/inferno/dsl/suite_endpoint.rb index 1a6336eb5..f8eb35e1f 100644 --- a/lib/inferno/dsl/suite_endpoint.rb +++ b/lib/inferno/dsl/suite_endpoint.rb @@ -165,8 +165,8 @@ def initialize(config: self.class.config) # rubocop:disable Lint/MissingSuper # # @example # request.params # Get url/query params - # request.body # Get body - # request.headers['accept'] # Get Accept header + # request.body.read # Get body + # request.headers['accept'] # Get Accept header def request req end @@ -255,13 +255,17 @@ def handle(req, res) @res = res test_run + make_response + persist_request if persist_request? update_result resume if resume_test_run? - - make_response + rescue StandardError => e + halt 500, e.full_message + ensure + request.body&.rewind end end end From 184ecb46b82b7de6c540ebaacfcc8aa63bd70201 Mon Sep 17 00:00:00 2001 From: Stephen MacVicar Date: Thu, 2 May 2024 09:07:38 -0400 Subject: [PATCH 12/14] fix error handling --- lib/inferno/dsl/suite_endpoint.rb | 6 ++---- lib/inferno/utils/middleware/request_recorder.rb | 1 + 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/inferno/dsl/suite_endpoint.rb b/lib/inferno/dsl/suite_endpoint.rb index f8eb35e1f..4b18f73c9 100644 --- a/lib/inferno/dsl/suite_endpoint.rb +++ b/lib/inferno/dsl/suite_endpoint.rb @@ -255,17 +255,15 @@ def handle(req, res) @res = res test_run - make_response - persist_request if persist_request? update_result resume if resume_test_run? + + make_response rescue StandardError => e halt 500, e.full_message - ensure - request.body&.rewind end end end diff --git a/lib/inferno/utils/middleware/request_recorder.rb b/lib/inferno/utils/middleware/request_recorder.rb index 274a29566..954fd3331 100644 --- a/lib/inferno/utils/middleware/request_recorder.rb +++ b/lib/inferno/utils/middleware/request_recorder.rb @@ -36,6 +36,7 @@ def call(env) # rubocop:disable Metrics/CyclomaticComplexity 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: } } From 150ff4d6d39b34ad13a054e97e80dd5dafd9df31 Mon Sep 17 00:00:00 2001 From: Stephen MacVicar Date: Wed, 8 May 2024 13:17:38 -0400 Subject: [PATCH 13/14] get request name automatically from test --- lib/inferno/dsl/suite_endpoint.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/inferno/dsl/suite_endpoint.rb b/lib/inferno/dsl/suite_endpoint.rb index 4b18f73c9..5ef9f368c 100644 --- a/lib/inferno/dsl/suite_endpoint.rb +++ b/lib/inferno/dsl/suite_endpoint.rb @@ -103,7 +103,7 @@ def tags # # @return [String] def name - nil + result&.runnable&.incoming_request_name end # Override this method to update the current waiting result. To resume the From a71aa5c2baf97340ccfcef8c4b1afaa42e298d41 Mon Sep 17 00:00:00 2001 From: Stephen MacVicar Date: Thu, 9 May 2024 11:44:29 -0400 Subject: [PATCH 14/14] fix race condition --- config.ru | 1 - lib/inferno/config/boot/web.rb | 1 - lib/inferno/dsl/suite_endpoint.rb | 62 ++++++++++++- .../utils/middleware/request_logger.rb | 8 ++ .../utils/middleware/request_recorder.rb | 93 ------------------- spec/request_helper.rb | 4 +- 6 files changed, 71 insertions(+), 98 deletions(-) delete mode 100644 lib/inferno/utils/middleware/request_recorder.rb diff --git a/config.ru b/config.ru index e4e918619..8501fb5fe 100644 --- a/config.ru +++ b/config.ru @@ -7,6 +7,5 @@ use Rack::Static, Inferno::Application.finalize! use Inferno::Utils::Middleware::RequestLogger -use Inferno::Utils::Middleware::RequestRecorder run Inferno::Web.app diff --git a/lib/inferno/config/boot/web.rb b/lib/inferno/config/boot/web.rb index f7bdf88b1..b890786a0 100644 --- a/lib/inferno/config/boot/web.rb +++ b/lib/inferno/config/boot/web.rb @@ -10,7 +10,6 @@ end require 'inferno/utils/middleware/request_logger' - require 'inferno/utils/middleware/request_recorder' require 'inferno/apps/web/application' end end diff --git a/lib/inferno/dsl/suite_endpoint.rb b/lib/inferno/dsl/suite_endpoint.rb index 5ef9f368c..ecb2bd632 100644 --- a/lib/inferno/dsl/suite_endpoint.rb +++ b/lib/inferno/dsl/suite_endpoint.rb @@ -1,4 +1,5 @@ require 'hanami/controller' +require 'rack/request' require_relative '../ext/rack' module Inferno @@ -210,6 +211,11 @@ 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 @@ -228,11 +234,12 @@ def find_result # 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.persist_request'] = true 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 @@ -265,6 +272,59 @@ def handle(req, res) 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/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/lib/inferno/utils/middleware/request_recorder.rb b/lib/inferno/utils/middleware/request_recorder.rb deleted file mode 100644 index 954fd3331..000000000 --- a/lib/inferno/utils/middleware/request_recorder.rb +++ /dev/null @@ -1,93 +0,0 @@ -require 'puma/null_io' - -module Inferno - module Utils - # @private - module Middleware - # This middleware handles persisting the incoming requests to - # Inferno::DSL::SuiteEndpoint. It is also responsible for resuming test - # runs which those endpoints indicate should be resumed, because the test - # runs can't be resumed prior to the incoming request being persisted. - class RequestRecorder - attr_reader :app - - def initialize(app) - @app = app - end - - def logger - @logger ||= Application['logger'] - end - - def call(env) # rubocop:disable Metrics/CyclomaticComplexity - env['rack.after_reply'] ||= [] - env['rack.after_reply'] << proc do - next unless env['inferno.persist_request'] - - 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'] - 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 - - response = app.call(env) - - # For some reason, response isn't in scope for the proc above. This - # ensures that the response is available to the proc so that all - # details of the response can be persisted. - 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') - - env['inferno.response'] - rescue StandardError => e - logger.error(e.full_message) - - env['inferno.response'] = response - end - end - end - end -end diff --git a/spec/request_helper.rb b/spec/request_helper.rb index 317ae44c5..0853de2de 100644 --- a/spec/request_helper.rb +++ b/spec/request_helper.rb @@ -1,12 +1,12 @@ require 'spec_helper' require 'rack/test' require_relative '../lib/inferno/apps/web/application' -require_relative '../lib/inferno/utils/middleware/request_recorder' +require_relative '../lib/inferno/utils/middleware/request_logger' module RequestHelpers def app Rack::Builder.new do - use Inferno::Utils::Middleware::RequestRecorder + use Inferno::Utils::Middleware::RequestLogger run Inferno::Web.app end end