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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions app/lib/r3x/client/healthchecks_io.rb
Original file line number Diff line number Diff line change
@@ -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>, 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
49 changes: 49 additions & 0 deletions app/lib/r3x/client/healthchecks_io/response.rb
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions config/initializers/inflections.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,7 @@
# ActiveSupport::Inflector.inflections(:en) do |inflect|
# inflect.acronym "RESTful"
# end

ActiveSupport::Inflector.inflections(:en) do |inflect|
inflect.acronym "IO"
end
2 changes: 1 addition & 1 deletion config/initializers/r3x_vault_env.rb
Original file line number Diff line number Diff line change
@@ -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
92 changes: 92 additions & 0 deletions test/lib/r3x/client/healthchecks_io/response_test.rb
Original file line number Diff line number Diff line change
@@ -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 "#<R3x::Client::HealthchecksIO::Response status=200 success=true>", response.inspect
end
end
end
end
Loading