ipgeolocation_sdk 1.0.0 → 2.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.
Files changed (228) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +8 -0
  3. data/README.md +288 -2003
  4. data/lib/ipgeolocation_sdk/client.rb +223 -0
  5. data/lib/ipgeolocation_sdk/client_config.rb +102 -0
  6. data/lib/ipgeolocation_sdk/enums.rb +110 -0
  7. data/lib/ipgeolocation_sdk/errors.rb +38 -0
  8. data/lib/ipgeolocation_sdk/json_output.rb +30 -0
  9. data/lib/ipgeolocation_sdk/models.rb +186 -0
  10. data/lib/ipgeolocation_sdk/request_models.rb +175 -0
  11. data/lib/ipgeolocation_sdk/serde.rb +433 -0
  12. data/lib/ipgeolocation_sdk/transport.rb +109 -0
  13. data/lib/ipgeolocation_sdk/value_object.rb +60 -0
  14. data/lib/ipgeolocation_sdk/version.rb +1 -13
  15. data/lib/ipgeolocation_sdk.rb +19 -101
  16. metadata +29 -310
  17. data/Gemfile +0 -9
  18. data/Rakefile +0 -10
  19. data/docs/ASNConnection.md +0 -22
  20. data/docs/ASNDetails.md +0 -48
  21. data/docs/ASNLookupApi.md +0 -89
  22. data/docs/ASNResponse.md +0 -20
  23. data/docs/ASNResponseXML.md +0 -20
  24. data/docs/ASNResponseXMLAsn.md +0 -48
  25. data/docs/Abuse.md +0 -40
  26. data/docs/AbuseContactApi.md +0 -85
  27. data/docs/AbuseResponse.md +0 -20
  28. data/docs/AbuseResponseXML.md +0 -20
  29. data/docs/Astronomy.md +0 -68
  30. data/docs/AstronomyApi.md +0 -97
  31. data/docs/AstronomyEvening.md +0 -36
  32. data/docs/AstronomyLocation.md +0 -50
  33. data/docs/AstronomyMorning.md +0 -36
  34. data/docs/AstronomyResponse.md +0 -22
  35. data/docs/AstronomyXMLResponse.md +0 -22
  36. data/docs/BulkIPGeolocation.md +0 -42
  37. data/docs/BulkIPSecurity.md +0 -42
  38. data/docs/CountryMetadata.md +0 -22
  39. data/docs/Currency.md +0 -22
  40. data/docs/ErrorResponse.md +0 -18
  41. data/docs/ErrorXMLResponse.md +0 -18
  42. data/docs/ErrorXMLResponseArray.md +0 -18
  43. data/docs/GeolocationResponse.md +0 -38
  44. data/docs/GeolocationXMLResponse.md +0 -38
  45. data/docs/GeolocationXMLResponseArray.md +0 -38
  46. data/docs/GetBulkIpGeolocation200ResponseInner1.md +0 -49
  47. data/docs/GetBulkIpGeolocationRequest.md +0 -18
  48. data/docs/GetBulkIpSecurityInfo200ResponseInner1.md +0 -49
  49. data/docs/IPLocationApi.md +0 -175
  50. data/docs/Location.md +0 -58
  51. data/docs/LocationMinimal.md +0 -52
  52. data/docs/Network.md +0 -22
  53. data/docs/NetworkAsn.md +0 -38
  54. data/docs/NetworkCompany.md +0 -22
  55. data/docs/NetworkMinimal.md +0 -20
  56. data/docs/NetworkMinimalAsn.md +0 -22
  57. data/docs/NetworkMinimalCompany.md +0 -18
  58. data/docs/ParseBulkUserAgentStringsRequest.md +0 -18
  59. data/docs/ParseUserAgentStringRequest.md +0 -18
  60. data/docs/Security.md +0 -38
  61. data/docs/SecurityAPIResponse.md +0 -34
  62. data/docs/SecurityAPIXMLResponse.md +0 -34
  63. data/docs/SecurityAPIXMLResponseArray.md +0 -34
  64. data/docs/SecurityApi.md +0 -175
  65. data/docs/TimeConversionApi.md +0 -109
  66. data/docs/TimeConversionResponse.md +0 -24
  67. data/docs/TimeConversionXMLResponse.md +0 -24
  68. data/docs/TimeZone.md +0 -36
  69. data/docs/TimeZoneDetailedResponse.md +0 -26
  70. data/docs/TimeZoneDetailedXMLResponse.md +0 -26
  71. data/docs/TimeZoneDstEnd.md +0 -28
  72. data/docs/TimeZoneDstStart.md +0 -28
  73. data/docs/TimezoneAirport.md +0 -40
  74. data/docs/TimezoneApi.md +0 -99
  75. data/docs/TimezoneDetail.md +0 -56
  76. data/docs/TimezoneDetailDstEnd.md +0 -28
  77. data/docs/TimezoneDetailDstStart.md +0 -28
  78. data/docs/TimezoneLocation.md +0 -48
  79. data/docs/TimezoneLocode.md +0 -32
  80. data/docs/UserAgentApi.md +0 -235
  81. data/docs/UserAgentData.md +0 -32
  82. data/docs/UserAgentDataDevice.md +0 -24
  83. data/docs/UserAgentDataEngine.md +0 -24
  84. data/docs/UserAgentDataOperatingSystem.md +0 -26
  85. data/docs/UserAgentXMLData.md +0 -32
  86. data/docs/UserAgentXMLDataArray.md +0 -32
  87. data/git_push.sh +0 -57
  88. data/ipgeolocation_sdk.gemspec +0 -29
  89. data/lib/ipgeolocation_sdk/api/abuse_contact_api.rb +0 -86
  90. data/lib/ipgeolocation_sdk/api/asn_lookup_api.rb +0 -92
  91. data/lib/ipgeolocation_sdk/api/astronomy_api.rb +0 -116
  92. data/lib/ipgeolocation_sdk/api/ip_geolocation_api.rb +0 -186
  93. data/lib/ipgeolocation_sdk/api/ip_security_api.rb +0 -184
  94. data/lib/ipgeolocation_sdk/api/time_conversion_api.rb +0 -122
  95. data/lib/ipgeolocation_sdk/api/timezone_api.rb +0 -113
  96. data/lib/ipgeolocation_sdk/api/user_agent_api.rb +0 -158
  97. data/lib/ipgeolocation_sdk/api_client.rb +0 -393
  98. data/lib/ipgeolocation_sdk/api_error.rb +0 -58
  99. data/lib/ipgeolocation_sdk/configuration.rb +0 -308
  100. data/lib/ipgeolocation_sdk/models/abuse.rb +0 -305
  101. data/lib/ipgeolocation_sdk/models/abuse_response.rb +0 -229
  102. data/lib/ipgeolocation_sdk/models/abuse_response_xml.rb +0 -229
  103. data/lib/ipgeolocation_sdk/models/asn_connection.rb +0 -238
  104. data/lib/ipgeolocation_sdk/models/asn_response.rb +0 -230
  105. data/lib/ipgeolocation_sdk/models/asn_response_asn.rb +0 -368
  106. data/lib/ipgeolocation_sdk/models/asn_response_xml.rb +0 -229
  107. data/lib/ipgeolocation_sdk/models/asn_response_xml_asn.rb +0 -364
  108. data/lib/ipgeolocation_sdk/models/astronomy.rb +0 -445
  109. data/lib/ipgeolocation_sdk/models/astronomy_evening.rb +0 -301
  110. data/lib/ipgeolocation_sdk/models/astronomy_location.rb +0 -364
  111. data/lib/ipgeolocation_sdk/models/astronomy_morning.rb +0 -301
  112. data/lib/ipgeolocation_sdk/models/astronomy_response.rb +0 -238
  113. data/lib/ipgeolocation_sdk/models/astronomy_xml_response.rb +0 -238
  114. data/lib/ipgeolocation_sdk/models/bulk_ip_geolocation.rb +0 -113
  115. data/lib/ipgeolocation_sdk/models/bulk_ip_security.rb +0 -113
  116. data/lib/ipgeolocation_sdk/models/country_metadata.rb +0 -240
  117. data/lib/ipgeolocation_sdk/models/currency.rb +0 -238
  118. data/lib/ipgeolocation_sdk/models/error_response.rb +0 -220
  119. data/lib/ipgeolocation_sdk/models/error_xml_response.rb +0 -220
  120. data/lib/ipgeolocation_sdk/models/error_xml_response_array.rb +0 -220
  121. data/lib/ipgeolocation_sdk/models/geolocation_response.rb +0 -310
  122. data/lib/ipgeolocation_sdk/models/geolocation_xml_response.rb +0 -310
  123. data/lib/ipgeolocation_sdk/models/geolocation_xml_response_array.rb +0 -310
  124. data/lib/ipgeolocation_sdk/models/get_bulk_ip_geolocation200_response_inner1.rb +0 -105
  125. data/lib/ipgeolocation_sdk/models/get_bulk_ip_geolocation_request.rb +0 -241
  126. data/lib/ipgeolocation_sdk/models/get_bulk_ip_security_info200_response_inner1.rb +0 -105
  127. data/lib/ipgeolocation_sdk/models/location.rb +0 -400
  128. data/lib/ipgeolocation_sdk/models/location_minimal.rb +0 -373
  129. data/lib/ipgeolocation_sdk/models/network.rb +0 -238
  130. data/lib/ipgeolocation_sdk/models/network_asn.rb +0 -310
  131. data/lib/ipgeolocation_sdk/models/network_company.rb +0 -238
  132. data/lib/ipgeolocation_sdk/models/network_minimal.rb +0 -229
  133. data/lib/ipgeolocation_sdk/models/network_minimal_asn.rb +0 -238
  134. data/lib/ipgeolocation_sdk/models/network_minimal_company.rb +0 -220
  135. data/lib/ipgeolocation_sdk/models/parse_bulk_user_agent_strings_request.rb +0 -222
  136. data/lib/ipgeolocation_sdk/models/parse_user_agent_string_request.rb +0 -220
  137. data/lib/ipgeolocation_sdk/models/security.rb +0 -310
  138. data/lib/ipgeolocation_sdk/models/security_api_response.rb +0 -292
  139. data/lib/ipgeolocation_sdk/models/security_apixml_response.rb +0 -292
  140. data/lib/ipgeolocation_sdk/models/security_apixml_response_array.rb +0 -292
  141. data/lib/ipgeolocation_sdk/models/time_conversion_response.rb +0 -247
  142. data/lib/ipgeolocation_sdk/models/time_conversion_xml_response.rb +0 -247
  143. data/lib/ipgeolocation_sdk/models/time_zone.rb +0 -301
  144. data/lib/ipgeolocation_sdk/models/time_zone_detailed_response.rb +0 -256
  145. data/lib/ipgeolocation_sdk/models/time_zone_detailed_xml_response.rb +0 -256
  146. data/lib/ipgeolocation_sdk/models/time_zone_dst_end.rb +0 -265
  147. data/lib/ipgeolocation_sdk/models/time_zone_dst_start.rb +0 -265
  148. data/lib/ipgeolocation_sdk/models/timezone_airport.rb +0 -319
  149. data/lib/ipgeolocation_sdk/models/timezone_detail.rb +0 -391
  150. data/lib/ipgeolocation_sdk/models/timezone_detail_dst_end.rb +0 -265
  151. data/lib/ipgeolocation_sdk/models/timezone_detail_dst_start.rb +0 -265
  152. data/lib/ipgeolocation_sdk/models/timezone_location.rb +0 -355
  153. data/lib/ipgeolocation_sdk/models/timezone_locode.rb +0 -283
  154. data/lib/ipgeolocation_sdk/models/user_agent_data.rb +0 -283
  155. data/lib/ipgeolocation_sdk/models/user_agent_data_device.rb +0 -247
  156. data/lib/ipgeolocation_sdk/models/user_agent_data_engine.rb +0 -247
  157. data/lib/ipgeolocation_sdk/models/user_agent_data_operating_system.rb +0 -256
  158. data/lib/ipgeolocation_sdk/models/user_agent_xml_data.rb +0 -283
  159. data/lib/ipgeolocation_sdk/models/user_agent_xml_data_array.rb +0 -283
  160. data/spec/api/abuse_contact_api_spec.rb +0 -48
  161. data/spec/api/asn_lookup_api_spec.rb +0 -50
  162. data/spec/api/astronomy_api_spec.rb +0 -54
  163. data/spec/api/ip_location_api_spec.rb +0 -67
  164. data/spec/api/security_api_spec.rb +0 -67
  165. data/spec/api/time_conversion_api_spec.rb +0 -60
  166. data/spec/api/timezone_api_spec.rb +0 -56
  167. data/spec/api/user_agent_api_spec.rb +0 -74
  168. data/spec/models/abuse_response_spec.rb +0 -42
  169. data/spec/models/abuse_response_xml_spec.rb +0 -42
  170. data/spec/models/abuse_spec.rb +0 -90
  171. data/spec/models/asn_connection_spec.rb +0 -48
  172. data/spec/models/asn_response_asn_spec.rb +0 -126
  173. data/spec/models/asn_response_spec.rb +0 -42
  174. data/spec/models/asn_response_xml_asn_spec.rb +0 -126
  175. data/spec/models/asn_response_xml_spec.rb +0 -42
  176. data/spec/models/astronomy_evening_spec.rb +0 -90
  177. data/spec/models/astronomy_location_spec.rb +0 -132
  178. data/spec/models/astronomy_morning_spec.rb +0 -90
  179. data/spec/models/astronomy_response_spec.rb +0 -48
  180. data/spec/models/astronomy_spec.rb +0 -186
  181. data/spec/models/astronomy_xml_response_spec.rb +0 -48
  182. data/spec/models/country_metadata_spec.rb +0 -48
  183. data/spec/models/currency_spec.rb +0 -48
  184. data/spec/models/error_response_spec.rb +0 -36
  185. data/spec/models/error_xml_response_array_spec.rb +0 -36
  186. data/spec/models/error_xml_response_spec.rb +0 -36
  187. data/spec/models/geolocation_response_spec.rb +0 -96
  188. data/spec/models/geolocation_xml_response_array_spec.rb +0 -96
  189. data/spec/models/geolocation_xml_response_spec.rb +0 -96
  190. data/spec/models/get_bulk_ip_geolocation200_response_inner1_spec.rb +0 -32
  191. data/spec/models/get_bulk_ip_geolocation200_response_inner_spec.rb +0 -32
  192. data/spec/models/get_bulk_ip_geolocation_request_spec.rb +0 -36
  193. data/spec/models/get_bulk_ip_security_info200_response_inner1_spec.rb +0 -32
  194. data/spec/models/get_bulk_ip_security_info200_response_inner_spec.rb +0 -32
  195. data/spec/models/location_minimal_spec.rb +0 -138
  196. data/spec/models/location_spec.rb +0 -156
  197. data/spec/models/network_asn_spec.rb +0 -96
  198. data/spec/models/network_company_spec.rb +0 -48
  199. data/spec/models/network_minimal_asn_spec.rb +0 -48
  200. data/spec/models/network_minimal_company_spec.rb +0 -36
  201. data/spec/models/network_minimal_spec.rb +0 -42
  202. data/spec/models/network_spec.rb +0 -48
  203. data/spec/models/parse_bulk_user_agent_strings_request_spec.rb +0 -36
  204. data/spec/models/parse_user_agent_string_request_spec.rb +0 -36
  205. data/spec/models/security_api_response_spec.rb +0 -84
  206. data/spec/models/security_apixml_response_array_spec.rb +0 -84
  207. data/spec/models/security_apixml_response_spec.rb +0 -84
  208. data/spec/models/security_spec.rb +0 -96
  209. data/spec/models/time_conversion_response_spec.rb +0 -54
  210. data/spec/models/time_conversion_xml_response_spec.rb +0 -54
  211. data/spec/models/time_zone_detailed_response_spec.rb +0 -60
  212. data/spec/models/time_zone_detailed_xml_response_spec.rb +0 -60
  213. data/spec/models/time_zone_dst_end_spec.rb +0 -66
  214. data/spec/models/time_zone_dst_start_spec.rb +0 -66
  215. data/spec/models/time_zone_spec.rb +0 -90
  216. data/spec/models/timezone_airport_spec.rb +0 -102
  217. data/spec/models/timezone_detail_dst_end_spec.rb +0 -66
  218. data/spec/models/timezone_detail_dst_start_spec.rb +0 -66
  219. data/spec/models/timezone_detail_spec.rb +0 -150
  220. data/spec/models/timezone_location_spec.rb +0 -126
  221. data/spec/models/timezone_locode_spec.rb +0 -78
  222. data/spec/models/user_agent_data_device_spec.rb +0 -54
  223. data/spec/models/user_agent_data_engine_spec.rb +0 -54
  224. data/spec/models/user_agent_data_operating_system_spec.rb +0 -60
  225. data/spec/models/user_agent_data_spec.rb +0 -78
  226. data/spec/models/user_agent_xml_data_array_spec.rb +0 -78
  227. data/spec/models/user_agent_xml_data_spec.rb +0 -78
  228. data/spec/spec_helper.rb +0 -111
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ module IpgeolocationSdk
4
+ class LookupIpGeolocationRequest < ValueObject
5
+ attributes :ip, :lang, :include, :fields, :excludes, :user_agent, :headers, :output
6
+
7
+ def initialize(
8
+ ip: nil,
9
+ lang: nil,
10
+ include: nil,
11
+ fields: nil,
12
+ excludes: nil,
13
+ user_agent: nil,
14
+ headers: nil,
15
+ output: ResponseFormat::JSON
16
+ )
17
+ normalized_ip = self.class.send(:normalize_ip, ip)
18
+ normalized_lang = Language.normalize(lang)
19
+ normalized_include = self.class.send(:normalize_tokens, include, "include")
20
+ normalized_fields = self.class.send(:normalize_tokens, fields, "fields")
21
+ normalized_excludes = self.class.send(:normalize_tokens, excludes, "excludes")
22
+ normalized_user_agent = self.class.send(:normalize_optional_string, user_agent, "user_agent")
23
+ normalized_headers = self.class.send(:normalize_headers, headers)
24
+ normalized_output = ResponseFormat.normalize(output)
25
+
26
+ super(
27
+ ip: normalized_ip,
28
+ lang: normalized_lang,
29
+ include: normalized_include,
30
+ fields: normalized_fields,
31
+ excludes: normalized_excludes,
32
+ user_agent: normalized_user_agent,
33
+ headers: normalized_headers,
34
+ output: normalized_output
35
+ )
36
+ end
37
+ end
38
+
39
+ class BulkLookupIpGeolocationRequest < ValueObject
40
+ attributes :ips, :lang, :include, :fields, :excludes, :user_agent, :headers, :output
41
+
42
+ def initialize(
43
+ ips:,
44
+ lang: nil,
45
+ include: nil,
46
+ fields: nil,
47
+ excludes: nil,
48
+ user_agent: nil,
49
+ headers: nil,
50
+ output: ResponseFormat::JSON
51
+ )
52
+ normalized_ips = self.class.send(:normalize_tokens, ips, "ips")
53
+ raise ValidationError, "ips must not be empty" if normalized_ips.empty?
54
+ raise ValidationError, "ips must contain at most 50000 entries" if normalized_ips.length > 50_000
55
+
56
+ normalized_lang = Language.normalize(lang)
57
+ normalized_include = self.class.send(:normalize_tokens, include, "include")
58
+ normalized_fields = self.class.send(:normalize_tokens, fields, "fields")
59
+ normalized_excludes = self.class.send(:normalize_tokens, excludes, "excludes")
60
+ normalized_user_agent = self.class.send(:normalize_optional_string, user_agent, "user_agent")
61
+ normalized_headers = self.class.send(:normalize_headers, headers)
62
+ normalized_output = ResponseFormat.normalize(output)
63
+
64
+ super(
65
+ ips: normalized_ips,
66
+ lang: normalized_lang,
67
+ include: normalized_include,
68
+ fields: normalized_fields,
69
+ excludes: normalized_excludes,
70
+ user_agent: normalized_user_agent,
71
+ headers: normalized_headers,
72
+ output: normalized_output
73
+ )
74
+ end
75
+ end
76
+
77
+ class << LookupIpGeolocationRequest
78
+ private
79
+
80
+ def normalize_ip(value)
81
+ return nil if value.nil?
82
+ raise TypeError, "ip must be a string" unless value.is_a?(String)
83
+
84
+ normalized = value.strip
85
+ raise ValidationError, "ip must not be blank" if normalized.empty?
86
+
87
+ normalized
88
+ end
89
+
90
+ def normalize_optional_string(value, field)
91
+ return nil if value.nil?
92
+ raise TypeError, "#{field} must be a string" unless value.is_a?(String)
93
+
94
+ normalized = value.strip
95
+ raise ValidationError, "#{field} must not be blank" if normalized.empty?
96
+ normalized
97
+ end
98
+
99
+ def normalize_tokens(values, field)
100
+ return [].freeze if values.nil?
101
+ raise ValidationError, "#{field} must be an array of strings, not a single string" if values.is_a?(String)
102
+ raise TypeError, "#{field} must be an array of strings" unless values.is_a?(Array)
103
+
104
+ normalized = values.map do |value|
105
+ raise TypeError, "#{field} values must be strings" unless value.is_a?(String)
106
+
107
+ token = value.strip
108
+ raise ValidationError, "#{field} values must not be blank" if token.empty?
109
+
110
+ token
111
+ end
112
+ normalized.freeze
113
+ end
114
+
115
+ def normalize_headers(value)
116
+ return {}.freeze if value.nil?
117
+ raise TypeError, "headers must be a hash" unless value.is_a?(Hash)
118
+
119
+ value.each_with_object({}) do |(raw_name, raw_value), result|
120
+ raise TypeError, "header names must be strings" unless raw_name.is_a?(String)
121
+
122
+ name = raw_name.strip
123
+ raise ValidationError, "header names must not be blank" if name.empty?
124
+ raise ValidationError, "header names must not contain CR or LF" if contains_cr_or_lf?(name)
125
+
126
+ values = normalize_header_values(raw_value)
127
+ result[name] = values.freeze
128
+ end.freeze
129
+ end
130
+
131
+ def normalize_header_values(value)
132
+ raw_values =
133
+ case value
134
+ when String
135
+ [value]
136
+ when Array
137
+ value
138
+ else
139
+ raise TypeError, "header values must be strings or arrays of strings"
140
+ end
141
+
142
+ raise ValidationError, "header values must not be empty" if raw_values.empty?
143
+
144
+ raw_values.map do |item|
145
+ raise TypeError, "header values must contain only strings" unless item.is_a?(String)
146
+
147
+ normalized = item.strip
148
+ raise ValidationError, "header values must not contain blank strings" if normalized.empty?
149
+ raise ValidationError, "header values must not contain CR or LF" if contains_cr_or_lf?(normalized)
150
+
151
+ normalized
152
+ end
153
+ end
154
+
155
+ def contains_cr_or_lf?(value)
156
+ value.include?("\r") || value.include?("\n")
157
+ end
158
+ end
159
+
160
+ class << BulkLookupIpGeolocationRequest
161
+ private
162
+
163
+ def normalize_optional_string(value, field)
164
+ LookupIpGeolocationRequest.send(:normalize_optional_string, value, field)
165
+ end
166
+
167
+ def normalize_tokens(values, field)
168
+ LookupIpGeolocationRequest.send(:normalize_tokens, values, field)
169
+ end
170
+
171
+ def normalize_headers(value)
172
+ LookupIpGeolocationRequest.send(:normalize_headers, value)
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,433 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "json"
5
+
6
+ module IpgeolocationSdk
7
+ module Serde
8
+ module_function
9
+
10
+ def parse_single_lookup(body)
11
+ payload = parse_json(body, "Failed to deserialize API response")
12
+ raise SerializationError, "Failed to deserialize API response" unless payload.is_a?(Hash)
13
+
14
+ parse_ip_geolocation_response(payload)
15
+ end
16
+
17
+ def parse_bulk_lookup(body)
18
+ payload = parse_json(body, "Failed to deserialize bulk lookup response")
19
+ unless payload.is_a?(Array)
20
+ raise SerializationError, "Failed to deserialize bulk response: expected an array payload"
21
+ end
22
+
23
+ payload.map do |item|
24
+ if bulk_error_item?(item)
25
+ BulkLookupError.new(
26
+ error: BulkLookupErrorDetails.new(message: string_or_nil(item["message"]))
27
+ )
28
+ else
29
+ raise SerializationError, "Failed to deserialize API response" unless item.is_a?(Hash)
30
+
31
+ BulkLookupSuccess.new(data: parse_ip_geolocation_response(item))
32
+ end
33
+ end.freeze
34
+ end
35
+
36
+ def build_query(params)
37
+ items = params.each_with_object([]) do |(key, value), result|
38
+ next if key.nil? || value.nil?
39
+
40
+ key_text = key.to_s.strip
41
+ value_text = value.to_s.strip
42
+ next if key_text.empty? || value_text.empty?
43
+
44
+ result << "#{CGI.escape(key_text)}=#{CGI.escape(value_text)}"
45
+ end
46
+
47
+ items.empty? ? "" : "?#{items.join('&')}"
48
+ end
49
+
50
+ def merge_headers(*header_maps)
51
+ merged = {}
52
+ names_by_lower = {}
53
+
54
+ header_maps.compact.each do |header_map|
55
+ header_map.each do |raw_name, raw_values|
56
+ next if raw_name.nil?
57
+
58
+ name = raw_name.to_s.strip
59
+ next if name.empty?
60
+
61
+ existing_name = names_by_lower[name.downcase]
62
+ merged.delete(existing_name) if existing_name && existing_name != name
63
+ names_by_lower[name.downcase] = name
64
+ merged[name] = Array(raw_values).map(&:to_s).freeze
65
+ end
66
+ end
67
+
68
+ merged.freeze
69
+ end
70
+
71
+ def resolve_user_agent_header(request_user_agent, request_headers, default_user_agent)
72
+ return [request_user_agent].freeze unless request_user_agent.nil?
73
+
74
+ custom = first_header_ignore_case(request_headers, "User-Agent")
75
+ return [custom].freeze unless custom.nil?
76
+
77
+ [default_user_agent].freeze
78
+ end
79
+
80
+ def validate_json_output(output)
81
+ return unless ResponseFormat.normalize(output) == ResponseFormat::XML
82
+
83
+ raise ValidationError, "XML output is not supported by typed methods. Use ResponseFormat::JSON."
84
+ end
85
+
86
+ def to_api_error(status_code, body)
87
+ api_message = extract_api_message(body)
88
+ message =
89
+ if api_message.nil?
90
+ "API request failed with HTTP status #{status_code}"
91
+ else
92
+ "API request failed with HTTP status #{status_code}: #{api_message}"
93
+ end
94
+
95
+ klass =
96
+ case status_code
97
+ when 400 then BadRequestError
98
+ when 401 then UnauthorizedError
99
+ when 404 then NotFoundError
100
+ when 405 then MethodNotAllowedError
101
+ when 413 then PayloadTooLargeError
102
+ when 415 then UnsupportedMediaTypeError
103
+ when 423 then LockedError
104
+ when 429 then RateLimitError
105
+ when 499 then ClientClosedRequestError
106
+ else
107
+ status_code.between?(500, 599) ? ServerError : ApiError
108
+ end
109
+
110
+ klass.new(message, status_code, api_message)
111
+ end
112
+
113
+ def extract_api_message(body)
114
+ return nil if body.nil?
115
+
116
+ normalized = body.to_s.strip
117
+ return nil if normalized.empty?
118
+
119
+ payload = JSON.parse(normalized)
120
+ return normalized[0, 512] unless payload.is_a?(Hash)
121
+
122
+ message = payload["message"]
123
+ if message.nil? && payload["error"].is_a?(Hash)
124
+ message = payload["error"]["message"]
125
+ end
126
+ string_or_nil(message) || normalized[0, 512]
127
+ rescue JSON::ParserError
128
+ normalized[0, 512]
129
+ end
130
+
131
+ def parse_int_header(value)
132
+ return nil if value.nil?
133
+
134
+ normalized = value.to_s.strip
135
+ return nil unless normalized.match?(/\A\d+\z/)
136
+
137
+ Integer(normalized, 10)
138
+ end
139
+
140
+ def first_header_ignore_case(headers, name)
141
+ headers.each do |key, values|
142
+ return values.first if key.casecmp?(name)
143
+ end
144
+ nil
145
+ end
146
+
147
+ def to_metadata(status_code, duration_ms, raw_headers)
148
+ headers = raw_headers || {}.freeze
149
+ ApiResponseMetadata.new(
150
+ credits_charged: parse_int_header(first_header_ignore_case(headers, "X-Credits-Charged")),
151
+ successful_records: parse_int_header(first_header_ignore_case(headers, "X-Successful-Record")),
152
+ status_code: status_code,
153
+ duration_ms: duration_ms,
154
+ raw_headers: headers
155
+ )
156
+ end
157
+
158
+ def to_plain_data(value, mode = JsonOutputMode::COMPACT)
159
+ normalized_mode = JsonOutputMode.normalize(mode)
160
+
161
+ case value
162
+ when ValueObject
163
+ value.class.attribute_names.each_with_object({}) do |name, result|
164
+ item = value.public_send(name)
165
+ next if normalized_mode == JsonOutputMode::COMPACT && item.nil?
166
+
167
+ result[name.to_s] = to_plain_data(item, normalized_mode)
168
+ end
169
+ when Array
170
+ value.map { |item| to_plain_data(item, normalized_mode) }
171
+ when Hash
172
+ value.each_with_object({}) do |(key, item), result|
173
+ next if normalized_mode == JsonOutputMode::COMPACT && item.nil?
174
+
175
+ result[key] = to_plain_data(item, normalized_mode)
176
+ end
177
+ else
178
+ value
179
+ end
180
+ end
181
+
182
+ def parse_ip_geolocation_response(node)
183
+ IpGeolocationResponse.new(
184
+ ip: string_or_nil(node["ip"]),
185
+ domain: string_or_nil(node["domain"]),
186
+ hostname: string_or_nil(node["hostname"]),
187
+ location: parse_optional_object(node["location"]) { |item| parse_location(item) },
188
+ country_metadata: parse_optional_object(node["country_metadata"]) { |item| parse_country_metadata(item) },
189
+ network: parse_optional_object(node["network"]) { |item| parse_network(item) },
190
+ currency: parse_optional_object(node["currency"]) { |item| parse_currency(item) },
191
+ asn: parse_optional_object(node["asn"]) { |item| parse_asn(item) },
192
+ company: parse_optional_object(node["company"]) { |item| parse_company(item) },
193
+ security: parse_optional_object(node["security"]) { |item| parse_security(item) },
194
+ abuse: parse_optional_object(node["abuse"]) { |item| parse_abuse(item) },
195
+ time_zone: parse_optional_object(node["time_zone"]) { |item| parse_time_zone_info(item) },
196
+ user_agent: parse_optional_object(node["user_agent"]) { |item| parse_user_agent(item) }
197
+ )
198
+ end
199
+
200
+ def parse_abuse(node)
201
+ Abuse.new(
202
+ route: string_or_nil(node["route"]),
203
+ country: string_or_nil(node["country"]),
204
+ name: string_or_nil(node["name"]),
205
+ organization: string_or_nil(node["organization"]),
206
+ kind: string_or_nil(node["kind"]),
207
+ address: string_or_nil(node["address"]),
208
+ emails: string_array_or_nil(node["emails"]),
209
+ phone_numbers: string_array_or_nil(node["phone_numbers"])
210
+ )
211
+ end
212
+
213
+ def parse_asn(node)
214
+ Asn.new(
215
+ as_number: string_or_nil(node["as_number"]),
216
+ organization: string_or_nil(node["organization"]),
217
+ country: string_or_nil(node["country"]),
218
+ type: string_or_nil(node["type"]),
219
+ domain: string_or_nil(node["domain"]),
220
+ date_allocated: string_or_nil(node["date_allocated"]),
221
+ rir: string_or_nil(node["rir"])
222
+ )
223
+ end
224
+
225
+ def parse_company(node)
226
+ Company.new(
227
+ name: string_or_nil(node["name"]),
228
+ type: string_or_nil(node["type"]),
229
+ domain: string_or_nil(node["domain"])
230
+ )
231
+ end
232
+
233
+ def parse_country_metadata(node)
234
+ CountryMetadata.new(
235
+ calling_code: string_or_nil(node["calling_code"]),
236
+ tld: string_or_nil(node["tld"]),
237
+ languages: string_array_or_nil(node["languages"])
238
+ )
239
+ end
240
+
241
+ def parse_currency(node)
242
+ Currency.new(
243
+ code: string_or_nil(node["code"]),
244
+ name: string_or_nil(node["name"]),
245
+ symbol: string_or_nil(node["symbol"])
246
+ )
247
+ end
248
+
249
+ def parse_dst_transition(node)
250
+ DstTransition.new(
251
+ utc_time: string_or_nil(node["utc_time"]),
252
+ duration: string_or_nil(node["duration"]),
253
+ gap: boolean_or_nil(node["gap"]),
254
+ date_time_after: string_or_nil(node["date_time_after"]),
255
+ date_time_before: string_or_nil(node["date_time_before"]),
256
+ overlap: boolean_or_nil(node["overlap"])
257
+ )
258
+ end
259
+
260
+ def parse_location(node)
261
+ Location.new(
262
+ continent_code: string_or_nil(node["continent_code"]),
263
+ continent_name: string_or_nil(node["continent_name"]),
264
+ country_code2: string_or_nil(node["country_code2"]),
265
+ country_code3: string_or_nil(node["country_code3"]),
266
+ country_name: string_or_nil(node["country_name"]),
267
+ country_name_official: string_or_nil(node["country_name_official"]),
268
+ country_capital: string_or_nil(node["country_capital"]),
269
+ state_prov: string_or_nil(node["state_prov"]),
270
+ state_code: string_or_nil(node["state_code"]),
271
+ district: string_or_nil(node["district"]),
272
+ city: string_or_nil(node["city"]),
273
+ locality: string_or_nil(node["locality"]),
274
+ accuracy_radius: string_or_nil(node["accuracy_radius"]),
275
+ confidence: LocationConfidence.normalize(node["confidence"]),
276
+ dma_code: string_or_nil(node["dma_code"]),
277
+ zipcode: string_or_nil(node["zipcode"]),
278
+ latitude: string_or_nil(node["latitude"]),
279
+ longitude: string_or_nil(node["longitude"]),
280
+ is_eu: boolean_or_nil(node["is_eu"]),
281
+ country_flag: string_or_nil(node["country_flag"]),
282
+ geoname_id: string_or_nil(node["geoname_id"]),
283
+ country_emoji: string_or_nil(node["country_emoji"])
284
+ )
285
+ end
286
+
287
+ def parse_network(node)
288
+ Network.new(
289
+ connection_type: string_or_nil(node["connection_type"]),
290
+ route: string_or_nil(node["route"]),
291
+ is_anycast: boolean_or_nil(node["is_anycast"])
292
+ )
293
+ end
294
+
295
+ def parse_security(node)
296
+ Security.new(
297
+ threat_score: numeric_or_nil(node["threat_score"]),
298
+ is_tor: boolean_or_nil(node["is_tor"]),
299
+ is_proxy: boolean_or_nil(node["is_proxy"]),
300
+ proxy_provider_names: string_array_or_nil(node["proxy_provider_names"]),
301
+ proxy_confidence_score: numeric_or_nil(node["proxy_confidence_score"]),
302
+ proxy_last_seen: string_or_nil(node["proxy_last_seen"]),
303
+ is_residential_proxy: boolean_or_nil(node["is_residential_proxy"]),
304
+ is_vpn: boolean_or_nil(node["is_vpn"]),
305
+ vpn_provider_names: string_array_or_nil(node["vpn_provider_names"]),
306
+ vpn_confidence_score: numeric_or_nil(node["vpn_confidence_score"]),
307
+ vpn_last_seen: string_or_nil(node["vpn_last_seen"]),
308
+ is_relay: boolean_or_nil(node["is_relay"]),
309
+ relay_provider_name: string_or_nil(node["relay_provider_name"]),
310
+ is_anonymous: boolean_or_nil(node["is_anonymous"]),
311
+ is_known_attacker: boolean_or_nil(node["is_known_attacker"]),
312
+ is_bot: boolean_or_nil(node["is_bot"]),
313
+ is_spam: boolean_or_nil(node["is_spam"]),
314
+ is_cloud_provider: boolean_or_nil(node["is_cloud_provider"]),
315
+ cloud_provider_name: string_or_nil(node["cloud_provider_name"])
316
+ )
317
+ end
318
+
319
+ def parse_time_zone_info(node)
320
+ TimeZoneInfo.new(
321
+ name: string_or_nil(node["name"]),
322
+ offset: numeric_or_nil(node["offset"]),
323
+ offset_with_dst: numeric_or_nil(node["offset_with_dst"]),
324
+ current_time: string_or_nil(node["current_time"]),
325
+ current_time_unix: numeric_or_nil(node["current_time_unix"]),
326
+ current_tz_abbreviation: string_or_nil(node["current_tz_abbreviation"]),
327
+ current_tz_full_name: string_or_nil(node["current_tz_full_name"]),
328
+ standard_tz_abbreviation: string_or_nil(node["standard_tz_abbreviation"]),
329
+ standard_tz_full_name: string_or_nil(node["standard_tz_full_name"]),
330
+ is_dst: boolean_or_nil(node["is_dst"]),
331
+ dst_savings: numeric_or_nil(node["dst_savings"]),
332
+ dst_exists: boolean_or_nil(node["dst_exists"]),
333
+ dst_tz_abbreviation: string_or_nil(node["dst_tz_abbreviation"]),
334
+ dst_tz_full_name: string_or_nil(node["dst_tz_full_name"]),
335
+ dst_start: parse_optional_object(node["dst_start"]) { |item| parse_dst_transition(item) },
336
+ dst_end: parse_optional_object(node["dst_end"]) { |item| parse_dst_transition(item) }
337
+ )
338
+ end
339
+
340
+ def parse_user_agent(node)
341
+ UserAgent.new(
342
+ user_agent_string: string_or_nil(node["user_agent_string"]),
343
+ name: string_or_nil(node["name"]),
344
+ type: string_or_nil(node["type"]),
345
+ version: string_or_nil(node["version"]),
346
+ version_major: string_or_nil(node["version_major"]),
347
+ device: parse_optional_object(node["device"]) { |item| parse_user_agent_device(item) },
348
+ engine: parse_optional_object(node["engine"]) { |item| parse_user_agent_engine(item) },
349
+ operating_system: parse_optional_object(node["operating_system"]) { |item| parse_user_agent_operating_system(item) }
350
+ )
351
+ end
352
+
353
+ def parse_user_agent_device(node)
354
+ UserAgentDevice.new(
355
+ name: string_or_nil(node["name"]),
356
+ type: string_or_nil(node["type"]),
357
+ brand: string_or_nil(node["brand"]),
358
+ cpu: string_or_nil(node["cpu"])
359
+ )
360
+ end
361
+
362
+ def parse_user_agent_engine(node)
363
+ UserAgentEngine.new(
364
+ name: string_or_nil(node["name"]),
365
+ type: string_or_nil(node["type"]),
366
+ version: string_or_nil(node["version"]),
367
+ version_major: string_or_nil(node["version_major"])
368
+ )
369
+ end
370
+
371
+ def parse_user_agent_operating_system(node)
372
+ UserAgentOperatingSystem.new(
373
+ name: string_or_nil(node["name"]),
374
+ type: string_or_nil(node["type"]),
375
+ version: string_or_nil(node["version"]),
376
+ version_major: string_or_nil(node["version_major"]),
377
+ build: string_or_nil(node["build"])
378
+ )
379
+ end
380
+
381
+ def parse_optional_object(value)
382
+ return nil if value.nil?
383
+ raise SerializationError, "Failed to deserialize API response" unless value.is_a?(Hash)
384
+
385
+ yield(value)
386
+ end
387
+
388
+ def string_or_nil(value)
389
+ return nil if value.nil?
390
+ raise SerializationError, "Failed to deserialize API response" unless value.is_a?(String)
391
+
392
+ value
393
+ end
394
+
395
+ def numeric_or_nil(value)
396
+ return nil if value.nil?
397
+ raise SerializationError, "Failed to deserialize API response" unless value.is_a?(Numeric)
398
+
399
+ value
400
+ end
401
+
402
+ def boolean_or_nil(value)
403
+ return nil if value.nil?
404
+ raise SerializationError, "Failed to deserialize API response" unless value == true || value == false
405
+
406
+ value
407
+ end
408
+
409
+ def string_array_or_nil(value)
410
+ return nil if value.nil?
411
+ raise SerializationError, "Failed to deserialize API response" unless value.is_a?(Array)
412
+
413
+ value.map do |item|
414
+ raise SerializationError, "Failed to deserialize API response" unless item.is_a?(String)
415
+
416
+ item
417
+ end.freeze
418
+ end
419
+
420
+ def parse_json(body, message)
421
+ JSON.parse(body)
422
+ rescue JSON::ParserError, TypeError => error
423
+ raise SerializationError.new(message, cause: error)
424
+ end
425
+
426
+ def bulk_error_item?(item)
427
+ return false unless item.is_a?(Hash)
428
+ return false if item["success"] == true
429
+
430
+ item["success"] == false || item.key?("message")
431
+ end
432
+ end
433
+ end