hookdeck 0.1.0

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,31 @@
1
+ module Hookdeck
2
+ class Configuration
3
+ DEFAULTS = {
4
+ api_base: 'https://api.hookdeck.com',
5
+ api_version: '2024-09-01'
6
+ }.freeze
7
+
8
+ attr_accessor :api_key, :api_base, :api_version
9
+
10
+ def initialize
11
+ DEFAULTS.each do |key, value|
12
+ instance_variable_set("@#{key}", value)
13
+ end
14
+ end
15
+
16
+ # Validates the configuration.
17
+ #
18
+ # @raise [Hookdeck::ValidationError] If the API key is not provided.
19
+ def validate!
20
+ validate_api_version!
21
+ end
22
+
23
+ private
24
+
25
+ def validate_api_version!
26
+ return if api_version.match?(/^\d{4}-\d{2}-\d{2}$/)
27
+
28
+ raise ValidationError, "Invalid API version format. Expected YYYY-MM-DD, got #{api_version}"
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,123 @@
1
+ module Hookdeck
2
+ class Error < StandardError
3
+ attr_reader :message, :request_id
4
+
5
+ def initialize(message = nil, request_id = nil)
6
+ @message = message
7
+ @request_id = request_id
8
+ super(error_message)
9
+ end
10
+
11
+ private
12
+
13
+ def error_message
14
+ request_id ? "#{message} (Request ID: #{request_id})" : message
15
+ end
16
+ end
17
+
18
+ class ValidationError < Error; end
19
+
20
+ class ApiError < Error
21
+ attr_reader :response,
22
+ :handled,
23
+ :status,
24
+ :message,
25
+ :data,
26
+ :request_id
27
+
28
+ def initialize(response = nil)
29
+ @response = response
30
+ parse_error_response(response&.dig(:body))
31
+ @request_id = response&.dig(:headers, 'x-request-id')
32
+ super(message, request_id)
33
+ end
34
+
35
+ private
36
+
37
+ def parse_error_response(body)
38
+ error_data = parse_body(body)
39
+ @handled = error_data['handled']
40
+ @status = error_data['status']
41
+ @message = error_data['message']
42
+ @data = error_data['data']
43
+ end
44
+
45
+ def parse_body(body)
46
+ return {} unless body
47
+
48
+ body.is_a?(String) ? JSON.parse(body) : body
49
+ rescue JSON::ParserError
50
+ {
51
+ 'handled' => true,
52
+ 'status' => response&.dig(:status),
53
+ 'message' => body.to_s,
54
+ 'data' => {}
55
+ }
56
+ end
57
+ end
58
+
59
+ class BadRequestError < ApiError
60
+ def initialize(response)
61
+ super
62
+ @status = 400
63
+ end
64
+ end
65
+
66
+ class UnauthorizedError < ApiError
67
+ def initialize(response)
68
+ super
69
+ @status = 401
70
+ end
71
+ end
72
+
73
+ class ForbiddenError < ApiError
74
+ def initialize(response)
75
+ super
76
+ @status = 403
77
+ end
78
+ end
79
+
80
+ class NotFoundError < ApiError
81
+ def initialize(response)
82
+ super
83
+ @status = 404
84
+ end
85
+ end
86
+
87
+ class UnprocessableEntityError < ApiError
88
+ def initialize(response)
89
+ super
90
+ @status = 422
91
+ end
92
+ end
93
+
94
+ class RateLimitError < ApiError
95
+ attr_reader :retry_after
96
+
97
+ def initialize(response)
98
+ super
99
+ @status = 429
100
+ @retry_after = response&.dig(:headers, 'retry-after')&.to_i
101
+ end
102
+ end
103
+
104
+ class ServerError < ApiError
105
+ def initialize(response)
106
+ super
107
+ @status = response&.dig(:status) || 500
108
+ end
109
+ end
110
+
111
+ class ConnectionError < Error
112
+ attr_reader :original_error
113
+
114
+ def initialize(error)
115
+ @original_error = error
116
+ super(error.message)
117
+ end
118
+ end
119
+
120
+ class TimeoutError < ConnectionError; end
121
+
122
+ class SSLError < ConnectionError; end
123
+ end
@@ -0,0 +1,39 @@
1
+ class HttpClient
2
+ attr_reader :config
3
+
4
+ def initialize(config)
5
+ @config = config
6
+ @connection_pools = {}
7
+ end
8
+
9
+ def connection
10
+ Thread.current[:hookdeck_client] ||= build_connection
11
+ end
12
+
13
+ def build_connection
14
+ Faraday.new(url: config.api_base) do |faraday|
15
+ setup_request_middleware(faraday)
16
+ setup_response_middleware(faraday)
17
+ setup_error_handling(faraday)
18
+ faraday.adapter :net_http
19
+ end
20
+ end
21
+
22
+ def setup_request_middleware(faraday)
23
+ faraday.request :json
24
+ # faraday.request :retry, max: config.max_retries, interval: config.retry_interval
25
+ faraday.request :authorization, 'Bearer', config.api_key
26
+
27
+ # faraday.use Middleware::RequestId, prefix: config.request_id_prefix
28
+ # faraday.use Middleware::Logging, logger: config.logger
29
+ end
30
+
31
+ def setup_response_middleware(faraday)
32
+ faraday.response :json, content_type: /\bjson$/
33
+ # faraday.response :logger, config.logger, bodies: true if config.logger
34
+ end
35
+
36
+ def setup_error_handling(faraday)
37
+ # faraday.use Middleware::ErrorHandler
38
+ end
39
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HookDeck
4
+ module Middleware
5
+ # ErrorHandler is a Faraday middleware that provides standardized error handling
6
+ # for HTTP requests. It catches Faraday errors and converts them into specific
7
+ # HookDeck error types based on the response status and error type
8
+ #
9
+ class ErrorHandler < Faraday::Middleware
10
+ # Executes the middleware
11
+ # @param env [Hash] The Faraday environment hash
12
+ # @raise [HookDeck::Error] Various error types based on the response
13
+ def call(env)
14
+ @app.call(env)
15
+ rescue Faraday::Error => e
16
+ handle_error(e, env)
17
+ end
18
+
19
+ private
20
+
21
+ # Maps Faraday errors to HookDeck-specific errors
22
+ # @param error [Faraday::Error] The original Faraday error
23
+ # @param _env [Hash] The Faraday environment hash
24
+ # @raise [HookDeck::Error] Converted error type
25
+ def handle_error(error, _env)
26
+ case error
27
+ when Faraday::ClientError
28
+ handle_client_error(error.response)
29
+ when Faraday::ServerError
30
+ handle_server_error(error.response)
31
+ when Faraday::ConnectionFailed
32
+ raise ConnectionError, error
33
+ when Faraday::TimeoutError
34
+ raise TimeoutError, error
35
+ when Faraday::SSLError
36
+ raise SSLError, error
37
+ else
38
+ raise Error, error.message
39
+ end
40
+ end
41
+
42
+ # Maps HTTP status codes to specific client errors
43
+ # @param response [Hash] The error response hash
44
+ # @raise [HookDeck::Error] Specific client error type
45
+ def handle_client_error(response)
46
+ error_class = case response[:status]
47
+ when 400 then BadRequestError
48
+ when 401 then UnauthorizedError
49
+ when 403 then ForbiddenError
50
+ when 404 then NotFoundError
51
+ when 409 then ConflictError
52
+ when 422 then UnprocessableEntityError
53
+ when 429 then RateLimitError
54
+ else ApiError
55
+ end
56
+
57
+ raise error_class, response
58
+ end
59
+
60
+ # Handles server-side errors
61
+ # @param response [Hash] The error response hash
62
+ # @raise [HookDeck::ServerError] Server error
63
+ def handle_server_error(response)
64
+ raise ServerError, response
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HookDeck
4
+ module Middleware
5
+ # Logging middleware that provides detailed request/response logging with security-aware filtering.
6
+ # Automatically redacts sensitive information from headers and request/response bodies.
7
+ #
8
+ class Logging < Faraday::Middleware
9
+ # Initialize the logging middleware
10
+ # @param app [#call] The Faraday app
11
+ # @param logger [Logger, nil] Optional custom logger (defaults to STDOUT logger)
12
+ def initialize(app, logger: nil)
13
+ super(app)
14
+ @logger = logger || default_logger
15
+ end
16
+
17
+ # Executes the middleware, logging request and response details
18
+ # @param env [Hash] The Faraday environment hash
19
+ def call(env)
20
+ start_time = Time.now
21
+ log_request(env)
22
+
23
+ @app.call(env).on_complete do |response_env|
24
+ duration = Time.now - start_time
25
+ log_response(response_env, duration)
26
+ end
27
+ rescue StandardError => e
28
+ log_error(e)
29
+ raise
30
+ end
31
+
32
+ private
33
+
34
+ # Logs request details with sensitive data filtered
35
+ def log_request(env)
36
+ @logger.info do
37
+ {
38
+ method: env.method.upcase,
39
+ url: env.url.to_s,
40
+ body: sanitize_body(env.body),
41
+ headers: sanitize_headers(env.request_headers),
42
+ request_id: env.request_headers['X-Request-Id']
43
+ }.to_json
44
+ end
45
+ end
46
+
47
+ # Logs response details with timing information
48
+ def log_response(env, duration)
49
+ @logger.info do
50
+ {
51
+ method: env.method.upcase,
52
+ url: env.url.to_s,
53
+ status: env.status,
54
+ duration: duration.round(3),
55
+ headers: sanitize_headers(env.response_headers),
56
+ request_id: env.request_headers['X-Request-Id']
57
+ }.to_json
58
+ end
59
+ end
60
+
61
+ # Logs error details with backtrace
62
+ # @param error [StandardError] The caught error
63
+ def log_error(error)
64
+ @logger.error do
65
+ {
66
+ error: error.class.name,
67
+ message: error.message,
68
+ backtrace: error.backtrace&.first(5)
69
+ }.to_json
70
+ end
71
+ end
72
+
73
+ # Sanitizes request/response body by filtering sensitive data
74
+ # @param body [String, nil] The raw body
75
+ # @return [Hash, String, nil] Sanitized body
76
+ def sanitize_body(body)
77
+ return nil if body.nil?
78
+ return body unless body.is_a?(String)
79
+
80
+ begin
81
+ parsed = JSON.parse(body)
82
+ sanitize_hash(parsed)
83
+ rescue JSON::ParserError
84
+ 'Unparseable body'
85
+ end
86
+ end
87
+
88
+ # Recursively sanitizes hash values
89
+ # @param hash [Hash] Hash to sanitize
90
+ # @return [Hash] Sanitized hash
91
+ def sanitize_hash(hash)
92
+ hash.each_with_object({}) do |(key, value), sanitized|
93
+ sanitized[key] = if sensitive_key?(key)
94
+ '[FILTERED]'
95
+ elsif value.is_a?(Hash)
96
+ sanitize_hash(value)
97
+ elsif value.is_a?(Array)
98
+ value.map { |v| v.is_a?(Hash) ? sanitize_hash(v) : v }
99
+ else
100
+ value
101
+ end
102
+ end
103
+ end
104
+
105
+ # Sanitizes headers by filtering sensitive values
106
+ # @param headers [Hash] Headers to sanitize
107
+ # @return [Hash] Sanitized headers
108
+ def sanitize_headers(headers)
109
+ headers.each_with_object({}) do |(key, value), sanitized|
110
+ sanitized[key] = if sensitive_header?(key)
111
+ '[FILTERED]'
112
+ else
113
+ value
114
+ end
115
+ end
116
+ end
117
+
118
+ # Checks if a key contains sensitive information
119
+ # @param key [String, Symbol] Key to check
120
+ # @return [Boolean] true if sensitive
121
+ def sensitive_key?(key)
122
+ key.to_s.downcase.match?(/password|token|key|secret|credential|auth/)
123
+ end
124
+
125
+ # Checks if a header contains sensitive information
126
+ # @param header [String, Symbol] Header to check
127
+ # @return [Boolean] true if sensitive
128
+ def sensitive_header?(header)
129
+ header.to_s.downcase.match?(/authorization|x-api-key|cookie/)
130
+ end
131
+
132
+ # Creates a default logger if none provided
133
+ # @return [Logger] Configured logger
134
+ def default_logger
135
+ logger = Logger.new($stdout)
136
+ logger.progname = 'hookdeck'
137
+ logger.formatter = proc do |severity, datetime, progname, msg|
138
+ "#{datetime} [#{progname}] #{severity}: #{msg}\n"
139
+ end
140
+ logger
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HookDeck
4
+ module Middleware
5
+ # Middleware that automatically generates and manages request IDs for HTTP requests.
6
+ # Adds both server and client request IDs to facilitate request tracking and debugging.
7
+ #
8
+ class RequestId < Faraday::Middleware
9
+ # Initialize the request ID middleware
10
+ # @param app [#call] The Faraday app
11
+ # @param options [Hash] Configuration options
12
+ # @option options [String] :prefix ('req') Prefix for generated request IDs
13
+ def initialize(app, options = {})
14
+ super(app)
15
+ @prefix = options.fetch(:prefix, 'req')
16
+ end
17
+
18
+ # Executes the middleware, adding and tracking request IDs
19
+ def call(env)
20
+ env.request_headers['X-Request-Id'] ||= generate_request_id
21
+ env.request_headers['X-Client-Request-Id'] ||= generate_request_id
22
+
23
+ @app.call(env).on_complete do |response_env|
24
+ # Store request IDs for potential error handling
25
+ response_env[:hookdeck_request_id] = response_env.response_headers['x-request-id']
26
+ response_env[:hookdeck_client_request_id] = env.request_headers['X-Client-Request-Id']
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ # Generates a unique request ID with configured prefix
33
+ # @return [String] Request ID in format "prefix_hexstring"
34
+ def generate_request_id
35
+ "#{@prefix}_#{SecureRandom.hex(16)}"
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,24 @@
1
+ module Hookdeck
2
+ module Resources
3
+ # @example List recent attempts
4
+ # client.attempts.list(limit: 10)
5
+ #
6
+ # @example Retrieve specific attempt
7
+ # attempt = client.attempts.retrieve('atm_123')
8
+ #
9
+ class Attempt < Base
10
+ def list(params = {})
11
+ get('attempts', params)
12
+ end
13
+
14
+ # Retrieves an attempt by ID.
15
+ #
16
+ # @param id [String] The ID of the attempt.
17
+ # @return [Hash] The attempt.
18
+ def retrieve(id)
19
+ validate_id!(id, 'atm_')
20
+ get("attempts/#{id}")
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,174 @@
1
+ module Hookdeck
2
+ module Resources
3
+ class Base
4
+ attr_reader :client
5
+
6
+ def initialize(client)
7
+ @client = client
8
+ end
9
+
10
+ protected
11
+
12
+ def get(path, params = {})
13
+ request(:get, path, params)
14
+ end
15
+
16
+ def post(path, params = {})
17
+ request(:post, path, params)
18
+ end
19
+
20
+ def put(path, params = {})
21
+ request(:put, path, params)
22
+ end
23
+
24
+ def delete(path, params = {})
25
+ request(:delete, path, params)
26
+ end
27
+
28
+ private
29
+
30
+ def request(method, path, params = {}, opts = {})
31
+ path = ensure_api_version(path)
32
+ handle_response(
33
+ client.request(
34
+ method,
35
+ path,
36
+ params,
37
+ opts
38
+ )
39
+ )
40
+ end
41
+
42
+ def ensure_api_version(path)
43
+ "/#{client.config.api_version}/#{path.gsub(%r{^/}, '')}"
44
+ end
45
+
46
+ def handle_response(response)
47
+ return nil if response.nil?
48
+
49
+ response
50
+ rescue StandardError => e
51
+ handle_error(e)
52
+ end
53
+
54
+ def handle_error(error)
55
+ case error
56
+ when Faraday::ClientError
57
+ case error.response[:status]
58
+ when 400
59
+ raise BadRequestError, error.response
60
+ when 401
61
+ raise UnauthorizedError, error.response
62
+ when 403
63
+ raise ForbiddenError, error.response
64
+ when 404
65
+ raise NotFoundError, error.response
66
+ when 409
67
+ raise ConflictError, error.response
68
+ when 422
69
+ raise UnprocessableEntityError, error.response
70
+ when 429
71
+ raise RateLimitError, error.response
72
+ else
73
+ raise ApiError, error.response
74
+ end
75
+ when Faraday::ServerError
76
+ raise ServerError, error.response
77
+ when Faraday::ConnectionFailed
78
+ raise ConnectionError, error
79
+ when Faraday::TimeoutError
80
+ raise TimeoutError, error
81
+ when Faraday::SSLError
82
+ raise SSLError, error
83
+ else
84
+ raise Error, error.message
85
+ end
86
+ end
87
+
88
+ def validate_id!(id, prefix)
89
+ return if id.is_a?(String) && id.start_with?(prefix)
90
+
91
+ raise ValidationError, "Invalid ID format. Expected #{prefix}*, got: #{id}"
92
+ end
93
+
94
+ def validate_params!(params, required_keys = [], optional_keys = [])
95
+ # Check for required keys
96
+ missing_keys = required_keys - params.keys
97
+ raise ValidationError, "Missing required parameters: #{missing_keys.join(', ')}" if missing_keys.any?
98
+
99
+ # Check for unexpected keys
100
+ allowed_keys = required_keys + optional_keys
101
+ unexpected_keys = params.keys - allowed_keys
102
+ return unless unexpected_keys.any?
103
+
104
+ raise ValidationError, "Unexpected parameters: #{unexpected_keys.join(', ')}"
105
+ end
106
+
107
+ def validate_enum!(value, allowed_values, field_name)
108
+ return if allowed_values.include?(value)
109
+
110
+ raise ValidationError, "Invalid #{field_name}. Expected one of: #{allowed_values.join(', ')}, got: #{value}"
111
+ end
112
+
113
+ def validate_array!(value, field_name, allowed_values = nil)
114
+ raise ValidationError, "#{field_name} must be an array, got: #{value.class}" unless value.is_a?(Array)
115
+
116
+ return unless allowed_values && (invalid_values = value - allowed_values).any?
117
+
118
+ raise ValidationError, "Invalid #{field_name} values: #{invalid_values.join(', ')}"
119
+ end
120
+
121
+ def validate_timestamp!(value, field_name)
122
+ Time.parse(value.to_s)
123
+ rescue ArgumentError
124
+ raise ValidationError, "Invalid timestamp format for #{field_name}: #{value}"
125
+ end
126
+
127
+ def validate_boolean!(value, field_name)
128
+ return if [true, false].include?(value)
129
+
130
+ raise ValidationError, "#{field_name} must be a boolean, got: #{value}"
131
+ end
132
+
133
+ def validate_integer!(value, field_name, min: nil, max: nil)
134
+ raise ValidationError, "#{field_name} must be an integer, got: #{value.class}" unless value.is_a?(Integer)
135
+
136
+ raise ValidationError, "#{field_name} must be greater than or equal to #{min}" if min && value < min
137
+
138
+ return unless max && value > max
139
+
140
+ raise ValidationError, "#{field_name} must be less than or equal to #{max}"
141
+ end
142
+
143
+ def validate_string!(value, field_name, pattern: nil, min_length: nil, max_length: nil)
144
+ raise ValidationError, "#{field_name} must be a string, got: #{value.class}" unless value.is_a?(String)
145
+
146
+ raise ValidationError, "#{field_name} format is invalid" if pattern && !value.match?(pattern)
147
+
148
+ if min_length && value.length < min_length
149
+ raise ValidationError, "#{field_name} must be at least #{min_length} characters"
150
+ end
151
+
152
+ return unless max_length && value.length > max_length
153
+
154
+ raise ValidationError, "#{field_name} must be at most #{max_length} characters"
155
+ end
156
+
157
+ def validate_url!(value, field_name)
158
+ uri = URI.parse(value)
159
+ unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
160
+ raise ValidationError,
161
+ "Invalid URL format for #{field_name}"
162
+ end
163
+ rescue URI::InvalidURIError
164
+ raise ValidationError, "Invalid URL format for #{field_name}"
165
+ end
166
+
167
+ def format_timestamp(value)
168
+ return nil if value.nil?
169
+
170
+ value.is_a?(Time) ? value.iso8601 : value
171
+ end
172
+ end
173
+ end
174
+ end