dadata-rb 3.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,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dadata
4
+ # Базовый класс обработки ошибок
5
+ class Error < StandardError; end
6
+
7
+ class ConfigurationError < Error; end
8
+
9
+ # Raised when the API responds with a non-success HTTP status.
10
+ class ApiError < Error
11
+ attr_reader :status
12
+
13
+ def initialize(status, message)
14
+ @status = status
15
+ super("Error: #{status} - #{message}")
16
+ end
17
+ end
18
+
19
+ # Raised when the request never reaches a usable response (network failure).
20
+ # Carries its message through StandardError, so `raise ConnectionError, 'msg'` works.
21
+ class ConnectionError < Error; end
22
+
23
+ # Raised when the request exceeds the configured timeout. A ConnectionError so
24
+ # callers that rescue ConnectionError keep working; rescue TimeoutError first
25
+ # when distinct handling is needed.
26
+ class TimeoutError < ConnectionError; end
27
+
28
+ # Status-specific API errors. The HTTP status => class mapping lives in
29
+ # Dadata::ClientBase::STATUS_ERRORS (400, 401, 403, 404, 429 respectively).
30
+ class BadRequestError < ApiError; end
31
+ class UnauthorizedError < ApiError; end
32
+ class AuthenticationError < ApiError; end
33
+ class NotFoundError < ApiError; end
34
+ class RateLimitError < ApiError; end
35
+ end
@@ -0,0 +1,272 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require 'faraday/retry'
5
+ require 'json'
6
+ require_relative '../sensitive_data'
7
+
8
+ module Dadata
9
+ # Faraday middleware that handles logging of requests and responses while ensuring
10
+ # sensitive data is properly sanitized. This middleware is automatically added to
11
+ # all DaData API clients.
12
+ #
13
+ # @api private
14
+ class SensitiveDataMiddleware < Faraday::Middleware
15
+ include SensitiveData
16
+
17
+ # Processes the request, logs it securely, and handles any errors
18
+ #
19
+ # @param env [Hash] The request environment
20
+ # @return [Faraday::Response] The response from the next middleware
21
+ def call(env)
22
+ log_request(env)
23
+ @app.call(env).on_complete do |response_env|
24
+ log_response(response_env)
25
+ end
26
+ rescue Faraday::Error => e
27
+ log_error(e)
28
+ raise
29
+ end
30
+
31
+ private
32
+
33
+ # Logs the request details with sensitive data filtered
34
+ #
35
+ # @param env [Hash] The request environment
36
+ # @return [void]
37
+ def log_request(env)
38
+ return unless logger
39
+
40
+ logger.debug do
41
+ msg = "DaData Request: #{env.method.upcase} #{env.url}"
42
+ msg += "\nBody: #{request_body(env)}" if log_bodies? && request_body(env)
43
+ msg += "\nHeaders: #{sanitize_headers(env.request_headers)}"
44
+ msg
45
+ end
46
+ end
47
+
48
+ # Returns the request payload (POST body or GET params) for logging, or nil
49
+ # when there is nothing to log. Only consulted when body logging is enabled.
50
+ #
51
+ # @param env [Hash] The request environment
52
+ # @return [String, nil]
53
+ def request_body(env)
54
+ body = env.body
55
+ return body if body && !body.empty?
56
+
57
+ params = env.params
58
+ params.inspect if params && !params.empty?
59
+ end
60
+
61
+ # Whether request payloads may be logged. Off by default because payloads
62
+ # contain personal data this API processes.
63
+ #
64
+ # @return [Boolean]
65
+ def log_bodies?
66
+ Dadata.configuration&.log_request_bodies || false
67
+ end
68
+
69
+ # Logs the response details with sensitive data filtered
70
+ #
71
+ # @param env [Hash] The response environment
72
+ # @return [void]
73
+ def log_response(env)
74
+ return unless logger
75
+
76
+ logger.debug do
77
+ msg = "DaData Response: #{env.status}"
78
+ msg += "\nHeaders: #{sanitize_headers(env.response_headers)}"
79
+ msg
80
+ end
81
+ end
82
+
83
+ # Logs error details with sensitive data filtered
84
+ #
85
+ # @param error [Faraday::Error] The error that occurred
86
+ # @return [void]
87
+ def log_error(error)
88
+ return unless logger
89
+
90
+ logger.error do
91
+ msg = "DaData Error: #{error.class.name}"
92
+ msg += "\nMessage: #{sanitize_message(error.message)}"
93
+ msg += "\nHeaders: #{sanitize_headers(error.response[:response_headers] || {})}" if error.response
94
+ msg
95
+ end
96
+ end
97
+
98
+ # Gets the logger from the DaData configuration
99
+ #
100
+ # @return [Logger, nil] The configured logger
101
+ def logger
102
+ Dadata.configuration&.logger
103
+ end
104
+ end
105
+
106
+ # Base client class that handles HTTP communication with the DaData API.
107
+ # Implements secure logging and request/response handling.
108
+ #
109
+ # @api private
110
+ class ClientBase
111
+ include SensitiveData
112
+
113
+ # HTTP status codes and their descriptions
114
+ ERRORS = {
115
+ 200 => 'Request processed successfully',
116
+ 400 => 'Invalid request (invalid JSON or XML)',
117
+ 401 => 'Missing API key or secret key, or non-existent key used',
118
+ 403 => 'Invalid API key, unconfirmed email, or daily request limit exceeded',
119
+ 404 => 'Service not found',
120
+ 405 => 'Request method other than POST used',
121
+ 413 => 'Request too long or too many conditions',
122
+ 429 => 'Too many requests per second or new connections per minute',
123
+ 500 => 'Internal service error'
124
+ }.freeze
125
+
126
+ # Maps HTTP status codes to the specific error class to raise.
127
+ # Any status not listed falls back to the generic ApiError.
128
+ STATUS_ERRORS = {
129
+ 400 => BadRequestError,
130
+ 401 => UnauthorizedError,
131
+ 403 => AuthenticationError,
132
+ 404 => NotFoundError,
133
+ 429 => RateLimitError
134
+ }.freeze
135
+
136
+ # Creates a new client instance
137
+ #
138
+ # @param base_url [String] The base URL for API requests
139
+ # @param token [String] The API token for authentication
140
+ # @param secret [String, nil] Optional secret key for additional authentication
141
+ def initialize(base_url, token, secret = nil)
142
+ @base_url = base_url
143
+ @token = token
144
+ @secret = secret
145
+ @connection = build_connection
146
+ @logger = Dadata.configuration&.logger
147
+ end
148
+
149
+ # Submits a request to the API
150
+ #
151
+ # @param url [String] The endpoint URL
152
+ # @param data [Hash] The request data
153
+ # @param method [Symbol] The HTTP method to use (:get or :post)
154
+ # @param timeout [Integer] Request timeout in seconds
155
+ # @return [Hash] The parsed response
156
+ # @raise [ApiError] If the API returns an error
157
+ # @raise [ConnectionError] If there's a network error
158
+ def submit(url, data, method = :get, timeout: Dadata.timeout_sec)
159
+ response = send_request(url, data, method, timeout)
160
+ handle_response(response)
161
+ rescue Faraday::Error => e
162
+ handle_connection_error(e)
163
+ end
164
+
165
+ # Closes the persistent connection, releasing any pooled sockets.
166
+ #
167
+ # @return [void]
168
+ def close
169
+ @connection.close if @connection.respond_to?(:close)
170
+ end
171
+
172
+ private
173
+
174
+ # Builds the Faraday connection with appropriate middleware and settings
175
+ #
176
+ # @return [Faraday::Connection]
177
+ def build_connection
178
+ require 'faraday/net_http_persistent'
179
+
180
+ Faraday.new(@base_url) do |conn|
181
+ conn.request :json
182
+ conn.request :retry, {
183
+ max: 2,
184
+ interval: 0.05,
185
+ interval_randomness: 0.5,
186
+ backoff_factor: 2,
187
+ exceptions: [
188
+ Faraday::ConnectionFailed,
189
+ Faraday::TimeoutError,
190
+ 'Timeout::Error'
191
+ ]
192
+ }
193
+
194
+ conn.use SensitiveDataMiddleware
195
+
196
+ conn.response :json, content_type: /\bjson$/
197
+
198
+ conn.headers = {
199
+ 'Content-Type' => 'application/json',
200
+ 'Accept' => 'application/json',
201
+ 'Authorization' => "Token #{@token}"
202
+ }
203
+ conn.headers['X-Secret'] = @secret if @secret
204
+
205
+ conn.adapter :net_http_persistent do |http|
206
+ http.read_timeout = Dadata.configuration&.timeout_sec || 10
207
+ http.open_timeout = Dadata.configuration&.timeout_sec || 10
208
+ http.write_timeout = Dadata.configuration&.timeout_sec || 10
209
+ end
210
+ end
211
+ end
212
+
213
+ # Sends a request to the API
214
+ #
215
+ # @param url [String] The endpoint URL
216
+ # @param data [Hash] The request data
217
+ # @param method [Symbol] The HTTP method to use (:get or :post)
218
+ # @param timeout [Integer] Request timeout in seconds
219
+ # @return [Faraday::Response] The response from the API
220
+ def send_request(url, data, method, timeout)
221
+ @connection.public_send(method) do |req|
222
+ req.url(url)
223
+ req.options.timeout = timeout
224
+ req.body = data.to_json unless method == :get
225
+ req.params = data if method == :get
226
+ end
227
+ end
228
+
229
+ # Handles the response from the API
230
+ #
231
+ # @param response [Faraday::Response] The response from the API
232
+ # @return [Hash] The parsed response
233
+ # @raise [ApiError] If the API returns an error
234
+ def handle_response(response)
235
+ return response.body if response.success?
236
+
237
+ error_message = ERRORS[response.status] || 'Unknown error'
238
+ log_error("API Error: #{error_message} (#{response.status})")
239
+
240
+ error_class = STATUS_ERRORS.fetch(response.status, ApiError)
241
+ raise error_class.new(response.status, error_message)
242
+ end
243
+
244
+ # Handles connection errors
245
+ #
246
+ # @param error [Faraday::Error] The error that occurred
247
+ # @raise [ConnectionError] If there's a network error
248
+ def handle_connection_error(error)
249
+ sanitized_error = sanitize_message(error.message)
250
+ log_error("Connection Error: #{sanitized_error}")
251
+ log_error("Headers: #{sanitize_headers(@connection.headers)}")
252
+
253
+ case error
254
+ when Faraday::TimeoutError
255
+ raise TimeoutError, 'Request timed out'
256
+ when Faraday::ConnectionFailed
257
+ raise ConnectionError, 'Failed to connect'
258
+ else
259
+ raise ConnectionError, 'Request failed'
260
+ end
261
+ end
262
+
263
+ # Logs an error, with sensitive data filtered. Sanitization comes from
264
+ # the shared SensitiveData module.
265
+ #
266
+ # @param message [String] The error message
267
+ # @return [void]
268
+ def log_error(message)
269
+ @logger&.error(sanitize_message(message))
270
+ end
271
+ end
272
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module Dadata
6
+ # Client for data cleaning and standardization operations
7
+ class CleanClient < ClientBase
8
+ BASE_URL = 'https://cleaner.dadata.ru/api/v1/'
9
+
10
+ def initialize(token = Dadata.api_key, secret = Dadata.secret_key)
11
+ super(BASE_URL, token, secret)
12
+ end
13
+
14
+ # Clean and standardize a single value
15
+ #
16
+ # @param name [String] Type of cleaning to apply:
17
+ # - address - postal address
18
+ # - phone - phone number
19
+ # - passport - passport number
20
+ # - name - full name
21
+ # - email - email address
22
+ # - birthdate - date
23
+ # - vehicle - vehicle brand and model
24
+ # - simple_party_name - company name
25
+ # @param source [String] Value to clean
26
+ # @return [Hash, nil] Cleaned data or nil if cleaning failed
27
+ def clean(name, source)
28
+ response = submit("clean/#{name}", [source], :post)
29
+ response&.first
30
+ end
31
+
32
+ # Clean and standardize a composite record
33
+ #
34
+ # @param structure [Array<String>] Record structure with fields:
35
+ # - AS_IS - leave as is (no standardization)
36
+ # - SIMPLE_PARTY_NAME - parse company name
37
+ # - NAME - parse as full name
38
+ # - BIRTHDATE - parse as date
39
+ # - ADDRESS - parse as address
40
+ # - PHONE - parse as phone
41
+ # - PASSPORT - passport number
42
+ # - EMAIL - email address
43
+ # - VEHICLE - vehicle brand and model
44
+ # @param record [Array<String>] Record to process; field order must match structure
45
+ # @return [Hash, nil] Cleaned data or nil if cleaning failed
46
+ def clean_record(structure, record)
47
+ data = { structure:, data: [record] }
48
+ response = submit('clean', data, :post)
49
+ response&.dig('data', 0)
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+ require 'date'
5
+
6
+ module Dadata
7
+ # Client for managing DaData subscriber profile
8
+ class ProfileClient < ClientBase
9
+ BASE_URL = 'https://dadata.ru/api/v2/'
10
+
11
+ def initialize(token = Dadata.api_key, secret = Dadata.secret_key)
12
+ super(BASE_URL, token, secret)
13
+ end
14
+
15
+ # Get current balance
16
+ #
17
+ # @return [Numeric, nil] Current balance or nil if request failed
18
+ def balance
19
+ response = submit('profile/balance', {}, :get)
20
+ response&.fetch('balance', nil)
21
+ end
22
+
23
+ # Get daily statistics
24
+ #
25
+ # @param date [String, nil] Date to get statistics for (ISO 8601 format)
26
+ # @return [Hash, nil] Daily statistics or nil if request failed
27
+ def daily_stats(date = nil)
28
+ date = date.nil? ? Date.today : handle_date(date)
29
+ submit('stat/daily', { date: date.iso8601 }, :get)
30
+ end
31
+
32
+ # Get API versions
33
+ #
34
+ # @return [Hash, nil] Version information or nil if request failed
35
+ def versions
36
+ submit('version', {}, :get)
37
+ end
38
+
39
+ private
40
+
41
+ # Convert string to Date object
42
+ #
43
+ # @param date_string [String] Date string in any parseable format
44
+ # @return [Date] Parsed date or today's date if parsing fails
45
+ def handle_date(date_string)
46
+ Date.parse(date_string.to_s)
47
+ rescue ArgumentError, TypeError
48
+ Date.today
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module Dadata
6
+ # Client for data suggestions and lookups
7
+ class SuggestClient < ClientBase
8
+ BASE_URL = 'https://suggestions.dadata.ru/suggestions/api/4_1/rs/'
9
+
10
+ def initialize(token = Dadata.api_key, secret = Dadata.secret_key)
11
+ super(BASE_URL, token, secret)
12
+ end
13
+
14
+ # Find addresses by geographic coordinates
15
+ #
16
+ # @param name [String] Type of entity to search for (e.g., 'address', 'postal_unit')
17
+ # @param lat [Numeric] Latitude
18
+ # @param lon [Numeric] Longitude
19
+ # @param radius_meters [Integer] Search radius in meters (default: 100, max: 1000)
20
+ # @param kwargs [Hash] Additional parameters (e.g., language, count)
21
+ # @return [Array<Hash>, nil] List of suggestions or nil if not found
22
+ def geolocate(name, lat, lon, radius_meters = 100, **kwargs)
23
+ data = { lat:, lon:, radius_meters: }.merge(kwargs)
24
+ response = submit("geolocate/#{name}", data, :post)
25
+ response&.fetch('suggestions', nil)
26
+ end
27
+
28
+ # Get address by IP
29
+ #
30
+ # @param query [String] IP address
31
+ # @param kwargs [Hash] Additional parameters (e.g., language)
32
+ # @return [Hash, nil] Location data or nil if not found
33
+ def iplocate(query, **kwargs)
34
+ data = { ip: query }.merge(kwargs)
35
+ response = submit('iplocate/address', data, :get)
36
+ response&.fetch('location', nil)
37
+ end
38
+
39
+ # Get suggestions for partial input
40
+ #
41
+ # @param name [String] Type of entity to search for
42
+ # @param query [String] Search query
43
+ # @param count [Integer] Maximum number of results
44
+ # @param kwargs [Hash] Additional parameters (e.g., language, constraints)
45
+ # @return [Array<Hash>, nil] List of suggestions or nil if not found
46
+ def suggest(name, query, count = Dadata.suggestions_count, **kwargs)
47
+ data = { query:, count: }.merge(kwargs)
48
+ response = submit("suggest/#{name}", data, :post)
49
+ response&.fetch('suggestions', nil)
50
+ end
51
+
52
+ # Find entities by identifier
53
+ #
54
+ # @param name [String] Type of entity to search for
55
+ # @param query [String] Entity identifier
56
+ # @param count [Integer] Maximum number of results
57
+ # @param kwargs [Hash] Additional parameters (e.g., language)
58
+ # @return [Array<Hash>, nil] List of suggestions or nil if not found
59
+ def find_by_id(name, query, count = Dadata.suggestions_count, **kwargs)
60
+ data = { query:, count: }.merge(kwargs)
61
+ response = submit("findById/#{name}", data, :post)
62
+ response&.fetch('suggestions', nil)
63
+ end
64
+
65
+ # Find companies by email address
66
+ #
67
+ # @param query [String] Email address
68
+ # @return [Array<Hash>, nil] List of companies or nil if not found
69
+ def find_by_email(query)
70
+ response = submit('findByEmail/company', { query: }, :post)
71
+ response&.fetch('suggestions', nil)
72
+ end
73
+
74
+ # Find affiliated companies
75
+ #
76
+ # @param query [String] Company identifier
77
+ # @param count [Integer] Maximum number of results
78
+ # @param kwargs [Hash] Additional parameters (e.g., scope, type)
79
+ # @return [Array<Hash>, nil] List of affiliated companies or nil if not found
80
+ def find_affiliated(query, count = Dadata.suggestions_count, **kwargs)
81
+ data = { query:, count: }.merge(kwargs)
82
+ response = submit('findAffiliated/party', data, :post)
83
+ response&.fetch('suggestions', nil)
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dadata
4
+ # The SensitiveData module provides functionality for sanitizing sensitive information
5
+ # in log messages and HTTP headers. It is used internally by the gem to ensure that
6
+ # sensitive data such as API keys and secrets are not exposed in logs.
7
+ #
8
+ # @example
9
+ # class MyLogger
10
+ # include SensitiveData
11
+ #
12
+ # def log_request(headers)
13
+ # puts sanitize_headers(headers)
14
+ # end
15
+ # end
16
+ module SensitiveData
17
+ # List of headers that contain sensitive information and should be filtered
18
+ SENSITIVE_HEADERS = %w[Authorization X-Secret API-Key].freeze
19
+
20
+ # Sanitizes headers by replacing sensitive values with [FILTERED]
21
+ #
22
+ # @param headers [Hash, nil] Headers to sanitize
23
+ # @return [String] Sanitized headers string
24
+ # @example
25
+ # headers = { 'API-Key' => 'secret', 'Content-Type' => 'application/json' }
26
+ # sanitize_headers(headers) # => "API-Key: [FILTERED], Content-Type: application/json"
27
+ def sanitize_headers(headers)
28
+ return '' unless headers
29
+
30
+ headers.map do |key, value|
31
+ if SENSITIVE_HEADERS.include?(key)
32
+ "#{key}: [FILTERED]"
33
+ else
34
+ "#{key}: #{value}"
35
+ end
36
+ end.join(', ')
37
+ end
38
+
39
+ # Sanitizes a message by replacing sensitive information with [FILTERED]
40
+ #
41
+ # @param msg [String, nil] Message to sanitize
42
+ # @return [String] Sanitized message
43
+ # @example
44
+ # msg = "API-Key: secret123, Content-Type: application/json"
45
+ # sanitize_message(msg) # => "API-Key: [FILTERED], Content-Type: application/json"
46
+ def sanitize_message(msg)
47
+ return '' unless msg.is_a?(String)
48
+
49
+ result = msg.dup
50
+ SENSITIVE_HEADERS.each do |header|
51
+ # Escape hyphens in the header name and handle any whitespace around the colon
52
+ pattern = /#{Regexp.escape(header)}[\s]*:[\s]*[^\n,]+/
53
+ result = result.gsub(pattern, "#{header}: [FILTERED]")
54
+ end
55
+ result
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dadata
4
+ VERSION = '3.0.0'
5
+ end
data/lib/dadata-rb.rb ADDED
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Lets `require 'dadata-rb'` (the published gem name) load the library, whose
4
+ # entry point and `Dadata` module live in `dadata.rb`. This keeps Bundler's
5
+ # default auto-require working for consumers who add `gem 'dadata-rb'`.
6
+ require_relative 'dadata'