diff --git a/app/lib/r3x/client/healthchecks_io.rb b/app/lib/r3x/client/healthchecks_io.rb new file mode 100644 index 0000000..29cae12 --- /dev/null +++ b/app/lib/r3x/client/healthchecks_io.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +module R3x + module Client + class HealthchecksIO + include R3x::Concerns::Logger + + def initialize(base_url) + @base_url = base_url.chomp("/") + end + + # Run a block of code with automatic healthcheck lifecycle. + # Sends a start signal before executing the block, and automatically + # sends success or failure signal after the block completes. + # + # @yield [HealthchecksIO, String] Yields the client and run ID + # @raise [ArgumentError] If no block is given + def run + raise ArgumentError, "Block required" unless block_given? + + rid = SecureRandom.uuid + send_start(rid: rid) + begin + yield(self, rid) + ping(rid: rid) + rescue StandardError => e + fail(body: e.message, rid: rid) + raise + end + end + + # Send a success ping to Healthchecks.io. + # Signals that a job has completed successfully. + # + # @param body [String, nil] Optional data to include in the request body + # @param rid [String, nil] Optional run ID for matching with start signal + # @return [HealthchecksIO::Response] The response from Healthchecks.io + def ping(body: nil, rid: nil) + method = body ? :post : :head + make_request(method, "", body: body, rid: rid) + end + + # Send a failure signal to Healthchecks.io. + # Signals that a job has failed. + # + # @param body [String, nil] Optional data to include in the request body + # @param rid [String, nil] Optional run ID for matching with start signal + # @return [HealthchecksIO::Response] The response from Healthchecks.io + def fail(body: nil, rid: nil) + make_request(:post, "/fail", body: body, rid: rid) + end + + # Send a log signal to Healthchecks.io. + # Logs information without changing the check status. + # + # @param lines [Array, String] Log lines to send + # @param rid [String, nil] Optional run ID + # @return [HealthchecksIO::Response] The response from Healthchecks.io + def log(lines:, rid: nil) + body = lines.is_a?(Array) ? lines.join("\n") : lines.to_s + make_request(:post, "/log", body: body, rid: rid) + end + + # Report an exit status to Healthchecks.io. + # Exit status 0 signals success, all other values signal failure. + # + # @param code [Integer] The exit status code (0-255) + # @param body [String, nil] Optional data to include in the request body + # @param rid [String, nil] Optional run ID for matching with start signal + # @return [HealthchecksIO::Response] The response from Healthchecks.io + def exit_status(code:, body: nil, rid: nil) + method = body ? :post : :head + make_request(method, "/#{code}", body: body, rid: rid) + end + + private + + attr_reader :base_url + + def connection + @connection ||= Faraday.new(url: base_url) do |f| + f.response :raise_error + f.options.timeout = 10 + f.options.open_timeout = 5 + end + end + + def send_start(rid: nil) + make_request(:head, "/start", rid: rid) + end + + def make_request(method, path, body: nil, rid: nil) + url = build_url(path, rid) + + logger.debug { "HealthchecksIO #{method.upcase} #{url}" } + + response = case method + when :head + connection.head(url) + when :post + connection.post(url, body) + else + raise ArgumentError, "Unsupported HTTP method: #{method}" + end + + Response.new(response) + end + + def build_url(path, rid) + uri = URI.parse(base_url) + uri.path = uri.path + path + uri.query = "rid=#{rid}" if rid + uri.to_s + end + end + end +end diff --git a/app/lib/r3x/client/healthchecks_io/response.rb b/app/lib/r3x/client/healthchecks_io/response.rb new file mode 100644 index 0000000..a179a8e --- /dev/null +++ b/app/lib/r3x/client/healthchecks_io/response.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module R3x + module Client + class HealthchecksIO + class Response + def initialize(faraday_response) + @response = faraday_response + end + + def success? + response.success? + end + + def status + response.status + end + + def body + response.body + end + + def headers + response.headers + end + + # The Ping-Body-Limit header value from Healthchecks.io. + # Indicates the maximum request body size the server accepts. + # + # @return [Integer, nil] The body limit in bytes, or nil if header not present + def body_limit + headers["Ping-Body-Limit"]&.to_i + end + + def to_s + body.to_s + end + + def inspect + "#<#{self.class.name} status=#{status} success=#{success?}>" + end + + private + + attr_reader :response + end + end + end +end diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index 3860f65..6fbdedd 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -14,3 +14,7 @@ # ActiveSupport::Inflector.inflections(:en) do |inflect| # inflect.acronym "RESTful" # end + +ActiveSupport::Inflector.inflections(:en) do |inflect| + inflect.acronym "IO" +end diff --git a/config/initializers/r3x_vault_env.rb b/config/initializers/r3x_vault_env.rb index f6e0925..3284a52 100644 --- a/config/initializers/r3x_vault_env.rb +++ b/config/initializers/r3x_vault_env.rb @@ -1,4 +1,4 @@ -Rails.application.config.before_initialize do +Rails.application.config.after_initialize do vault_path = ENV["R3X_VAULT_SECRETS_PATH"].presence R3x::Env.load_from_vault(vault_path) if vault_path end diff --git a/test/lib/r3x/client/healthchecks_io/response_test.rb b/test/lib/r3x/client/healthchecks_io/response_test.rb new file mode 100644 index 0000000..8bb6fca --- /dev/null +++ b/test/lib/r3x/client/healthchecks_io/response_test.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require "test_helper" + +module R3x + module Client + class HealthchecksIOResponseTest < ActiveSupport::TestCase + setup do + @base_url = "https://hc-ping.com/test-uuid-123" + @client = HealthchecksIO.new(@base_url) + end + + teardown do + WebMock.reset! + end + + test "success? returns true for successful response" do + stub_request(:head, @base_url).to_return(status: 200, body: "OK") + + response = @client.ping + + assert response.success? + end + + test "success? returns false for failed response" do + stub_request(:head, @base_url).to_return(status: 500, body: "Error") + + assert_raises(Faraday::Error) do + @client.ping + end + end + + test "status returns the HTTP status code" do + stub_request(:head, @base_url).to_return(status: 201, body: "Created") + + response = @client.ping + + assert_equal 201, response.status + end + + test "body returns the response body" do + stub_request(:head, @base_url).to_return(status: 200, body: "Custom body") + + response = @client.ping + + assert_equal "Custom body", response.body + end + + test "headers returns the response headers" do + stub_request(:head, @base_url) + .to_return(status: 200, body: "OK", headers: { "Content-Type" => "text/plain" }) + + response = @client.ping + + assert_equal "text/plain", response.headers["Content-Type"] + end + + test "body_limit returns integer from Ping-Body-Limit header" do + stub_request(:head, @base_url) + .to_return(status: 200, body: "OK", headers: { "Ping-Body-Limit" => "100000" }) + + response = @client.ping + + assert_equal 100000, response.body_limit + end + + test "body_limit returns nil when header not present" do + stub_request(:head, @base_url).to_return(status: 200, body: "OK") + + response = @client.ping + + assert_nil response.body_limit + end + + test "to_s returns body as string" do + stub_request(:head, @base_url).to_return(status: 200, body: "Test body") + + response = @client.ping + + assert_equal "Test body", response.to_s + end + + test "inspect shows status and success" do + stub_request(:head, @base_url).to_return(status: 200, body: "OK") + + response = @client.ping + + assert_equal "#", response.inspect + end + end + end +end diff --git a/test/lib/r3x/client/healthchecks_io_test.rb b/test/lib/r3x/client/healthchecks_io_test.rb new file mode 100644 index 0000000..ba851f8 --- /dev/null +++ b/test/lib/r3x/client/healthchecks_io_test.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true + +require "test_helper" + +module R3x + module Client + class HealthchecksIOTest < ActiveSupport::TestCase + setup do + @base_url = "https://hc-ping.com/test-uuid-123" + @client = HealthchecksIO.new(@base_url) + end + + teardown do + WebMock.reset! + end + + test "run sends start ping, yields block, and sends success ping" do + stub_request(:head, %r{#{@base_url}/start\?.*}).to_return(status: 200, body: "OK") + stub_request(:head, %r{#{@base_url}\?.*}).to_return(status: 200, body: "OK") + + executed = false + received_rid = nil + + @client.run do |client, rid| + executed = true + received_rid = rid + assert_instance_of HealthchecksIO, client + assert_match(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/, rid) + end + + assert executed + assert_requested :head, %r{#{@base_url}/start\?.*}, times: 1 + assert_requested :head, %r{#{@base_url}\?.*}, times: 1 + end + + test "run sends fail ping when block raises error" do + stub_request(:head, %r{#{@base_url}/start\?.*}).to_return(status: 200, body: "OK") + stub_request(:post, %r{#{@base_url}/fail\?.*}).to_return(status: 200, body: "OK") + + error = assert_raises(StandardError) do + @client.run do |client, rid| + raise StandardError, "Test error" + end + end + + assert_equal "Test error", error.message + assert_requested :head, %r{#{@base_url}/start\?.*}, times: 1 + assert_requested :post, %r{#{@base_url}/fail\?.*}, times: 1 + end + + test "run raises ArgumentError when no block given" do + error = assert_raises(ArgumentError) do + @client.run + end + + assert_equal "Block required", error.message + end + + test "ping sends success signal" do + request = stub_request(:head, @base_url) + .to_return(status: 200, body: "OK") + + response = @client.ping + + assert response.success? + assert_equal 200, response.status + assert_equal "OK", response.body + assert_requested request + end + + test "ping sends success signal with body" do + request = stub_request(:post, @base_url) + .with(body: "Custom data") + .to_return(status: 200, body: "OK") + + response = @client.ping(body: "Custom data") + + assert response.success? + assert_requested request + end + + test "ping sends rid parameter" do + rid = "123e4567-e89b-12d3-a456-426614174000" + stub_request(:head, "#{@base_url}?rid=#{rid}").to_return(status: 200, body: "OK") + + response = @client.ping(rid: rid) + + assert response.success? + assert_requested :head, "#{@base_url}?rid=#{rid}", times: 1 + end + + test "fail sends failure signal" do + request = stub_request(:post, "#{@base_url}/fail") + .to_return(status: 200, body: "OK") + + response = @client.fail + + assert response.success? + assert_requested request + end + + test "fail sends failure signal with body" do + request = stub_request(:post, "#{@base_url}/fail") + .with(body: "Error details") + .to_return(status: 200, body: "OK") + + response = @client.fail(body: "Error details") + + assert response.success? + assert_requested request + end + + test "log sends log signal with array of lines" do + request = stub_request(:post, "#{@base_url}/log") + .with(body: "Line 1\nLine 2\nLine 3") + .to_return(status: 200, body: "OK") + + response = @client.log(lines: [ "Line 1", "Line 2", "Line 3" ]) + + assert response.success? + assert_requested request + end + + test "log sends log signal with string" do + request = stub_request(:post, "#{@base_url}/log") + .with(body: "Single log line") + .to_return(status: 200, body: "OK") + + response = @client.log(lines: "Single log line") + + assert response.success? + assert_requested request + end + + test "exit_status sends exit code 0 as success" do + request = stub_request(:head, "#{@base_url}/0") + .to_return(status: 200, body: "OK") + + response = @client.exit_status(code: 0) + + assert response.success? + assert_requested request + end + + test "exit_status sends non-zero exit code as failure" do + request = stub_request(:head, "#{@base_url}/1") + .to_return(status: 200, body: "OK") + + response = @client.exit_status(code: 1) + + assert response.success? + assert_requested request + end + + test "chomps trailing slash from base_url" do + client = HealthchecksIO.new("https://hc-ping.com/uuid/") + request = stub_request(:head, "https://hc-ping.com/uuid") + .to_return(status: 200, body: "OK") + + client.ping + + assert_requested request + end + end + end +end