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.
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "config_loader"
4
+ require_relative "config_validator"
5
+ require_relative "logger_factory"
6
+ require_relative "plugin_loader"
7
+ require_relative "../app/api"
8
+
9
+ module Hooks
10
+ module Core
11
+ # Main builder that orchestrates the webhook server setup
12
+ class Builder
13
+ # Initialize builder with configuration options
14
+ #
15
+ # @param config [String, Hash] Path to config file or config hash
16
+ # @param log [Logger] Custom logger instance
17
+ def initialize(config: nil, log: nil)
18
+ @log = log
19
+ @config_input = config
20
+ end
21
+
22
+ # Build and return Rack-compatible application
23
+ #
24
+ # @return [Object] Rack-compatible application
25
+ def build
26
+ # Load and validate configuration
27
+ config = load_and_validate_config
28
+
29
+ # Create logger unless a custom logger is provided
30
+ if @log.nil?
31
+ @log = LoggerFactory.create(
32
+ log_level: config[:log_level],
33
+ custom_logger: @custom_logger
34
+ )
35
+ end
36
+
37
+ # Load all plugins at boot time
38
+ load_plugins(config)
39
+
40
+ # Load endpoints
41
+ endpoints = load_endpoints(config)
42
+
43
+ # Log startup
44
+ @log.info "starting hooks server v#{Hooks::VERSION}"
45
+ @log.info "config: #{endpoints.size} endpoints loaded"
46
+ @log.info "environment: #{config[:environment]}"
47
+ @log.info "available endpoints: #{endpoints.map { |e| e[:path] }.join(', ')}"
48
+
49
+ # Build and return Grape API class
50
+ Hooks::App::API.create(
51
+ config:,
52
+ endpoints:,
53
+ log: @log
54
+ )
55
+ end
56
+
57
+ private
58
+
59
+ # Load and validate all configuration
60
+ #
61
+ # @return [Hash] Validated global configuration
62
+ def load_and_validate_config
63
+ # Load base config from file/hash and environment
64
+ config = ConfigLoader.load(config_path: @config_input)
65
+
66
+ # Validate global configuration
67
+ ConfigValidator.validate_global_config(config)
68
+ rescue ConfigValidator::ValidationError => e
69
+ raise ConfigurationError, "Configuration validation failed: #{e.message}"
70
+ end
71
+
72
+ # Load and validate endpoint configurations
73
+ #
74
+ # @param config [Hash] Global configuration
75
+ # @return [Array<Hash>] Array of validated endpoint configurations
76
+ def load_endpoints(config)
77
+ endpoints = ConfigLoader.load_endpoints(config[:endpoints_dir])
78
+ ConfigValidator.validate_endpoints(endpoints)
79
+ rescue ConfigValidator::ValidationError => e
80
+ raise ConfigurationError, "Endpoint validation failed: #{e.message}"
81
+ end
82
+
83
+ # Load all plugins at boot time
84
+ #
85
+ # @param config [Hash] Global configuration
86
+ # @return [void]
87
+ def load_plugins(config)
88
+ PluginLoader.load_all_plugins(config)
89
+ rescue => e
90
+ raise ConfigurationError, "Plugin loading failed: #{e.message}"
91
+ end
92
+ end
93
+
94
+ # Configuration error
95
+ class ConfigurationError < StandardError; end
96
+ end
97
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "json"
5
+
6
+ module Hooks
7
+ module Core
8
+ # Loads and merges configuration from files and environment variables
9
+ class ConfigLoader
10
+ DEFAULT_CONFIG = {
11
+ handler_plugin_dir: "./plugins/handlers",
12
+ auth_plugin_dir: "./plugins/auth",
13
+ log_level: "info",
14
+ request_limit: 1_048_576,
15
+ request_timeout: 30,
16
+ root_path: "/webhooks",
17
+ health_path: "/health",
18
+ version_path: "/version",
19
+ environment: "production",
20
+ production: true,
21
+ endpoints_dir: "./config/endpoints",
22
+ use_catchall_route: false,
23
+ symbolize_payload: true,
24
+ normalize_headers: true
25
+ }.freeze
26
+
27
+ # Load and merge configuration from various sources
28
+ #
29
+ # @param config_path [String, Hash] Path to config file or config hash
30
+ # @return [Hash] Merged configuration
31
+ def self.load(config_path: nil)
32
+ config = DEFAULT_CONFIG.dup
33
+
34
+ # Load from file if path provided
35
+ if config_path.is_a?(String) && File.exist?(config_path)
36
+ file_config = load_config_file(config_path)
37
+ config.merge!(file_config) if file_config
38
+ elsif config_path.is_a?(Hash)
39
+ config.merge!(config_path)
40
+ end
41
+
42
+ # Override with environment variables
43
+ config.merge!(load_env_config)
44
+
45
+ # Convert string keys to symbols for consistency
46
+ config = symbolize_keys(config)
47
+
48
+ if config[:environment] == "production"
49
+ config[:production] = true
50
+ else
51
+ config[:production] = false
52
+ end
53
+
54
+ return config
55
+ end
56
+
57
+ # Load endpoint configurations from directory
58
+ #
59
+ # @param endpoints_dir [String] Directory containing endpoint config files
60
+ # @return [Array<Hash>] Array of endpoint configurations
61
+ def self.load_endpoints(endpoints_dir)
62
+ return [] unless endpoints_dir && Dir.exist?(endpoints_dir)
63
+
64
+ endpoints = []
65
+ files = Dir.glob(File.join(endpoints_dir, "*.{yml,yaml,json}"))
66
+
67
+ files.each do |file|
68
+ endpoint_config = load_config_file(file)
69
+ if endpoint_config
70
+ endpoints << symbolize_keys(endpoint_config)
71
+ end
72
+ end
73
+
74
+ endpoints
75
+ end
76
+
77
+ private
78
+
79
+ # Load configuration from YAML or JSON file
80
+ #
81
+ # @param file_path [String] Path to config file
82
+ # @return [Hash, nil] Parsed configuration or nil if error
83
+ def self.load_config_file(file_path)
84
+ content = File.read(file_path)
85
+
86
+ result = case File.extname(file_path).downcase
87
+ when ".json"
88
+ JSON.parse(content)
89
+ when ".yml", ".yaml"
90
+ YAML.safe_load(content, permitted_classes: [Symbol])
91
+ else
92
+ nil
93
+ end
94
+
95
+ result
96
+ rescue => _e
97
+ # In production, we'd log this error
98
+ nil
99
+ end
100
+
101
+ # Load configuration from environment variables
102
+ #
103
+ # @return [Hash] Configuration from ENV vars
104
+ def self.load_env_config
105
+ env_config = {}
106
+
107
+ env_mappings = {
108
+ "HOOKS_HANDLER_PLUGIN_DIR" => :handler_plugin_dir,
109
+ "HOOKS_AUTH_PLUGIN_DIR" => :auth_plugin_dir,
110
+ "HOOKS_LOG_LEVEL" => :log_level,
111
+ "HOOKS_REQUEST_LIMIT" => :request_limit,
112
+ "HOOKS_REQUEST_TIMEOUT" => :request_timeout,
113
+ "HOOKS_ROOT_PATH" => :root_path,
114
+ "HOOKS_HEALTH_PATH" => :health_path,
115
+ "HOOKS_VERSION_PATH" => :version_path,
116
+ "HOOKS_ENVIRONMENT" => :environment,
117
+ "HOOKS_ENDPOINTS_DIR" => :endpoints_dir
118
+ }
119
+
120
+ env_mappings.each do |env_key, config_key|
121
+ value = ENV[env_key]
122
+ next unless value
123
+
124
+ # Convert numeric values
125
+ case config_key
126
+ when :request_limit, :request_timeout
127
+ env_config[config_key] = value.to_i
128
+ else
129
+ env_config[config_key] = value
130
+ end
131
+ end
132
+
133
+ env_config
134
+ end
135
+
136
+ # Recursively convert string keys to symbols
137
+ #
138
+ # @param obj [Hash, Array, Object] Object to convert
139
+ # @return [Hash, Array, Object] Converted object
140
+ def self.symbolize_keys(obj)
141
+ case obj
142
+ when Hash
143
+ obj.transform_keys(&:to_sym).transform_values { |v| symbolize_keys(v) }
144
+ when Array
145
+ obj.map { |v| symbolize_keys(v) }
146
+ else
147
+ obj
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-schema"
4
+ require_relative "../security"
5
+
6
+ module Hooks
7
+ module Core
8
+ # Validates configuration using Dry::Schema
9
+ class ConfigValidator
10
+ # Custom validation error
11
+ class ValidationError < StandardError; end
12
+
13
+ # Global configuration schema
14
+ GLOBAL_CONFIG_SCHEMA = Dry::Schema.Params do
15
+ optional(:handler_dir).filled(:string) # For backward compatibility
16
+ optional(:handler_plugin_dir).filled(:string)
17
+ optional(:auth_plugin_dir).maybe(:string)
18
+ optional(:log_level).filled(:string, included_in?: %w[debug info warn error])
19
+ optional(:request_limit).filled(:integer, gt?: 0)
20
+ optional(:request_timeout).filled(:integer, gt?: 0)
21
+ optional(:root_path).filled(:string)
22
+ optional(:health_path).filled(:string)
23
+ optional(:version_path).filled(:string)
24
+ optional(:environment).filled(:string, included_in?: %w[development production])
25
+ optional(:endpoints_dir).filled(:string)
26
+ optional(:use_catchall_route).filled(:bool)
27
+ optional(:symbolize_payload).filled(:bool)
28
+ optional(:normalize_headers).filled(:bool)
29
+ end
30
+
31
+ # Endpoint configuration schema
32
+ ENDPOINT_CONFIG_SCHEMA = Dry::Schema.Params do
33
+ required(:path).filled(:string)
34
+ required(:handler).filled(:string)
35
+
36
+ optional(:auth).hash do
37
+ required(:type).filled(:string)
38
+ optional(:secret_env_key).filled(:string)
39
+ optional(:header).filled(:string)
40
+ optional(:algorithm).filled(:string)
41
+ optional(:timestamp_header).filled(:string)
42
+ optional(:timestamp_tolerance).filled(:integer, gt?: 0)
43
+ optional(:format).filled(:string)
44
+ optional(:version_prefix).filled(:string)
45
+ optional(:payload_template).filled(:string)
46
+ end
47
+
48
+ optional(:opts).hash
49
+ end
50
+
51
+ # Validate global configuration
52
+ #
53
+ # @param config [Hash] Configuration to validate
54
+ # @return [Hash] Validated configuration
55
+ # @raise [ValidationError] if validation fails
56
+ def self.validate_global_config(config)
57
+ result = GLOBAL_CONFIG_SCHEMA.call(config)
58
+
59
+ if result.failure?
60
+ raise ValidationError, "Invalid global configuration: #{result.errors.to_h}"
61
+ end
62
+
63
+ result.to_h
64
+ end
65
+
66
+ # Validate endpoint configuration with additional security checks
67
+ #
68
+ # @param config [Hash] Endpoint configuration to validate
69
+ # @return [Hash] Validated configuration
70
+ # @raise [ValidationError] if validation fails
71
+ def self.validate_endpoint_config(config)
72
+ result = ENDPOINT_CONFIG_SCHEMA.call(config)
73
+
74
+ if result.failure?
75
+ raise ValidationError, "Invalid endpoint configuration: #{result.errors.to_h}"
76
+ end
77
+
78
+ validated_config = result.to_h
79
+
80
+ # Security: Additional validation for handler name
81
+ handler_name = validated_config[:handler]
82
+ unless valid_handler_name?(handler_name)
83
+ raise ValidationError, "Invalid handler name: #{handler_name}"
84
+ end
85
+
86
+ validated_config
87
+ end
88
+
89
+ # Validate array of endpoint configurations
90
+ #
91
+ # @param endpoints [Array<Hash>] Array of endpoint configurations
92
+ # @return [Array<Hash>] Array of validated configurations
93
+ # @raise [ValidationError] if any validation fails
94
+ def self.validate_endpoints(endpoints)
95
+ validated_endpoints = []
96
+
97
+ endpoints.each_with_index do |endpoint, index|
98
+ begin
99
+ validated_endpoints << validate_endpoint_config(endpoint)
100
+ rescue ValidationError => e
101
+ raise ValidationError, "Endpoint #{index}: #{e.message}"
102
+ end
103
+ end
104
+
105
+ validated_endpoints
106
+ end
107
+
108
+ private
109
+
110
+ # Validate that a handler name is safe
111
+ #
112
+ # @param handler_name [String] The handler name to validate
113
+ # @return [Boolean] true if the handler name is safe, false otherwise
114
+ def self.valid_handler_name?(handler_name)
115
+ # Must be a string
116
+ return false unless handler_name.is_a?(String)
117
+
118
+ # Must not be empty or only whitespace
119
+ return false if handler_name.strip.empty?
120
+
121
+ # Must match a safe pattern: alphanumeric + underscore, starting with uppercase
122
+ return false unless handler_name.match?(/\A[A-Z][a-zA-Z0-9_]*\z/)
123
+
124
+ # Must not be a system/built-in class name
125
+ return false if Hooks::Security::DANGEROUS_CLASSES.include?(handler_name)
126
+
127
+ true
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hooks
4
+ module Log
5
+ class << self
6
+ attr_accessor :instance
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "redacting_logger"
4
+ require "logger"
5
+ require "json"
6
+ require "securerandom"
7
+
8
+ module Hooks
9
+ module Core
10
+ # Factory for creating structured JSON loggers
11
+ class LoggerFactory
12
+ # Create a structured JSON logger
13
+ #
14
+ # @param log_level [String] Log level (debug, info, warn, error)
15
+ # @param custom_logger [Logger] Custom logger instance (optional)
16
+ # @return [Logger] Configured logger instance
17
+ def self.create(log_level: "info", custom_logger: nil)
18
+ return custom_logger if custom_logger
19
+
20
+ $stdout.sync = true # don't buffer - flush immediately
21
+
22
+ # Create a new logger
23
+ logger = RedactingLogger.new(
24
+ $stdout, # The device to log to (defaults to $stdout if not provided)
25
+ redact_patterns: [], # An array of Regexp patterns to redact from the logs
26
+ level: parse_log_level(log_level), # The log level to use
27
+ redacted_msg: "[REDACTED]", # The message to replace the redacted patterns with
28
+ use_default_patterns: true # Whether to use the default built-in patterns or not
29
+ )
30
+
31
+ logger.formatter = json_formatter
32
+ logger
33
+ end
34
+
35
+ private
36
+
37
+ # Parse string log level to Logger constant
38
+ #
39
+ # @param level [String] Log level string
40
+ # @return [Integer] Logger level constant
41
+ def self.parse_log_level(level)
42
+ case level.to_s.downcase
43
+ when "debug" then Logger::DEBUG
44
+ when "info" then Logger::INFO
45
+ when "warn" then Logger::WARN
46
+ when "error" then Logger::ERROR
47
+ else Logger::INFO
48
+ end
49
+ end
50
+
51
+ # JSON formatter for structured logging
52
+ #
53
+ # @return [Proc] Formatter procedure
54
+ def self.json_formatter
55
+ proc do |severity, datetime, progname, msg|
56
+ log_entry = {
57
+ timestamp: datetime.iso8601,
58
+ level: severity.downcase,
59
+ message: msg
60
+ }
61
+
62
+ # Add request context if available in thread local storage
63
+ if Thread.current[:hooks_request_context]
64
+ log_entry.merge!(Thread.current[:hooks_request_context])
65
+ end
66
+
67
+ "#{log_entry.to_json}\n"
68
+ end
69
+ end
70
+ end
71
+
72
+ # Helper for setting request context in logs
73
+ module LogContext
74
+ # Set request context for current thread
75
+ #
76
+ # @param context [Hash] Request context data
77
+ def self.set(context)
78
+ Thread.current[:hooks_request_context] = context
79
+ end
80
+
81
+ # Clear request context for current thread
82
+ def self.clear
83
+ Thread.current[:hooks_request_context] = nil
84
+ end
85
+
86
+ # Execute block with request context
87
+ #
88
+ # @param context [Hash] Request context data
89
+ # @yield Block to execute with context
90
+ def self.with(context)
91
+ old_context = Thread.current[:hooks_request_context]
92
+ Thread.current[:hooks_request_context] = context
93
+ yield
94
+ ensure
95
+ Thread.current[:hooks_request_context] = old_context
96
+ end
97
+ end
98
+ end
99
+ end