poodle-ruby 1.0.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,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_error"
4
+
5
+ module Poodle
6
+ # Exception raised when request validation fails (400 Bad Request, 422 Unprocessable Entity)
7
+ #
8
+ # @example Handling validation errors
9
+ # begin
10
+ # client.send_email(invalid_email)
11
+ # rescue Poodle::ValidationError => e
12
+ # puts "Validation failed: #{e.message}"
13
+ # e.errors.each do |field, messages|
14
+ # puts "#{field}: #{messages.join(', ')}"
15
+ # end
16
+ # end
17
+ class ValidationError < Error
18
+ # @return [Hash] field-specific validation errors
19
+ attr_reader :errors
20
+
21
+ # Initialize a new ValidationError
22
+ #
23
+ # @param message [String] the error message
24
+ # @param errors [Hash] field-specific validation errors
25
+ # @param context [Hash] additional context information
26
+ # @param status_code [Integer] HTTP status code (400 or 422)
27
+ def initialize(message = "Validation failed", errors: {}, context: {}, status_code: 400)
28
+ @errors = errors
29
+ super(message, context: context.merge(errors: errors), status_code: status_code)
30
+ end
31
+
32
+ # Create a ValidationError for invalid email address
33
+ #
34
+ # @param email [String] the invalid email address
35
+ # @param field [String] the field name (default: "email")
36
+ # @return [ValidationError] the validation error
37
+ def self.invalid_email(email, field: "email")
38
+ new(
39
+ "Invalid email address provided",
40
+ errors: { field => ["'#{email}' is not a valid email address"] }
41
+ )
42
+ end
43
+
44
+ # Create a ValidationError for missing required field
45
+ #
46
+ # @param field [String] the missing field name
47
+ # @return [ValidationError] the validation error
48
+ def self.missing_field(field)
49
+ new(
50
+ "Missing required field: #{field}",
51
+ errors: { field => ["The #{field} field is required"] }
52
+ )
53
+ end
54
+
55
+ # Create a ValidationError for invalid content
56
+ #
57
+ # @return [ValidationError] the validation error
58
+ def self.invalid_content
59
+ new(
60
+ "Email must contain either HTML content, text content, or both",
61
+ errors: { content: ["At least one content type (html or text) is required"] }
62
+ )
63
+ end
64
+
65
+ # Create a ValidationError for content too large
66
+ #
67
+ # @param field [String] the field name
68
+ # @param max_size [Integer] the maximum allowed size
69
+ # @return [ValidationError] the validation error
70
+ def self.content_too_large(field, max_size)
71
+ new(
72
+ "Content size exceeds maximum allowed size of #{max_size} bytes",
73
+ errors: { field => ["Content size exceeds maximum allowed size of #{max_size} bytes"] }
74
+ )
75
+ end
76
+
77
+ # Create a ValidationError for invalid field value
78
+ #
79
+ # @param field [String] the field name
80
+ # @param value [String] the invalid value
81
+ # @param reason [String] the reason why it's invalid
82
+ # @return [ValidationError] the validation error
83
+ def self.invalid_field_value(field, value, reason = "")
84
+ message = "Invalid value for field '#{field}': #{value}"
85
+ message += ". #{reason}" unless reason.empty?
86
+
87
+ new(
88
+ message,
89
+ errors: { field => [message] }
90
+ )
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,327 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+ require_relative "configuration"
6
+ require_relative "errors/authentication_error"
7
+ require_relative "errors/payment_error"
8
+ require_relative "errors/forbidden_error"
9
+ require_relative "errors/rate_limit_error"
10
+ require_relative "errors/validation_error"
11
+ require_relative "errors/network_error"
12
+ require_relative "errors/server_error"
13
+
14
+ module Poodle
15
+ # HTTP client wrapper for Poodle API communication
16
+ #
17
+ # @example Basic usage
18
+ # config = Poodle::Configuration.new(api_key: "your_api_key")
19
+ # client = Poodle::HttpClient.new(config)
20
+ # response = client.post("api/v1/send-email", email_data)
21
+ class HttpClient
22
+ # @return [Configuration] the configuration object
23
+ attr_reader :config
24
+
25
+ # Initialize a new HttpClient
26
+ #
27
+ # @param config [Configuration] the configuration object
28
+ def initialize(config)
29
+ @config = config
30
+ @connection = build_connection
31
+ end
32
+
33
+ # Send a POST request
34
+ #
35
+ # @param endpoint [String] the API endpoint
36
+ # @param data [Hash] the request data
37
+ # @param headers [Hash] additional headers
38
+ # @return [Hash] the parsed response data
39
+ # @raise [Poodle::Error] if the request fails
40
+ def post(endpoint, data = {}, headers = {})
41
+ request(:post, endpoint, data, headers)
42
+ end
43
+
44
+ # Send a GET request
45
+ #
46
+ # @param endpoint [String] the API endpoint
47
+ # @param params [Hash] query parameters
48
+ # @param headers [Hash] additional headers
49
+ # @return [Hash] the parsed response data
50
+ # @raise [Poodle::Error] if the request fails
51
+ def get(endpoint, params = {}, headers = {})
52
+ request(:get, endpoint, params, headers)
53
+ end
54
+
55
+ private
56
+
57
+ # Send an HTTP request
58
+ #
59
+ # @param method [Symbol] the HTTP method
60
+ # @param endpoint [String] the API endpoint
61
+ # @param data [Hash] the request data or query parameters
62
+ # @param headers [Hash] additional headers
63
+ # @return [Hash] the parsed response data
64
+ # @raise [Poodle::Error] if the request fails
65
+ def request(method, endpoint, data = {}, headers = {})
66
+ url = @config.url_for(endpoint)
67
+
68
+ log_request(method, url, data) if @config.debug?
69
+
70
+ response = perform_request(method, url, data, headers)
71
+
72
+ log_response(response) if @config.debug?
73
+
74
+ handle_response(response)
75
+ rescue Faraday::TimeoutError => e
76
+ puts "TimeoutError: #{e.message}"
77
+ raise NetworkError.connection_timeout(@config.timeout)
78
+ rescue Faraday::ConnectionFailed => e
79
+ handle_connection_failed_error(e)
80
+ rescue Faraday::Error => e
81
+ raise NetworkError.connection_failed(@config.base_url, original_error: e)
82
+ end
83
+
84
+ # Perform the actual HTTP request
85
+ #
86
+ # @param method [Symbol] the HTTP method
87
+ # @param url [String] the request URL
88
+ # @param data [Hash] the request data
89
+ # @param headers [Hash] additional headers
90
+ # @return [Faraday::Response] the HTTP response
91
+ def perform_request(method, url, data, headers)
92
+ case method
93
+ when :post
94
+ @connection.post(url) do |req|
95
+ req.headers.update(headers)
96
+ req.body = data.to_json
97
+ end
98
+ when :get
99
+ @connection.get(url) do |req|
100
+ req.headers.update(headers)
101
+ req.params = data
102
+ end
103
+ else
104
+ raise ArgumentError, "Unsupported HTTP method: #{method}"
105
+ end
106
+ end
107
+
108
+ # Handle connection failed errors
109
+ #
110
+ # @param error [Faraday::ConnectionFailed] the connection error
111
+ # @raise [NetworkError] the appropriate network error
112
+ def handle_connection_failed_error(error)
113
+ if error.message.include?("SSL") || error.message.include?("certificate")
114
+ raise NetworkError.ssl_error(error.message)
115
+ elsif error.message.include?("resolve") || error.message.include?("DNS")
116
+ host = URI.parse(@config.base_url).host
117
+ raise NetworkError.dns_resolution_failed(host)
118
+ else
119
+ raise NetworkError.connection_failed(@config.base_url, original_error: error)
120
+ end
121
+ end
122
+
123
+ # Build the Faraday connection
124
+ #
125
+ # @return [Faraday::Connection] the configured connection
126
+ def build_connection
127
+ Faraday.new do |conn|
128
+ configure_connection_middleware(conn)
129
+ configure_connection_timeouts(conn)
130
+ configure_connection_headers(conn)
131
+ configure_custom_options(conn)
132
+ end
133
+ end
134
+
135
+ # Configure connection middleware
136
+ #
137
+ # @param conn [Faraday::Connection] the connection
138
+ def configure_connection_middleware(conn)
139
+ conn.request :json
140
+ conn.response :json, content_type: /\bjson$/
141
+ conn.adapter Faraday.default_adapter
142
+ end
143
+
144
+ # Configure connection timeouts
145
+ #
146
+ # @param conn [Faraday::Connection] the connection
147
+ def configure_connection_timeouts(conn)
148
+ conn.options.timeout = @config.timeout
149
+ conn.options.open_timeout = @config.connect_timeout
150
+ end
151
+
152
+ # Configure connection headers
153
+ #
154
+ # @param conn [Faraday::Connection] the connection
155
+ def configure_connection_headers(conn)
156
+ conn.headers["Authorization"] = "Bearer #{@config.api_key}"
157
+ conn.headers["Content-Type"] = "application/json"
158
+ conn.headers["Accept"] = "application/json"
159
+ conn.headers["User-Agent"] = @config.user_agent
160
+ end
161
+
162
+ # Configure custom HTTP options
163
+ #
164
+ # @param conn [Faraday::Connection] the connection
165
+ def configure_custom_options(conn)
166
+ @config.http_options.each do |key, value|
167
+ conn.options[key] = value
168
+ end
169
+ end
170
+
171
+ # Handle HTTP response
172
+ #
173
+ # @param response [Faraday::Response] the HTTP response
174
+ # @return [Hash] the parsed response data
175
+ # @raise [Poodle::Error] if the response indicates an error
176
+ def handle_response(response)
177
+ return response.body || {} if success_response?(response)
178
+
179
+ handle_error_response(response)
180
+ end
181
+
182
+ # Check if response is successful
183
+ #
184
+ # @param response [Faraday::Response] the HTTP response
185
+ # @return [Boolean] true if successful
186
+ def success_response?(response)
187
+ (200..299).cover?(response.status)
188
+ end
189
+
190
+ # Handle error responses
191
+ #
192
+ # @param response [Faraday::Response] the HTTP response
193
+ # @raise [Poodle::Error] the appropriate error
194
+ def handle_error_response(response)
195
+ case response.status
196
+ when 400
197
+ handle_validation_error(response)
198
+ when 401
199
+ raise AuthenticationError.invalid_api_key
200
+ when 402
201
+ handle_payment_error(response)
202
+ when 403
203
+ handle_forbidden_error(response)
204
+ when 422
205
+ handle_validation_error(response, status_code: 422)
206
+ when 429
207
+ raise RateLimitError.from_headers(response.headers)
208
+ when 500..599
209
+ handle_server_error(response)
210
+ else
211
+ raise NetworkError.http_error(response.status, extract_error_message(response))
212
+ end
213
+ end
214
+
215
+ # Handle validation errors (400, 422)
216
+ #
217
+ # @param response [Faraday::Response] the HTTP response
218
+ # @param status_code [Integer] the HTTP status code
219
+ # @raise [ValidationError] the validation error
220
+ def handle_validation_error(response, status_code: 400)
221
+ body = response.body || {}
222
+ message = extract_error_message(response)
223
+ errors = extract_validation_errors(body)
224
+
225
+ raise ValidationError.new(message, errors: errors, status_code: status_code)
226
+ end
227
+
228
+ # Handle payment errors (402)
229
+ #
230
+ # @param response [Faraday::Response] the HTTP response
231
+ # @raise [PaymentError] the payment error
232
+ def handle_payment_error(response)
233
+ message = extract_error_message(response)
234
+
235
+ case message
236
+ when /subscription.*expired/i
237
+ raise PaymentError.subscription_expired
238
+ when /trial.*limit/i
239
+ raise PaymentError.trial_limit_reached
240
+ when /monthly.*limit/i
241
+ raise PaymentError.monthly_limit_reached
242
+ else
243
+ raise PaymentError, message
244
+ end
245
+ end
246
+
247
+ # Handle forbidden errors (403)
248
+ #
249
+ # @param response [Faraday::Response] the HTTP response
250
+ # @raise [ForbiddenError] the forbidden error
251
+ def handle_forbidden_error(response)
252
+ body = response.body || {}
253
+ message = extract_error_message(response)
254
+
255
+ raise ForbiddenError.insufficient_permissions unless message.include?("suspended")
256
+
257
+ # Extract suspension details if available
258
+ reason = body["reason"] || "unknown"
259
+ rate = body["rate"]
260
+ raise ForbiddenError.account_suspended(reason, rate)
261
+ end
262
+
263
+ # Handle server errors (5xx)
264
+ #
265
+ # @param response [Faraday::Response] the HTTP response
266
+ # @raise [ServerError] the server error
267
+ def handle_server_error(response)
268
+ message = extract_error_message(response)
269
+
270
+ case response.status
271
+ when 500
272
+ raise ServerError.internal_server_error(message)
273
+ when 502
274
+ raise ServerError.bad_gateway(message)
275
+ when 503
276
+ raise ServerError.service_unavailable(message)
277
+ when 504
278
+ raise ServerError.gateway_timeout(message)
279
+ else
280
+ raise ServerError.new(message, status_code: response.status)
281
+ end
282
+ end
283
+
284
+ # Extract error message from response
285
+ #
286
+ # @param response [Faraday::Response] the HTTP response
287
+ # @return [String] the error message
288
+ def extract_error_message(response)
289
+ body = response.body
290
+ return "HTTP #{response.status} error" unless body.is_a?(Hash)
291
+
292
+ body["message"] || body["error"] || "HTTP #{response.status} error"
293
+ end
294
+
295
+ # Extract validation errors from response body
296
+ #
297
+ # @param body [Hash] the response body
298
+ # @return [Hash] field-specific validation errors
299
+ def extract_validation_errors(body)
300
+ return {} unless body.is_a?(Hash)
301
+
302
+ errors = body["errors"] || body["validation_errors"] || {}
303
+ return {} unless errors.is_a?(Hash)
304
+
305
+ # Convert string values to arrays for consistency
306
+ errors.transform_values { |v| Array(v) }
307
+ end
308
+
309
+ # Log HTTP request for debugging
310
+ #
311
+ # @param method [Symbol] the HTTP method
312
+ # @param url [String] the request URL
313
+ # @param data [Hash] the request data
314
+ def log_request(method, url, data)
315
+ puts "[Poodle] #{method.upcase} #{url}"
316
+ puts "[Poodle] Request: #{data.to_json}" unless data.empty?
317
+ end
318
+
319
+ # Log HTTP response for debugging
320
+ #
321
+ # @param response [Faraday::Response] the HTTP response
322
+ def log_response(response)
323
+ puts "[Poodle] Response: #{response.status}"
324
+ puts "[Poodle] Body: #{response.body}" if response.body
325
+ end
326
+ end
327
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module Poodle
6
+ module Rails
7
+ # Rails Railtie for automatic Poodle integration
8
+ class Railtie < ::Rails::Railtie
9
+ # Add Poodle configuration to Rails generators
10
+ config.generators do |g|
11
+ g.test_framework :rspec
12
+ end
13
+
14
+ # Initialize Poodle after Rails application is initialized
15
+ initializer "poodle.configure" do |app|
16
+ # Auto-configure Poodle if credentials or environment variables are present
17
+ if ENV["POODLE_API_KEY"] ||
18
+ (app.credentials.respond_to?(:poodle_api_key) && app.credentials.poodle_api_key)
19
+ Poodle::Rails.auto_configure!
20
+ end
21
+ end
22
+
23
+ # Add Poodle logger integration
24
+ initializer "poodle.logger" do |_app|
25
+ # Integrate with Rails logger if debug mode is enabled
26
+ if Poodle::Rails.configured? && Poodle::Rails.configuration.debug?
27
+ # Override the HTTP client logging to use Rails logger
28
+ Poodle::HttpClient.class_eval do
29
+ private
30
+
31
+ def log_request(method, url, data)
32
+ ::Rails.logger.debug "[Poodle] #{method.upcase} #{url}"
33
+ ::Rails.logger.debug "[Poodle] Request: #{data.to_json}" unless data.empty?
34
+ end
35
+
36
+ def log_response(response)
37
+ ::Rails.logger.debug "[Poodle] Response: #{response.status}"
38
+ ::Rails.logger.debug "[Poodle] Body: #{response.body}" if response.body
39
+ end
40
+ end
41
+ end
42
+ end
43
+
44
+ # Add rake tasks
45
+ rake_tasks do
46
+ load "poodle/rails/tasks.rake"
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :poodle do
4
+ desc "Check Poodle configuration"
5
+ task config: :environment do
6
+ if Poodle::Rails.configured?
7
+ config = Poodle::Rails.configuration
8
+ puts "✅ Poodle is configured"
9
+ puts " API Key: #{config.api_key ? "***#{config.api_key[-4..]}" : 'Not set'}"
10
+ puts " Base URL: #{config.base_url}"
11
+ puts " Timeout: #{config.timeout}s"
12
+ puts " Debug: #{config.debug?}"
13
+ else
14
+ puts "❌ Poodle is not configured"
15
+ puts " Set POODLE_API_KEY environment variable or configure in Rails credentials"
16
+ end
17
+ end
18
+
19
+ desc "Test Poodle connection"
20
+ task test: :environment do
21
+ unless Poodle::Rails.configured?
22
+ puts "❌ Poodle is not configured. Run 'rake poodle:config' to check configuration."
23
+ exit 1
24
+ end
25
+
26
+ begin
27
+ client = Poodle::Rails.client
28
+ puts "✅ Poodle client created successfully"
29
+ puts " SDK Version: #{Poodle::VERSION}"
30
+ puts " User Agent: #{client.config.user_agent}"
31
+ rescue StandardError => e
32
+ puts "❌ Failed to create Poodle client: #{e.message}"
33
+ exit 1
34
+ end
35
+ end
36
+
37
+ desc "Send a test email"
38
+ task :send_test, %i[to from] => :environment do |_t, args|
39
+ unless Poodle::Rails.configured?
40
+ puts "❌ Poodle is not configured. Run 'rake poodle:config' to check configuration."
41
+ exit 1
42
+ end
43
+
44
+ to_email = args[:to] || ENV.fetch("POODLE_TEST_TO", nil)
45
+ from_email = args[:from] || ENV["POODLE_TEST_FROM"] || "test@example.com"
46
+
47
+ unless to_email
48
+ puts "❌ Please provide a recipient email address:"
49
+ puts " rake poodle:send_test[recipient@example.com]"
50
+ puts " or set POODLE_TEST_TO environment variable"
51
+ exit 1
52
+ end
53
+
54
+ begin
55
+ client = Poodle::Rails.client
56
+ response = client.send(
57
+ from: from_email,
58
+ to: to_email,
59
+ subject: "Test Email from Poodle Ruby SDK",
60
+ html: "<h1>Test Email</h1><p>This is a test email sent from the Poodle Ruby SDK in a Rails application.</p>",
61
+ text: "Test Email\n\nThis is a test email sent from the Poodle Ruby SDK in a Rails application."
62
+ )
63
+
64
+ if response.success?
65
+ puts "✅ Test email sent successfully!"
66
+ puts " From: #{from_email}"
67
+ puts " To: #{to_email}"
68
+ puts " Message: #{response.message}"
69
+ else
70
+ puts "❌ Failed to send test email: #{response.message}"
71
+ exit 1
72
+ end
73
+ rescue StandardError => e
74
+ puts "❌ Error sending test email: #{e.message}"
75
+ puts " #{e.class.name}"
76
+ exit 1
77
+ end
78
+ end
79
+
80
+ desc "Generate Poodle initializer"
81
+ task install: :environment do
82
+ initializer_path = Rails.root.join("config", "initializers", "poodle.rb")
83
+
84
+ if File.exist?(initializer_path)
85
+ puts "⚠️ Poodle initializer already exists at #{initializer_path}"
86
+ puts " Remove it first if you want to regenerate it."
87
+ exit 1
88
+ end
89
+
90
+ initializer_content = <<~RUBY
91
+ # frozen_string_literal: true
92
+
93
+ # Poodle email sending configuration
94
+ Poodle::Rails.configure do |config|
95
+ # API key from Rails credentials or environment variable
96
+ config.api_key = Rails.application.credentials.poodle_api_key || ENV['POODLE_API_KEY']
97
+
98
+ # Optional: Override base URL (defaults to https://api.usepoodle.com)
99
+ # config.base_url = ENV['POODLE_BASE_URL']
100
+
101
+ # Optional: Set timeout (defaults to 30 seconds)
102
+ # config.timeout = 30
103
+
104
+ # Optional: Enable debug mode in development
105
+ config.debug = Rails.env.development?
106
+ end
107
+ RUBY
108
+
109
+ File.write(initializer_path, initializer_content)
110
+ puts "✅ Created Poodle initializer at #{initializer_path}"
111
+ puts " Don't forget to set your API key in Rails credentials or environment variables!"
112
+ end
113
+ end