fabulous 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,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fabulous
4
+ class Client
5
+ attr_reader :configuration
6
+
7
+ def initialize(configuration = nil)
8
+ @configuration = configuration || Fabulous.configuration || Configuration.new
9
+ validate_configuration!
10
+ end
11
+
12
+ def domains
13
+ @domains ||= Resources::Domain.new(self)
14
+ end
15
+
16
+ def dns
17
+ @dns ||= Resources::DNS.new(self)
18
+ end
19
+
20
+ def request(action, params = {})
21
+ params = build_params(action, params)
22
+
23
+ # The Fabulous API uses GET requests with URL parameters
24
+ url = "/#{action}"
25
+
26
+ response = connection.get(url) do |req|
27
+ req.params = params
28
+ end
29
+
30
+ # Debug output if ENV variable is set
31
+ if ENV["DEBUG_FABULOUS"]
32
+ puts "Request URL: #{configuration.base_url}#{url}"
33
+ puts "Parameters: #{params.inspect}"
34
+ puts "HTTP Status: #{response.status}"
35
+ puts "Response Body:"
36
+ puts response.body
37
+ puts
38
+ end
39
+
40
+ Response.new(response.body).tap do |parsed_response|
41
+ handle_errors(parsed_response)
42
+ end
43
+ rescue Faraday::TimeoutError => e
44
+ raise TimeoutError, "Request timed out: #{e.message}"
45
+ rescue Faraday::Error => e
46
+ raise RequestError, "Request failed: #{e.message}"
47
+ end
48
+
49
+ private
50
+
51
+ def connection
52
+ @connection ||= Faraday.new(url: configuration.base_url) do |faraday|
53
+ faraday.request :url_encoded
54
+ faraday.adapter Faraday.default_adapter
55
+ faraday.options.timeout = configuration.timeout
56
+ faraday.options.open_timeout = configuration.open_timeout
57
+ end
58
+ end
59
+
60
+ def build_params(action, params)
61
+ # Don't include action in params, it's part of the URL path
62
+ {
63
+ username: configuration.username,
64
+ password: configuration.password
65
+ }.merge(params)
66
+ end
67
+
68
+ def handle_errors(response)
69
+ return if response.success?
70
+
71
+ case response.status_code
72
+ when 300..399
73
+ raise AuthenticationError.new(response.status_message || "Authentication failed", response.status_code)
74
+ when 400..499
75
+ raise RequestError.new(response.status_message || "Request error", response.status_code)
76
+ when 500..599
77
+ raise ResponseError.new(response.status_message || "Server error", response.status_code)
78
+ when 689
79
+ raise RateLimitError, "Execution time exhausted (300 seconds per 24 hours limit reached)"
80
+ else
81
+ raise Error, "Unknown error: #{response.status_message} (code: #{response.status_code})"
82
+ end
83
+ end
84
+
85
+ def validate_configuration!
86
+ raise ConfigurationError, "Username and password are required" unless configuration.valid?
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fabulous
4
+ class Configuration
5
+ attr_accessor :username, :password, :base_url, :timeout, :open_timeout
6
+
7
+ def initialize
8
+ @base_url = "https://api.fabulous.com"
9
+ @timeout = 30
10
+ @open_timeout = 10
11
+ end
12
+
13
+ def valid?
14
+ !username.nil? && !password.nil?
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fabulous
4
+ class Error < StandardError; end
5
+
6
+ class ConfigurationError < Error; end
7
+
8
+ class AuthenticationError < Error
9
+ attr_reader :code
10
+
11
+ def initialize(message, code = nil)
12
+ @code = code
13
+ super(message)
14
+ end
15
+ end
16
+
17
+ class RequestError < Error
18
+ attr_reader :code
19
+
20
+ def initialize(message, code = nil)
21
+ @code = code
22
+ super(message)
23
+ end
24
+ end
25
+
26
+ class ResponseError < Error
27
+ attr_reader :code
28
+
29
+ def initialize(message, code = nil)
30
+ @code = code
31
+ super(message)
32
+ end
33
+ end
34
+
35
+ class RateLimitError < Error; end
36
+
37
+ class TimeoutError < Error; end
38
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fabulous
4
+ module Resources
5
+ class Base
6
+ attr_reader :client
7
+
8
+ def initialize(client)
9
+ @client = client
10
+ end
11
+
12
+ protected
13
+
14
+ def request(action, params = {})
15
+ client.request(action, params)
16
+ end
17
+
18
+ def paginate(action, params = {}, &block)
19
+ page = params.delete(:page) || 1
20
+ all_results = []
21
+
22
+ loop do
23
+ response = request(action, params.merge(page: page))
24
+
25
+ if block_given?
26
+ yield response, page
27
+ else
28
+ all_results.concat(extract_items(response))
29
+ end
30
+
31
+ break unless response.paginated? && page < response.page_count
32
+ page += 1
33
+ end
34
+
35
+ block_given? ? nil : all_results
36
+ end
37
+
38
+ def extract_items(response)
39
+ # Override in subclasses to extract the appropriate items
40
+ response.data.values.first || []
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fabulous
4
+ module Resources
5
+ class DNS < Base
6
+ def list_records(domain_name, type: nil)
7
+ params = { domain: domain_name }
8
+ params[:type] = type if type
9
+
10
+ response = request("listDNSrecords", params)
11
+ response.data[:dns_records] || []
12
+ end
13
+
14
+ # MX Records
15
+ def mx_records(domain_name)
16
+ response = request("getMXRecords", domain: domain_name)
17
+ response.data[:mx_records] || []
18
+ end
19
+
20
+ def add_mx_record(domain_name, hostname:, priority:, ttl: 3600)
21
+ response = request("addMXRecord", {
22
+ domain: domain_name,
23
+ hostname: hostname,
24
+ priority: priority,
25
+ ttl: ttl
26
+ })
27
+ response.success?
28
+ end
29
+
30
+ def update_mx_record(domain_name, record_id:, hostname: nil, priority: nil, ttl: nil)
31
+ params = {
32
+ domain: domain_name,
33
+ recordId: record_id
34
+ }
35
+ params[:hostname] = hostname if hostname
36
+ params[:priority] = priority if priority
37
+ params[:ttl] = ttl if ttl
38
+
39
+ response = request("updateMXRecord", params)
40
+ response.success?
41
+ end
42
+
43
+ def delete_mx_record(domain_name, record_id)
44
+ response = request("deleteMXRecord", domain: domain_name, recordId: record_id)
45
+ response.success?
46
+ end
47
+
48
+ # CNAME Records
49
+ def cname_records(domain_name)
50
+ response = request("getCNAMERecords", domain: domain_name)
51
+ response.data[:cname_records] || []
52
+ end
53
+
54
+ def add_cname_record(domain_name, alias_name:, target:, ttl: 3600)
55
+ response = request("addCNAMERecord", {
56
+ domain: domain_name,
57
+ alias: alias_name,
58
+ target: target,
59
+ ttl: ttl
60
+ })
61
+ response.success?
62
+ end
63
+
64
+ def update_cname_record(domain_name, record_id:, alias_name: nil, target: nil, ttl: nil)
65
+ params = {
66
+ domain: domain_name,
67
+ recordId: record_id
68
+ }
69
+ params[:alias] = alias_name if alias_name
70
+ params[:target] = target if target
71
+ params[:ttl] = ttl if ttl
72
+
73
+ response = request("updateCNAMERecord", params)
74
+ response.success?
75
+ end
76
+
77
+ def delete_cname_record(domain_name, record_id)
78
+ response = request("deleteCNAMERecord", domain: domain_name, recordId: record_id)
79
+ response.success?
80
+ end
81
+
82
+ # A Records
83
+ def a_records(domain_name)
84
+ response = request("getARecords", domain: domain_name)
85
+ response.data[:a_records] || []
86
+ end
87
+
88
+ def add_a_record(domain_name, hostname:, ip_address:, ttl: 3600)
89
+ response = request("addARecord", {
90
+ domain: domain_name,
91
+ hostname: hostname,
92
+ ipAddress: ip_address,
93
+ ttl: ttl
94
+ })
95
+ response.success?
96
+ end
97
+
98
+ def update_a_record(domain_name, record_id:, hostname: nil, ip_address: nil, ttl: nil)
99
+ params = {
100
+ domain: domain_name,
101
+ recordId: record_id
102
+ }
103
+ params[:hostname] = hostname if hostname
104
+ params[:ipAddress] = ip_address if ip_address
105
+ params[:ttl] = ttl if ttl
106
+
107
+ response = request("updateARecord", params)
108
+ response.success?
109
+ end
110
+
111
+ def delete_a_record(domain_name, record_id)
112
+ response = request("deleteARecord", domain: domain_name, recordId: record_id)
113
+ response.success?
114
+ end
115
+
116
+ # TXT Records
117
+ def txt_records(domain_name)
118
+ response = request("getTXTRecords", domain: domain_name)
119
+ response.data[:txt_records] || []
120
+ end
121
+
122
+ def add_txt_record(domain_name, hostname:, text:, ttl: 3600)
123
+ response = request("addTXTRecord", {
124
+ domain: domain_name,
125
+ hostname: hostname,
126
+ text: text,
127
+ ttl: ttl
128
+ })
129
+ response.success?
130
+ end
131
+
132
+ def update_txt_record(domain_name, record_id:, hostname: nil, text: nil, ttl: nil)
133
+ params = {
134
+ domain: domain_name,
135
+ recordId: record_id
136
+ }
137
+ params[:hostname] = hostname if hostname
138
+ params[:text] = text if text
139
+ params[:ttl] = ttl if ttl
140
+
141
+ response = request("updateTXTRecord", params)
142
+ response.success?
143
+ end
144
+
145
+ def delete_txt_record(domain_name, record_id)
146
+ response = request("deleteTXTRecord", domain: domain_name, recordId: record_id)
147
+ response.success?
148
+ end
149
+
150
+ # AAAA Records (IPv6)
151
+ def aaaa_records(domain_name)
152
+ response = request("getAAAARecords", domain: domain_name)
153
+ response.data[:aaaa_records] || []
154
+ end
155
+
156
+ def add_aaaa_record(domain_name, hostname:, ipv6_address:, ttl: 3600)
157
+ response = request("addAAAARecord", {
158
+ domain: domain_name,
159
+ hostname: hostname,
160
+ ipv6Address: ipv6_address,
161
+ ttl: ttl
162
+ })
163
+ response.success?
164
+ end
165
+
166
+ def update_aaaa_record(domain_name, record_id:, hostname: nil, ipv6_address: nil, ttl: nil)
167
+ params = {
168
+ domain: domain_name,
169
+ recordId: record_id
170
+ }
171
+ params[:hostname] = hostname if hostname
172
+ params[:ipv6Address] = ipv6_address if ipv6_address
173
+ params[:ttl] = ttl if ttl
174
+
175
+ response = request("updateAAAARecord", params)
176
+ response.success?
177
+ end
178
+
179
+ def delete_aaaa_record(domain_name, record_id)
180
+ response = request("deleteAAAARecord", domain: domain_name, recordId: record_id)
181
+ response.success?
182
+ end
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fabulous
4
+ module Resources
5
+ class Domain < Base
6
+ def list(page: nil, &block)
7
+ if page
8
+ response = request("listDomains", page: page)
9
+ block_given? ? yield(response) : response.data[:domains]
10
+ else
11
+ paginate("listDomains", &block)
12
+ end
13
+ end
14
+
15
+ def all
16
+ paginate("listDomains")
17
+ end
18
+
19
+ def check(domain_name)
20
+ response = request("checkDomain", domain: domain_name)
21
+ response.data[:available]
22
+ end
23
+
24
+ def info(domain_name)
25
+ response = request("domainInfo", domain: domain_name)
26
+ response.data[:domain_info]
27
+ end
28
+
29
+ def register(domain_name, years: 1, nameservers: [], whois_privacy: false, auto_renew: false)
30
+ params = {
31
+ domain: domain_name,
32
+ years: years,
33
+ whoisPrivacy: whois_privacy,
34
+ autoRenew: auto_renew
35
+ }
36
+
37
+ nameservers.each_with_index do |ns, index|
38
+ params["ns#{index + 1}"] = ns
39
+ end
40
+
41
+ response = request("registerDomain", params)
42
+ response.success?
43
+ end
44
+
45
+ def renew(domain_name, years: 1)
46
+ response = request("renewDomain", domain: domain_name, years: years)
47
+ response.success?
48
+ end
49
+
50
+ def transfer_in(domain_name, auth_code)
51
+ response = request("transferIn", domain: domain_name, authCode: auth_code)
52
+ response.success?
53
+ end
54
+
55
+ def set_nameservers(domain_name, nameservers)
56
+ params = { domain: domain_name }
57
+
58
+ nameservers.each_with_index do |ns, index|
59
+ params["ns#{index + 1}"] = ns
60
+ end
61
+
62
+ response = request("setNameServers", params)
63
+ response.success?
64
+ end
65
+
66
+ def get_nameservers(domain_name)
67
+ info = info(domain_name)
68
+ info[:nameservers] if info
69
+ end
70
+
71
+ def lock(domain_name)
72
+ response = request("lockDomain", domain: domain_name)
73
+ response.success?
74
+ end
75
+
76
+ def unlock(domain_name)
77
+ response = request("unlockDomain", domain: domain_name)
78
+ response.success?
79
+ end
80
+
81
+ def set_auto_renew(domain_name, enabled: true)
82
+ response = request("setAutoRenew", domain: domain_name, autoRenew: enabled)
83
+ response.success?
84
+ end
85
+
86
+ def enable_whois_privacy(domain_name)
87
+ response = request("enableWhoisPrivacy", domain: domain_name)
88
+ response.success?
89
+ end
90
+
91
+ def disable_whois_privacy(domain_name)
92
+ response = request("disableWhoisPrivacy", domain: domain_name)
93
+ response.success?
94
+ end
95
+
96
+ protected
97
+
98
+ def extract_items(response)
99
+ response.data[:domains] || []
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fabulous
4
+ class Response
5
+ attr_reader :doc, :raw_xml
6
+
7
+ def initialize(xml_string)
8
+ @raw_xml = xml_string
9
+ @doc = Nokogiri::XML(xml_string)
10
+ end
11
+
12
+ def success?
13
+ status_code && status_code.to_i == 200
14
+ end
15
+
16
+ def status_code
17
+ # Try both old and new format
18
+ @status_code ||= doc.at_xpath("//statusCode")&.text&.to_i ||
19
+ doc.at_xpath("//response/status")&.text&.to_i
20
+ end
21
+
22
+ def status_message
23
+ # Try both old and new format
24
+ @status_message ||= doc.at_xpath("//statusText")&.text ||
25
+ doc.at_xpath("//response/reason")&.text
26
+ end
27
+
28
+ def data
29
+ @data ||= parse_data
30
+ end
31
+
32
+ def paginated?
33
+ # Check if results count exceeds what's shown (pagination needed)
34
+ total = doc.at_xpath("//results")&.attr("count")&.to_i || 0
35
+ shown = doc.xpath("//results/result").length
36
+ total > shown && shown > 0
37
+ end
38
+
39
+ def page_count
40
+ # Calculate based on total results and page size
41
+ total = doc.at_xpath("//results")&.attr("count")&.to_i || 0
42
+ page_size = doc.xpath("//results/result").length
43
+ return 1 if page_size == 0
44
+ (total.to_f / page_size).ceil
45
+ end
46
+
47
+ def current_page
48
+ # Try to get from request params
49
+ doc.at_xpath("//request/params/param[@name='page']")&.text&.to_i || 1
50
+ end
51
+
52
+ private
53
+
54
+ def parse_data
55
+ result = {}
56
+
57
+ # Parse new format with results
58
+ if results = doc.xpath("//results/result")
59
+ result[:domains] = parse_results_domains(results)
60
+ # Parse old format
61
+ elsif domains = doc.xpath("//domain")
62
+ result[:domains] = parse_domains(domains)
63
+ end
64
+
65
+ if dns_records = doc.xpath("//dnsrecord")
66
+ result[:dns_records] = parse_dns_records(dns_records)
67
+ end
68
+
69
+ if mx_records = doc.xpath("//mxrecord")
70
+ result[:mx_records] = parse_mx_records(mx_records)
71
+ end
72
+
73
+ if cname_records = doc.xpath("//cnamerecord")
74
+ result[:cname_records] = parse_cname_records(cname_records)
75
+ end
76
+
77
+ if a_records = doc.xpath("//arecord")
78
+ result[:a_records] = parse_a_records(a_records)
79
+ end
80
+
81
+ # Parse single domain info
82
+ if domain_info = doc.at_xpath("//domainInfo")
83
+ result[:domain_info] = parse_domain_info(domain_info)
84
+ elsif info_result = doc.at_xpath("//results/result[expiry]")
85
+ # domainInfo response format
86
+ result[:domain_info] = {
87
+ expiry_date: info_result.at_xpath("expiry")&.text,
88
+ nameservers: info_result.xpath("nameserverss/nameservers").map(&:text),
89
+ status: info_result.at_xpath("fabstatus")&.text&.capitalize || "Active",
90
+ auto_renew: info_result.at_xpath("autorenewstatus")&.text == "1",
91
+ locked: info_result.xpath("registrystatuss/registrystatus").any? { |s| s.text.include?("Prohibited") },
92
+ whois_privacy: info_result.at_xpath("whoisprivacyenabled")&.text == "1"
93
+ }
94
+ elsif domain_element = doc.at_xpath("//domain")
95
+ # Alternative format for domain info
96
+ result[:domain_info] = {
97
+ status: domain_element.at_xpath("status")&.text || "Active"
98
+ }
99
+ end
100
+
101
+ # Parse availability check
102
+ if availability = doc.at_xpath("//availability")
103
+ result[:available] = availability.text == "true"
104
+ end
105
+
106
+ result.empty? ? parse_generic : result
107
+ end
108
+
109
+ def parse_results_domains(results)
110
+ results.map do |result|
111
+ {
112
+ name: result.at_xpath("domain")&.text,
113
+ expiry_date: result.at_xpath("exdate")&.text,
114
+ status: "Active", # Not provided in new format, assuming active
115
+ auto_renew: nil, # Not provided in this format
116
+ locked: nil # Not provided in this format
117
+ }.compact
118
+ end
119
+ end
120
+
121
+ def parse_domains(domains)
122
+ domains.map do |domain|
123
+ {
124
+ name: domain.at_xpath("name")&.text,
125
+ status: domain.at_xpath("status")&.text,
126
+ expiry_date: domain.at_xpath("expiryDate")&.text,
127
+ auto_renew: domain.at_xpath("autoRenew")&.text == "true",
128
+ locked: domain.at_xpath("locked")&.text == "true"
129
+ }.compact
130
+ end
131
+ end
132
+
133
+ def parse_domain_info(info)
134
+ {
135
+ name: info.at_xpath("name")&.text,
136
+ status: info.at_xpath("status")&.text,
137
+ creation_date: info.at_xpath("creationDate")&.text,
138
+ expiry_date: info.at_xpath("expiryDate")&.text,
139
+ nameservers: info.xpath("nameservers/nameserver").map(&:text),
140
+ auto_renew: info.at_xpath("autoRenew")&.text == "true",
141
+ locked: info.at_xpath("locked")&.text == "true",
142
+ whois_privacy: info.at_xpath("whoisPrivacy")&.text == "true"
143
+ }.compact
144
+ end
145
+
146
+ def parse_dns_records(records)
147
+ records.map do |record|
148
+ {
149
+ id: record.at_xpath("id")&.text,
150
+ type: record.at_xpath("type")&.text,
151
+ name: record.at_xpath("name")&.text,
152
+ value: record.at_xpath("value")&.text,
153
+ ttl: record.at_xpath("ttl")&.text&.to_i,
154
+ priority: record.at_xpath("priority")&.text&.to_i
155
+ }.compact
156
+ end
157
+ end
158
+
159
+ def parse_mx_records(records)
160
+ records.map do |record|
161
+ {
162
+ id: record.at_xpath("id")&.text,
163
+ hostname: record.at_xpath("hostname")&.text,
164
+ priority: record.at_xpath("priority")&.text&.to_i,
165
+ ttl: record.at_xpath("ttl")&.text&.to_i
166
+ }.compact
167
+ end
168
+ end
169
+
170
+ def parse_cname_records(records)
171
+ records.map do |record|
172
+ {
173
+ id: record.at_xpath("id")&.text,
174
+ alias: record.at_xpath("alias")&.text,
175
+ target: record.at_xpath("target")&.text,
176
+ ttl: record.at_xpath("ttl")&.text&.to_i
177
+ }.compact
178
+ end
179
+ end
180
+
181
+ def parse_a_records(records)
182
+ records.map do |record|
183
+ {
184
+ id: record.at_xpath("id")&.text,
185
+ hostname: record.at_xpath("hostname")&.text,
186
+ ip_address: record.at_xpath("ipAddress")&.text,
187
+ ttl: record.at_xpath("ttl")&.text&.to_i
188
+ }.compact
189
+ end
190
+ end
191
+
192
+ def parse_generic
193
+ # Return all non-status elements as a hash
194
+ result = {}
195
+ doc.root.children.each do |child|
196
+ next if child.text? || child.name =~ /status/i
197
+
198
+ if child.children.length > 1
199
+ result[child.name.to_sym] = parse_element(child)
200
+ else
201
+ result[child.name.to_sym] = child.text
202
+ end
203
+ end
204
+ result
205
+ end
206
+
207
+ def parse_element(element)
208
+ if element.children.all? { |c| c.text? }
209
+ element.text
210
+ else
211
+ result = {}
212
+ element.children.each do |child|
213
+ next if child.text?
214
+
215
+ if result[child.name.to_sym]
216
+ result[child.name.to_sym] = [result[child.name.to_sym]] unless result[child.name.to_sym].is_a?(Array)
217
+ result[child.name.to_sym] << parse_element(child)
218
+ else
219
+ result[child.name.to_sym] = parse_element(child)
220
+ end
221
+ end
222
+ result
223
+ end
224
+ end
225
+ end
226
+ end