hooks-ruby 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +295 -0
- data/bin/bundle +109 -0
- data/bin/erb +27 -0
- data/bin/hooks +27 -0
- data/bin/htmldiff +27 -0
- data/bin/irb +27 -0
- data/bin/ldiff +27 -0
- data/bin/puma +27 -0
- data/bin/pumactl +27 -0
- data/bin/racc +27 -0
- data/bin/rdoc +27 -0
- data/bin/ri +27 -0
- data/bin/rspec +27 -0
- data/bin/rubocop +27 -0
- data/bin/ruby-parse +27 -0
- data/bin/ruby-rewrite +27 -0
- data/bin/rubygems-await +27 -0
- data/bin/sigstore-cli +27 -0
- data/bin/thor +27 -0
- data/config.ru +6 -0
- data/hooks.gemspec +36 -0
- data/lib/hooks/app/api.rb +112 -0
- data/lib/hooks/app/auth/auth.rb +48 -0
- data/lib/hooks/app/endpoints/catchall.rb +84 -0
- data/lib/hooks/app/endpoints/health.rb +20 -0
- data/lib/hooks/app/endpoints/version.rb +18 -0
- data/lib/hooks/app/helpers.rb +95 -0
- data/lib/hooks/core/builder.rb +97 -0
- data/lib/hooks/core/config_loader.rb +152 -0
- data/lib/hooks/core/config_validator.rb +131 -0
- data/lib/hooks/core/log.rb +9 -0
- data/lib/hooks/core/logger_factory.rb +99 -0
- data/lib/hooks/core/plugin_loader.rb +250 -0
- data/lib/hooks/plugins/auth/base.rb +62 -0
- data/lib/hooks/plugins/auth/hmac.rb +313 -0
- data/lib/hooks/plugins/auth/shared_secret.rb +115 -0
- data/lib/hooks/plugins/handlers/base.rb +35 -0
- data/lib/hooks/plugins/handlers/default.rb +21 -0
- data/lib/hooks/plugins/lifecycle.rb +33 -0
- data/lib/hooks/security.rb +17 -0
- data/lib/hooks/utils/normalize.rb +83 -0
- data/lib/hooks/version.rb +5 -0
- data/lib/hooks.rb +29 -0
- metadata +189 -0
data/bin/rdoc
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
#
|
5
|
+
# This file was generated by Bundler.
|
6
|
+
#
|
7
|
+
# The application 'rdoc' is installed as part of a gem, and
|
8
|
+
# this file is here to facilitate running it.
|
9
|
+
#
|
10
|
+
|
11
|
+
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
|
12
|
+
|
13
|
+
bundle_binstub = File.expand_path("bundle", __dir__)
|
14
|
+
|
15
|
+
if File.file?(bundle_binstub)
|
16
|
+
if File.read(bundle_binstub, 300).include?("This file was generated by Bundler")
|
17
|
+
load(bundle_binstub)
|
18
|
+
else
|
19
|
+
abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
|
20
|
+
Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
require "rubygems"
|
25
|
+
require "bundler/setup"
|
26
|
+
|
27
|
+
load Gem.bin_path("rdoc", "rdoc")
|
data/bin/ri
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
#
|
5
|
+
# This file was generated by Bundler.
|
6
|
+
#
|
7
|
+
# The application 'ri' is installed as part of a gem, and
|
8
|
+
# this file is here to facilitate running it.
|
9
|
+
#
|
10
|
+
|
11
|
+
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
|
12
|
+
|
13
|
+
bundle_binstub = File.expand_path("bundle", __dir__)
|
14
|
+
|
15
|
+
if File.file?(bundle_binstub)
|
16
|
+
if File.read(bundle_binstub, 300).include?("This file was generated by Bundler")
|
17
|
+
load(bundle_binstub)
|
18
|
+
else
|
19
|
+
abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
|
20
|
+
Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
require "rubygems"
|
25
|
+
require "bundler/setup"
|
26
|
+
|
27
|
+
load Gem.bin_path("rdoc", "ri")
|
data/bin/rspec
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
#
|
5
|
+
# This file was generated by Bundler.
|
6
|
+
#
|
7
|
+
# The application 'rspec' is installed as part of a gem, and
|
8
|
+
# this file is here to facilitate running it.
|
9
|
+
#
|
10
|
+
|
11
|
+
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
|
12
|
+
|
13
|
+
bundle_binstub = File.expand_path("bundle", __dir__)
|
14
|
+
|
15
|
+
if File.file?(bundle_binstub)
|
16
|
+
if File.read(bundle_binstub, 300).include?("This file was generated by Bundler")
|
17
|
+
load(bundle_binstub)
|
18
|
+
else
|
19
|
+
abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
|
20
|
+
Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
require "rubygems"
|
25
|
+
require "bundler/setup"
|
26
|
+
|
27
|
+
load Gem.bin_path("rspec-core", "rspec")
|
data/bin/rubocop
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
#
|
5
|
+
# This file was generated by Bundler.
|
6
|
+
#
|
7
|
+
# The application 'rubocop' is installed as part of a gem, and
|
8
|
+
# this file is here to facilitate running it.
|
9
|
+
#
|
10
|
+
|
11
|
+
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
|
12
|
+
|
13
|
+
bundle_binstub = File.expand_path("bundle", __dir__)
|
14
|
+
|
15
|
+
if File.file?(bundle_binstub)
|
16
|
+
if File.read(bundle_binstub, 300).include?("This file was generated by Bundler")
|
17
|
+
load(bundle_binstub)
|
18
|
+
else
|
19
|
+
abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
|
20
|
+
Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
require "rubygems"
|
25
|
+
require "bundler/setup"
|
26
|
+
|
27
|
+
load Gem.bin_path("rubocop", "rubocop")
|
data/bin/ruby-parse
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
#
|
5
|
+
# This file was generated by Bundler.
|
6
|
+
#
|
7
|
+
# The application 'ruby-parse' is installed as part of a gem, and
|
8
|
+
# this file is here to facilitate running it.
|
9
|
+
#
|
10
|
+
|
11
|
+
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
|
12
|
+
|
13
|
+
bundle_binstub = File.expand_path("bundle", __dir__)
|
14
|
+
|
15
|
+
if File.file?(bundle_binstub)
|
16
|
+
if File.read(bundle_binstub, 300).include?("This file was generated by Bundler")
|
17
|
+
load(bundle_binstub)
|
18
|
+
else
|
19
|
+
abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
|
20
|
+
Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
require "rubygems"
|
25
|
+
require "bundler/setup"
|
26
|
+
|
27
|
+
load Gem.bin_path("parser", "ruby-parse")
|
data/bin/ruby-rewrite
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
#
|
5
|
+
# This file was generated by Bundler.
|
6
|
+
#
|
7
|
+
# The application 'ruby-rewrite' is installed as part of a gem, and
|
8
|
+
# this file is here to facilitate running it.
|
9
|
+
#
|
10
|
+
|
11
|
+
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
|
12
|
+
|
13
|
+
bundle_binstub = File.expand_path("bundle", __dir__)
|
14
|
+
|
15
|
+
if File.file?(bundle_binstub)
|
16
|
+
if File.read(bundle_binstub, 300).include?("This file was generated by Bundler")
|
17
|
+
load(bundle_binstub)
|
18
|
+
else
|
19
|
+
abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
|
20
|
+
Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
require "rubygems"
|
25
|
+
require "bundler/setup"
|
26
|
+
|
27
|
+
load Gem.bin_path("parser", "ruby-rewrite")
|
data/bin/rubygems-await
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
#
|
5
|
+
# This file was generated by Bundler.
|
6
|
+
#
|
7
|
+
# The application 'rubygems-await' is installed as part of a gem, and
|
8
|
+
# this file is here to facilitate running it.
|
9
|
+
#
|
10
|
+
|
11
|
+
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
|
12
|
+
|
13
|
+
bundle_binstub = File.expand_path("bundle", __dir__)
|
14
|
+
|
15
|
+
if File.file?(bundle_binstub)
|
16
|
+
if File.read(bundle_binstub, 300).include?("This file was generated by Bundler")
|
17
|
+
load(bundle_binstub)
|
18
|
+
else
|
19
|
+
abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
|
20
|
+
Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
require "rubygems"
|
25
|
+
require "bundler/setup"
|
26
|
+
|
27
|
+
load Gem.bin_path("rubygems-await", "rubygems-await")
|
data/bin/sigstore-cli
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
#
|
5
|
+
# This file was generated by Bundler.
|
6
|
+
#
|
7
|
+
# The application 'sigstore-cli' is installed as part of a gem, and
|
8
|
+
# this file is here to facilitate running it.
|
9
|
+
#
|
10
|
+
|
11
|
+
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
|
12
|
+
|
13
|
+
bundle_binstub = File.expand_path("bundle", __dir__)
|
14
|
+
|
15
|
+
if File.file?(bundle_binstub)
|
16
|
+
if File.read(bundle_binstub, 300).include?("This file was generated by Bundler")
|
17
|
+
load(bundle_binstub)
|
18
|
+
else
|
19
|
+
abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
|
20
|
+
Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
require "rubygems"
|
25
|
+
require "bundler/setup"
|
26
|
+
|
27
|
+
load Gem.bin_path("sigstore-cli", "sigstore-cli")
|
data/bin/thor
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
#
|
5
|
+
# This file was generated by Bundler.
|
6
|
+
#
|
7
|
+
# The application 'thor' is installed as part of a gem, and
|
8
|
+
# this file is here to facilitate running it.
|
9
|
+
#
|
10
|
+
|
11
|
+
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
|
12
|
+
|
13
|
+
bundle_binstub = File.expand_path("bundle", __dir__)
|
14
|
+
|
15
|
+
if File.file?(bundle_binstub)
|
16
|
+
if File.read(bundle_binstub, 300).include?("This file was generated by Bundler")
|
17
|
+
load(bundle_binstub)
|
18
|
+
else
|
19
|
+
abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
|
20
|
+
Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
require "rubygems"
|
25
|
+
require "bundler/setup"
|
26
|
+
|
27
|
+
load Gem.bin_path("thor", "thor")
|
data/config.ru
ADDED
data/hooks.gemspec
ADDED
@@ -0,0 +1,36 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "lib/hooks/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |spec|
|
6
|
+
spec.name = "hooks-ruby"
|
7
|
+
spec.version = Hooks::VERSION
|
8
|
+
spec.authors = ["github", "GrantBirki"]
|
9
|
+
spec.license = "MIT"
|
10
|
+
|
11
|
+
spec.summary = "A Pluggable Webhook Server Framework written in Ruby"
|
12
|
+
spec.description = <<~SPEC_DESC
|
13
|
+
A Pluggable Webhook Server Framework written in Ruby
|
14
|
+
SPEC_DESC
|
15
|
+
|
16
|
+
spec.homepage = "https://github.com/github/hooks"
|
17
|
+
spec.metadata = {
|
18
|
+
"bug_tracker_uri" => "https://github.com/github/hooks/issues"
|
19
|
+
}
|
20
|
+
|
21
|
+
spec.add_dependency "redacting-logger", "~> 1.5"
|
22
|
+
spec.add_dependency "retryable", "~> 3.0", ">= 3.0.5"
|
23
|
+
spec.add_dependency "dry-schema", "~> 1.14", ">= 1.14.1"
|
24
|
+
spec.add_dependency "grape", "~> 2.3"
|
25
|
+
spec.add_dependency "grape-swagger", "~> 2.1", ">= 2.1.2"
|
26
|
+
spec.add_dependency "puma", "~> 6.6"
|
27
|
+
|
28
|
+
spec.required_ruby_version = Gem::Requirement.new(">= 3.2.2")
|
29
|
+
|
30
|
+
spec.files = %w[LICENSE README.md hooks.gemspec config.ru]
|
31
|
+
spec.files += Dir.glob("lib/**/*.rb")
|
32
|
+
spec.files += Dir.glob("bin/*")
|
33
|
+
spec.bindir = "bin"
|
34
|
+
spec.executables = ["hooks"]
|
35
|
+
spec.require_paths = ["lib"]
|
36
|
+
end
|
@@ -0,0 +1,112 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "grape"
|
4
|
+
require "json"
|
5
|
+
require "securerandom"
|
6
|
+
require_relative "helpers"
|
7
|
+
require_relative "auth/auth"
|
8
|
+
require_relative "../plugins/handlers/base"
|
9
|
+
require_relative "../plugins/handlers/default"
|
10
|
+
require_relative "../core/logger_factory"
|
11
|
+
require_relative "../core/log"
|
12
|
+
|
13
|
+
# Import all core endpoint classes dynamically
|
14
|
+
Dir[File.join(__dir__, "endpoints/**/*.rb")].sort.each { |file| require file }
|
15
|
+
|
16
|
+
module Hooks
|
17
|
+
module App
|
18
|
+
# Factory for creating configured Grape API classes
|
19
|
+
class API
|
20
|
+
include Hooks::App::Helpers
|
21
|
+
include Hooks::App::Auth
|
22
|
+
|
23
|
+
class << self
|
24
|
+
attr_reader :start_time
|
25
|
+
end
|
26
|
+
|
27
|
+
# Create a new configured API class
|
28
|
+
def self.create(config:, endpoints:, log:)
|
29
|
+
@start_time = Time.now
|
30
|
+
|
31
|
+
Hooks::Log.instance = log
|
32
|
+
|
33
|
+
api_class = Class.new(Grape::API) do
|
34
|
+
content_type :json, "application/json"
|
35
|
+
content_type :txt, "text/plain"
|
36
|
+
content_type :xml, "application/xml"
|
37
|
+
content_type :any, "*/*"
|
38
|
+
format :txt
|
39
|
+
default_format :txt
|
40
|
+
end
|
41
|
+
|
42
|
+
api_class.class_eval do
|
43
|
+
helpers Helpers, Auth
|
44
|
+
|
45
|
+
mount Hooks::App::HealthEndpoint => config[:health_path]
|
46
|
+
mount Hooks::App::VersionEndpoint => config[:version_path]
|
47
|
+
|
48
|
+
endpoints.each do |endpoint_config|
|
49
|
+
full_path = "#{config[:root_path]}#{endpoint_config[:path]}"
|
50
|
+
handler_class_name = endpoint_config[:handler]
|
51
|
+
|
52
|
+
post(full_path) do
|
53
|
+
request_id = uuid
|
54
|
+
request_context = {
|
55
|
+
request_id:,
|
56
|
+
path: full_path,
|
57
|
+
handler: handler_class_name
|
58
|
+
}
|
59
|
+
|
60
|
+
Core::LogContext.with(request_context) do
|
61
|
+
begin
|
62
|
+
enforce_request_limits(config)
|
63
|
+
request.body.rewind
|
64
|
+
raw_body = request.body.read
|
65
|
+
|
66
|
+
if endpoint_config[:auth]
|
67
|
+
log.info "validating request (id: #{request_id}, handler: #{handler_class_name})"
|
68
|
+
validate_auth!(raw_body, headers, endpoint_config, config)
|
69
|
+
end
|
70
|
+
|
71
|
+
payload = parse_payload(raw_body, headers, symbolize: config[:symbolize_payload])
|
72
|
+
handler = load_handler(handler_class_name)
|
73
|
+
normalized_headers = config[:normalize_headers] ? Hooks::Utils::Normalize.headers(headers) : headers
|
74
|
+
|
75
|
+
response = handler.call(
|
76
|
+
payload:,
|
77
|
+
headers: normalized_headers,
|
78
|
+
config: endpoint_config
|
79
|
+
)
|
80
|
+
|
81
|
+
log.info "request processed successfully (id: #{request_id}, handler: #{handler_class_name})"
|
82
|
+
status 200
|
83
|
+
content_type "application/json"
|
84
|
+
(response || { status: "ok" }).to_json
|
85
|
+
rescue => e
|
86
|
+
log.error "request failed: #{e.message} (id: #{request_id}, handler: #{handler_class_name})"
|
87
|
+
error_response = {
|
88
|
+
error: e.message,
|
89
|
+
code: determine_error_code(e),
|
90
|
+
request_id: request_id
|
91
|
+
}
|
92
|
+
error_response[:backtrace] = e.backtrace unless config[:production]
|
93
|
+
status error_response[:code]
|
94
|
+
content_type "application/json"
|
95
|
+
error_response.to_json
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
if config[:use_catchall_route]
|
102
|
+
route_path = Hooks::App::CatchallEndpoint.mount_path(config)
|
103
|
+
route_block = Hooks::App::CatchallEndpoint.route_block(config, log)
|
104
|
+
post(route_path, &route_block)
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
api_class
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "../../core/plugin_loader"
|
4
|
+
|
5
|
+
module Hooks
|
6
|
+
module App
|
7
|
+
# Provides authentication helpers for verifying incoming requests.
|
8
|
+
#
|
9
|
+
# @example Usage
|
10
|
+
# include Hooks::App::Auth
|
11
|
+
# validate_auth!(payload, headers, endpoint_config)
|
12
|
+
module Auth
|
13
|
+
# Verifies the incoming request using the configured authentication method.
|
14
|
+
#
|
15
|
+
# @param payload [String, Hash] The request payload to authenticate.
|
16
|
+
# @param headers [Hash] The request headers.
|
17
|
+
# @param endpoint_config [Hash] The endpoint configuration, must include :auth key.
|
18
|
+
# @param global_config [Hash] The global configuration (optional, for compatibility).
|
19
|
+
# @raise [StandardError] Raises error if authentication fails or is misconfigured.
|
20
|
+
# @return [void]
|
21
|
+
# @note This method will halt execution with an error if authentication fails.
|
22
|
+
def validate_auth!(payload, headers, endpoint_config, global_config = {})
|
23
|
+
auth_config = endpoint_config[:auth]
|
24
|
+
|
25
|
+
# Security: Ensure auth type is present and valid
|
26
|
+
auth_type = auth_config&.dig(:type)
|
27
|
+
unless auth_type&.is_a?(String) && !auth_type.strip.empty?
|
28
|
+
error!("authentication configuration missing or invalid", 500)
|
29
|
+
end
|
30
|
+
|
31
|
+
# Get auth plugin from loaded plugins registry (boot-time loaded only)
|
32
|
+
begin
|
33
|
+
auth_class = Core::PluginLoader.get_auth_plugin(auth_type)
|
34
|
+
rescue => e
|
35
|
+
error!("unsupported auth type '#{auth_type}'", 400)
|
36
|
+
end
|
37
|
+
|
38
|
+
unless auth_class.valid?(
|
39
|
+
payload:,
|
40
|
+
headers:,
|
41
|
+
config: endpoint_config
|
42
|
+
)
|
43
|
+
error!("authentication failed", 401)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,84 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "grape"
|
4
|
+
require_relative "../../plugins/handlers/default"
|
5
|
+
require_relative "../helpers"
|
6
|
+
|
7
|
+
module Hooks
|
8
|
+
module App
|
9
|
+
class CatchallEndpoint < Grape::API
|
10
|
+
include Hooks::App::Helpers
|
11
|
+
|
12
|
+
def self.mount_path(config)
|
13
|
+
"#{config[:root_path]}/*path"
|
14
|
+
end
|
15
|
+
|
16
|
+
def self.route_block(captured_config, captured_logger)
|
17
|
+
proc do
|
18
|
+
request_id = uuid
|
19
|
+
|
20
|
+
# Use captured values
|
21
|
+
config = captured_config
|
22
|
+
log = captured_logger
|
23
|
+
|
24
|
+
# Set request context for logging
|
25
|
+
request_context = {
|
26
|
+
request_id: request_id,
|
27
|
+
path: "/#{params[:path]}",
|
28
|
+
handler: "DefaultHandler"
|
29
|
+
}
|
30
|
+
|
31
|
+
Hooks::Core::LogContext.with(request_context) do
|
32
|
+
begin
|
33
|
+
# Enforce request limits
|
34
|
+
enforce_request_limits(config)
|
35
|
+
|
36
|
+
# Get raw body for payload parsing
|
37
|
+
request.body.rewind
|
38
|
+
raw_body = request.body.read
|
39
|
+
|
40
|
+
# Parse payload
|
41
|
+
payload = parse_payload(raw_body, headers)
|
42
|
+
|
43
|
+
# Use default handler
|
44
|
+
handler = DefaultHandler.new
|
45
|
+
|
46
|
+
# Call handler
|
47
|
+
response = handler.call(
|
48
|
+
payload: payload,
|
49
|
+
headers: headers,
|
50
|
+
config: {}
|
51
|
+
)
|
52
|
+
|
53
|
+
log.info "request processed successfully with default handler (id: #{request_id})"
|
54
|
+
|
55
|
+
# Return response as JSON string when using txt format
|
56
|
+
status 200
|
57
|
+
content_type "application/json"
|
58
|
+
(response || { status: "ok" }).to_json
|
59
|
+
|
60
|
+
rescue StandardError => e
|
61
|
+
log.error "request failed: #{e.message} (id: #{request_id})"
|
62
|
+
|
63
|
+
# Return error response
|
64
|
+
error_response = {
|
65
|
+
error: e.message,
|
66
|
+
code: determine_error_code(e),
|
67
|
+
request_id: request_id
|
68
|
+
}
|
69
|
+
|
70
|
+
# Add backtrace in all environments except production
|
71
|
+
unless config[:production] == true
|
72
|
+
error_response[:backtrace] = e.backtrace
|
73
|
+
end
|
74
|
+
|
75
|
+
status error_response[:code]
|
76
|
+
content_type "application/json"
|
77
|
+
error_response.to_json
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "grape"
|
4
|
+
require_relative "../../version"
|
5
|
+
|
6
|
+
module Hooks
|
7
|
+
module App
|
8
|
+
class HealthEndpoint < Grape::API
|
9
|
+
get do
|
10
|
+
content_type "application/json"
|
11
|
+
{
|
12
|
+
status: "healthy",
|
13
|
+
timestamp: Time.now.iso8601,
|
14
|
+
version: Hooks::VERSION,
|
15
|
+
uptime_seconds: (Time.now - Hooks::App::API.start_time).to_i
|
16
|
+
}.to_json
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "grape"
|
4
|
+
require_relative "../../version"
|
5
|
+
|
6
|
+
module Hooks
|
7
|
+
module App
|
8
|
+
class VersionEndpoint < Grape::API
|
9
|
+
get do
|
10
|
+
content_type "application/json"
|
11
|
+
{
|
12
|
+
version: Hooks::VERSION,
|
13
|
+
timestamp: Time.now.iso8601
|
14
|
+
}.to_json
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,95 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "securerandom"
|
4
|
+
require_relative "../security"
|
5
|
+
require_relative "../core/plugin_loader"
|
6
|
+
|
7
|
+
module Hooks
|
8
|
+
module App
|
9
|
+
module Helpers
|
10
|
+
# Generate a unique identifier (UUID)
|
11
|
+
#
|
12
|
+
# @return [String] a new UUID string
|
13
|
+
def uuid
|
14
|
+
SecureRandom.uuid
|
15
|
+
end
|
16
|
+
|
17
|
+
# Enforce request size and timeout limits
|
18
|
+
#
|
19
|
+
# @param config [Hash] The configuration hash, must include :request_limit
|
20
|
+
# @raise [StandardError] Halts with error if request body is too large
|
21
|
+
# @return [void]
|
22
|
+
# @note Timeout enforcement should be handled at the server level (e.g., Puma)
|
23
|
+
def enforce_request_limits(config)
|
24
|
+
# Check content length (handle different header formats and sources)
|
25
|
+
content_length = headers["Content-Length"] || headers["CONTENT_LENGTH"] ||
|
26
|
+
headers["content-length"] || headers["HTTP_CONTENT_LENGTH"] ||
|
27
|
+
env["CONTENT_LENGTH"] || env["HTTP_CONTENT_LENGTH"]
|
28
|
+
|
29
|
+
# Also try to get from request object directly
|
30
|
+
content_length ||= request.content_length if respond_to?(:request) && request.respond_to?(:content_length)
|
31
|
+
|
32
|
+
content_length = content_length&.to_i
|
33
|
+
|
34
|
+
if content_length && content_length > config[:request_limit]
|
35
|
+
error!("request body too large", 413)
|
36
|
+
end
|
37
|
+
|
38
|
+
# Note: Timeout enforcement would typically be handled at the server level (Puma, etc.)
|
39
|
+
end
|
40
|
+
|
41
|
+
# Parse request payload
|
42
|
+
#
|
43
|
+
# @param raw_body [String] The raw request body
|
44
|
+
# @param headers [Hash] The request headers
|
45
|
+
# @param symbolize [Boolean] Whether to symbolize keys in parsed JSON (default: true)
|
46
|
+
# @return [Hash, String] Parsed JSON as Hash (optionally symbolized), or raw body if not JSON
|
47
|
+
def parse_payload(raw_body, headers, symbolize: true)
|
48
|
+
content_type = headers["Content-Type"] || headers["CONTENT_TYPE"] || headers["content-type"] || headers["HTTP_CONTENT_TYPE"]
|
49
|
+
|
50
|
+
# Try to parse as JSON if content type suggests it or if it looks like JSON
|
51
|
+
if content_type&.include?("application/json") || (raw_body.strip.start_with?("{", "[") rescue false)
|
52
|
+
begin
|
53
|
+
parsed_payload = JSON.parse(raw_body)
|
54
|
+
parsed_payload = parsed_payload.transform_keys(&:to_sym) if symbolize && parsed_payload.is_a?(Hash)
|
55
|
+
return parsed_payload
|
56
|
+
rescue JSON::ParserError
|
57
|
+
# If JSON parsing fails, return raw body
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
# Return raw body for all other cases
|
62
|
+
raw_body
|
63
|
+
end
|
64
|
+
|
65
|
+
# Load handler class
|
66
|
+
#
|
67
|
+
# @param handler_class_name [String] The name of the handler class to load
|
68
|
+
# @return [Object] An instance of the loaded handler class
|
69
|
+
# @raise [StandardError] If handler cannot be found
|
70
|
+
def load_handler(handler_class_name)
|
71
|
+
# Get handler class from loaded plugins registry (boot-time loaded only)
|
72
|
+
begin
|
73
|
+
handler_class = Core::PluginLoader.get_handler_plugin(handler_class_name)
|
74
|
+
return handler_class.new
|
75
|
+
rescue => e
|
76
|
+
error!("failed to get handler '#{handler_class_name}': #{e.message}", 500)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
private
|
81
|
+
|
82
|
+
# Determine HTTP error code from exception
|
83
|
+
#
|
84
|
+
# @param exception [Exception] The exception to map to an HTTP status code
|
85
|
+
# @return [Integer] The HTTP status code (400, 501, or 500)
|
86
|
+
def determine_error_code(exception)
|
87
|
+
case exception
|
88
|
+
when ArgumentError then 400
|
89
|
+
when NotImplementedError then 501
|
90
|
+
else 500
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|