rospatent 1.1.0 → 1.3.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.
@@ -3,22 +3,46 @@
3
3
  # Rospatent API client configuration
4
4
  # Documentation: https://online.rospatent.gov.ru/open-data/open-api
5
5
  Rospatent.configure do |config|
6
- # API URL (default: https://searchplatform.rospatent.gov.ru)
7
- # config.api_url = ENV.fetch("ROSPATENT_API_URL", "https://searchplatform.rospatent.gov.ru")
8
-
9
6
  # JWT Bearer token for API authorization - REQUIRED
10
- # Obtain this from the Rospatent API administration
11
- config.token = Rails.application.credentials.rospatent_api_token || ENV.fetch(
12
- "ROSPATENT_API_TOKEN", nil
13
- )
7
+ # Priority: Rails credentials > Environment variable > Manual setting
8
+ config.token = Rails.application.credentials.rospatent_token ||
9
+ ENV["ROSPATENT_TOKEN"] ||
10
+ ENV.fetch("ROSPATENT_API_TOKEN", nil)
11
+
12
+ # Environment configuration - respect environment variables
13
+ config.environment = ENV.fetch("ROSPATENT_ENV", Rails.env)
14
+
15
+ # Cache configuration - respect environment variables or use Rails defaults
16
+ config.cache_enabled = if ENV.key?("ROSPATENT_CACHE_ENABLED")
17
+ ENV["ROSPATENT_CACHE_ENABLED"] == "true"
18
+ else
19
+ Rails.env.production?
20
+ end
14
21
 
15
- # Rails-specific environment integration
16
- config.environment = Rails.env
17
- config.cache_enabled = Rails.env.production?
18
- config.log_level = Rails.env.production? ? :warn : :debug
22
+ # Logging configuration - CRITICAL: Respect environment variables first!
23
+ config.log_level = if ENV.key?("ROSPATENT_LOG_LEVEL")
24
+ ENV["ROSPATENT_LOG_LEVEL"].to_sym
25
+ else
26
+ Rails.env.production? ? :warn : :debug
27
+ end
19
28
 
20
- # Optional: Override defaults if needed
21
- # config.timeout = 30
22
- # config.retry_count = 3
23
- # config.user_agent = "YourApp/1.0"
29
+ config.log_requests = if ENV.key?("ROSPATENT_LOG_REQUESTS")
30
+ ENV["ROSPATENT_LOG_REQUESTS"] == "true"
31
+ else
32
+ !Rails.env.production?
33
+ end
34
+
35
+ config.log_responses = if ENV.key?("ROSPATENT_LOG_RESPONSES")
36
+ ENV["ROSPATENT_LOG_RESPONSES"] == "true"
37
+ else
38
+ Rails.env.development?
39
+ end
40
+
41
+ # Optional: Override other defaults if needed
42
+ # config.api_url = ENV.fetch("ROSPATENT_API_URL", "https://searchplatform.rospatent.gov.ru")
43
+ # config.timeout = ENV.fetch("ROSPATENT_TIMEOUT", "30").to_i
44
+ # config.retry_count = ENV.fetch("ROSPATENT_RETRY_COUNT", "3").to_i
45
+ # config.cache_ttl = ENV.fetch("ROSPATENT_CACHE_TTL", "300").to_i
46
+ # config.cache_max_size = ENV.fetch("ROSPATENT_CACHE_MAX_SIZE", "1000").to_i
47
+ # config.connection_pool_size = ENV.fetch("ROSPATENT_POOL_SIZE", "5").to_i
24
48
  end
@@ -150,7 +150,7 @@ module Rospatent
150
150
  # @param text [String] The text to find similar patents to (minimum 50 words required)
151
151
  # @param count [Integer] Maximum number of results to return (default: 100)
152
152
  # @return [Hash] The similar search results
153
- # @raise [Rospatent::Errors::ValidationError] If text has insufficient words or other validation errors
153
+ # @raise [Rospatent::Errors::ValidationError] If text has insufficient words or errors
154
154
  def similar_patents_by_text(text, count: 100)
155
155
  # Validate inputs - text must have at least 50 words for the API
156
156
  validated_text = validate_text_with_word_count(text, "search_text", min_words: 50,
@@ -199,7 +199,7 @@ module Rospatent
199
199
  @logger.log_cache("miss", cache_key)
200
200
 
201
201
  # Make the API request
202
- result = get("/patsearch/v0.2/datasets/tree")
202
+ result = get("/patsearch/v0.2/datasets/tree", {})
203
203
 
204
204
  # Cache the result for longer since datasets don't change often
205
205
  @cache.set(cache_key, result, ttl: 3600) # Cache for 1 hour
@@ -230,19 +230,22 @@ module Rospatent
230
230
  # Format publication date
231
231
  formatted_date = validated_date.strftime("%Y/%m/%d")
232
232
 
233
+ # Format publication number with appropriate padding
234
+ formatted_number = format_publication_number(validated_number, validated_country)
235
+
233
236
  # Construct the path
234
237
  path = "/media/#{validated_collection}/#{validated_country}/" \
235
- "#{validated_doc_type}/#{formatted_date}/#{validated_number}/" \
238
+ "#{validated_doc_type}/#{formatted_date}/#{formatted_number}/" \
236
239
  "#{validated_filename}"
237
240
 
238
- # Make a GET request to retrieve the media file
239
- get(path)
241
+ # Get binary data
242
+ get(path, {}, binary: true)
240
243
  end
241
244
 
242
- # Simplified method to retrieve media data by patent ID and collection ID
243
- # @param document_id [String] The patent document ID (e.g., "RU134694U1_20131120")
244
- # @param collection_id [String] Dataset/collection identifier (e.g., "National")
245
- # @param filename [String] Media file name (e.g., "document.pdf")
245
+ # Retrieve media using simplified patent ID format
246
+ # @param document_id [String] Patent document ID (e.g., "RU134694U1_20131120")
247
+ # @param collection_id [String] Collection identifier (e.g., "National")
248
+ # @param filename [String] Filename to retrieve (e.g., "document.pdf")
246
249
  # @return [String] Binary content of the requested file
247
250
  # @raise [Rospatent::Errors::InvalidRequestError] If document_id format is invalid
248
251
  # or parameters are missing
@@ -258,9 +261,12 @@ module Rospatent
258
261
  # Format the date from YYYYMMDD to YYYY/MM/DD
259
262
  formatted_date = id_parts[:date].gsub(/^(\d{4})(\d{2})(\d{2})$/, '\1/\2/\3')
260
263
 
264
+ # Format publication number with appropriate padding
265
+ formatted_number = format_publication_number(id_parts[:number], id_parts[:country_code])
266
+
261
267
  # Call the base method with extracted components
262
268
  patent_media(validated_collection, id_parts[:country_code], id_parts[:doc_type],
263
- formatted_date, id_parts[:number], validated_filename)
269
+ formatted_date, formatted_number, validated_filename)
264
270
  end
265
271
 
266
272
  # Extract and parse the abstract content from a patent document
@@ -334,7 +340,7 @@ module Rospatent
334
340
  }
335
341
 
336
342
  # Make a POST request to the classification search endpoint
337
- result = post("/patsearch/v0.2/classification/#{validated_classifier}/search", payload)
343
+ result = post("/patsearch/v0.2/classification/#{validated_classifier}/search/", payload)
338
344
 
339
345
  # Cache the result
340
346
  @cache.set(cache_key, result, ttl: 1800) # Cache for 30 minutes
@@ -374,7 +380,7 @@ module Rospatent
374
380
  }
375
381
 
376
382
  # Make a POST request to the classification code endpoint
377
- result = post("/patsearch/v0.2/classification/#{validated_classifier}/code", payload)
383
+ result = post("/patsearch/v0.2/classification/#{validated_classifier}/code/", payload)
378
384
 
379
385
  # Cache the result for longer since classification codes don't change often
380
386
  @cache.set(cache_key, result, ttl: 3600) # Cache for 1 hour
@@ -386,8 +392,9 @@ module Rospatent
386
392
  # Execute a GET request to the API
387
393
  # @param endpoint [String] API endpoint
388
394
  # @param params [Hash] Query parameters (optional)
389
- # @return [Hash] Response data
390
- def get(endpoint, params = {})
395
+ # @param binary [Boolean] Whether to expect binary response (default: false)
396
+ # @return [Hash, String] Response data (Hash for JSON, String for binary)
397
+ def get(endpoint, params = {}, binary: false)
391
398
  start_time = Time.now
392
399
  request_id = generate_request_id
393
400
 
@@ -395,8 +402,12 @@ module Rospatent
395
402
  @request_count += 1
396
403
 
397
404
  response = connection.get(endpoint, params) do |req|
398
- req.headers["Accept"] = "application/json"
399
- req.headers["Content-Type"] = "application/json"
405
+ if binary
406
+ req.headers["Accept"] = "*/*"
407
+ else
408
+ req.headers["Accept"] = "application/json"
409
+ req.headers["Content-Type"] = "application/json"
410
+ end
400
411
  req.headers["X-Request-ID"] = request_id
401
412
  end
402
413
 
@@ -406,7 +417,11 @@ module Rospatent
406
417
  @logger.log_response("GET", endpoint, response.status, duration,
407
418
  response_size: response.body&.bytesize, request_id: request_id)
408
419
 
409
- handle_response(response, request_id)
420
+ if binary
421
+ handle_binary_response(response, request_id)
422
+ else
423
+ handle_response(response, request_id)
424
+ end
410
425
  rescue Faraday::Error => e
411
426
  @logger.log_error(e, { endpoint: endpoint, params: params, request_id: request_id })
412
427
  handle_error(e)
@@ -492,12 +507,13 @@ module Rospatent
492
507
  qn: { type: :string, max_length: 1000 },
493
508
  limit: { type: :positive_integer, min_value: 1, max_value: 100 },
494
509
  offset: { type: :positive_integer, min_value: 0, max_value: 10_000 },
495
- pre_tag: { type: :string, max_length: 50 },
496
- post_tag: { type: :string, max_length: 50 },
510
+ pre_tag: { type: :string_or_array, max_length: 50, max_size: 10 },
511
+ post_tag: { type: :string_or_array, max_length: 50, max_size: 10 },
497
512
  sort: { type: :enum, allowed_values: %i[relevance pub_date filing_date] },
498
- group_by: { type: :enum, allowed_values: [:patent_family] },
513
+ group_by: { type: :string_enum, allowed_values: %w[family:docdb family:dwpi] },
499
514
  include_facets: { type: :boolean },
500
- highlight: { type: :boolean },
515
+ highlight: { type: :hash },
516
+ filter: { type: :filter },
501
517
  datasets: { type: :array, max_size: 10 }
502
518
  }
503
519
 
@@ -617,7 +633,8 @@ module Rospatent
617
633
 
618
634
  error_msg = begin
619
635
  data = JSON.parse(response.body)
620
- data["error"] || data["message"] || "Unknown error"
636
+ # Try different possible error message fields used by Rospatent API
637
+ data["result"] || data["error"] || data["message"] || "Unknown error"
621
638
  rescue JSON::ParserError
622
639
  response.body
623
640
  end
@@ -642,6 +659,43 @@ module Rospatent
642
659
  end
643
660
  end
644
661
 
662
+ # Process binary API response (for media files)
663
+ # @param response [Faraday::Response] Raw response from the API
664
+ # @param request_id [String] Request ID for tracking
665
+ # @return [String] Binary response data
666
+ # @raise [Rospatent::Errors::ApiError] If the response is not successful
667
+ def handle_binary_response(response, request_id = nil)
668
+ return response.body if response.success?
669
+
670
+ # For binary endpoints, error responses might still be JSON
671
+ error_msg = begin
672
+ data = JSON.parse(response.body)
673
+ # Try different possible error message fields used by Rospatent API
674
+ data["result"] || data["error"] || data["message"] || "Unknown error"
675
+ rescue JSON::ParserError
676
+ "Binary request failed"
677
+ end
678
+
679
+ # Create specific error types based on status code
680
+ case response.status
681
+ when 401
682
+ raise Errors::AuthenticationError, "#{error_msg} [Request ID: #{request_id}]"
683
+ when 404
684
+ raise Errors::NotFoundError.new("#{error_msg} [Request ID: #{request_id}]", response.status)
685
+ when 422
686
+ errors = extract_validation_errors(response)
687
+ raise Errors::ValidationError.new(error_msg, errors)
688
+ when 429
689
+ retry_after = response.headers["Retry-After"]&.to_i
690
+ raise Errors::RateLimitError.new(error_msg, response.status, retry_after)
691
+ when 503
692
+ raise Errors::ServiceUnavailableError.new("#{error_msg} [Request ID: #{request_id}]",
693
+ response.status)
694
+ else
695
+ raise Errors::ApiError.new(error_msg, response.status, response.body, request_id)
696
+ end
697
+ end
698
+
645
699
  # Handle connection errors
646
700
  # @param error [Faraday::Error] Connection error
647
701
  # @raise [Rospatent::Errors::ConnectionError] Wrapped connection error
@@ -661,7 +715,8 @@ module Rospatent
661
715
  # @return [Hash] Field-specific validation errors
662
716
  def extract_validation_errors(response)
663
717
  data = JSON.parse(response.body)
664
- data["errors"] || data["validation_errors"] || {}
718
+ # Check various possible validation error fields
719
+ data["errors"] || data["validation_errors"] || data["details"] || {}
665
720
  rescue JSON::ParserError
666
721
  {}
667
722
  end
@@ -695,5 +750,18 @@ module Rospatent
695
750
  def generate_request_id
696
751
  "req_#{Time.now.to_f}_#{rand(10_000)}"
697
752
  end
753
+
754
+ # Pad publication number with leading zeros for specific countries
755
+ # @param number [String] Publication number to pad
756
+ # @param country_code [String] Country code (e.g., "RU")
757
+ # @return [String] Padded publication number
758
+ def format_publication_number(number, country_code)
759
+ # Russian patents require 10-digit publication numbers
760
+ if country_code == "RU" && number.length < 10
761
+ number.rjust(10, "0")
762
+ else
763
+ number
764
+ end
765
+ end
698
766
  end
699
767
  end
@@ -31,7 +31,7 @@ module Rospatent
31
31
  # Initialize a new configuration with default values
32
32
  def initialize
33
33
  @api_url = "https://searchplatform.rospatent.gov.ru"
34
- @token = nil
34
+ @token = ENV["ROSPATENT_TOKEN"] || ENV.fetch("ROSPATENT_API_TOKEN", nil)
35
35
  @timeout = 30
36
36
  @retry_count = 3
37
37
  @user_agent = "Rospatent Ruby Client/#{Rospatent::VERSION}"
@@ -106,6 +106,7 @@ module Rospatent
106
106
  private
107
107
 
108
108
  # Load environment-specific configuration
109
+ # Only override values that weren't explicitly set by environment variables
109
110
  def load_environment_config
110
111
  unless valid_environment?
111
112
  raise ArgumentError, "Invalid environment: #{@environment}. " \
@@ -116,20 +117,20 @@ module Rospatent
116
117
  when "production"
117
118
  @timeout = 60
118
119
  @retry_count = 5
119
- @log_level = :warn
120
- @cache_ttl = 600 # 10 minutes in production
120
+ @log_level = :warn unless ENV.key?("ROSPATENT_LOG_LEVEL")
121
+ @cache_ttl = 600 unless ENV.key?("ROSPATENT_CACHE_TTL") # 10 minutes in production
121
122
  when "staging"
122
123
  @timeout = 45
123
124
  @retry_count = 3
124
- @log_level = :info
125
- @cache_ttl = 300 # 5 minutes in staging
125
+ @log_level = :info unless ENV.key?("ROSPATENT_LOG_LEVEL")
126
+ @cache_ttl = 300 unless ENV.key?("ROSPATENT_CACHE_TTL") # 5 minutes in staging
126
127
  when "development"
127
128
  @timeout = 10
128
129
  @retry_count = 1
129
- @log_level = :debug
130
- @log_requests = true
131
- @log_responses = true
132
- @cache_ttl = 60 # 1 minute in development
130
+ @log_level = :debug unless ENV.key?("ROSPATENT_LOG_LEVEL")
131
+ @log_requests = true unless ENV.key?("ROSPATENT_LOG_REQUESTS")
132
+ @log_responses = true unless ENV.key?("ROSPATENT_LOG_RESPONSES")
133
+ @cache_ttl = 60 unless ENV.key?("ROSPATENT_CACHE_TTL") # 1 minute in development
133
134
  end
134
135
  end
135
136
  end
@@ -170,6 +170,26 @@ module Rospatent
170
170
  value
171
171
  end
172
172
 
173
+ # Validate string enum value (preserves string type)
174
+ # @param value [String, nil] Value to validate
175
+ # @param allowed_values [Array<String>] Array of allowed string values
176
+ # @param field_name [String] Name of the field for error messages
177
+ # @return [String] Validated string
178
+ # @raise [ValidationError] If value is not in allowed list
179
+ def validate_string_enum(value, allowed_values, field_name)
180
+ return nil if value.nil?
181
+
182
+ # Ensure value is a string
183
+ value = value.to_s if value.respond_to?(:to_s)
184
+
185
+ unless allowed_values.include?(value)
186
+ raise Errors::ValidationError,
187
+ "Invalid #{field_name}. Allowed values: #{allowed_values.join(', ')}"
188
+ end
189
+
190
+ value
191
+ end
192
+
173
193
  # Validate array parameter
174
194
  # @param value [Array, nil] Array to validate
175
195
  # @param field_name [String] Name of the field for error messages
@@ -207,6 +227,29 @@ module Rospatent
207
227
  value
208
228
  end
209
229
 
230
+ # Validate string or array parameter (for highlight tags)
231
+ # @param value [String, Array, nil] String or Array to validate
232
+ # @param field_name [String] Name of the field for error messages
233
+ # @param max_length [Integer, nil] Maximum string length (for string values)
234
+ # @param max_size [Integer, nil] Maximum array size (for array values)
235
+ # @return [String, Array] Validated string or array
236
+ # @raise [ValidationError] If value is invalid
237
+ def validate_string_or_array(value, field_name, max_length: nil, max_size: nil)
238
+ return nil if value.nil?
239
+
240
+ case value
241
+ when String
242
+ validate_string(value, field_name, max_length: max_length)
243
+ when Array
244
+ validate_array(value, field_name, max_size: max_size) do |element|
245
+ validate_string(element, "#{field_name} element", max_length: max_length)
246
+ end
247
+ else
248
+ raise Errors::ValidationError,
249
+ "Invalid #{field_name} type. Expected String or Array, got #{value.class}"
250
+ end
251
+ end
252
+
210
253
  # Validate hash parameter
211
254
  # @param value [Hash, nil] Hash to validate
212
255
  # @param field_name [String] Name of the field for error messages
@@ -296,8 +339,17 @@ module Rospatent
296
339
  param_name.to_s,
297
340
  max_length: rules[:max_length]
298
341
  )
342
+ when :string_or_array
343
+ validate_string_or_array(
344
+ value,
345
+ param_name.to_s,
346
+ max_length: rules[:max_length],
347
+ max_size: rules[:max_size]
348
+ )
299
349
  when :enum
300
350
  validate_enum(value, rules[:allowed_values], param_name.to_s)
351
+ when :string_enum
352
+ validate_string_enum(value, rules[:allowed_values], param_name.to_s)
301
353
  when :date
302
354
  validate_date(value, param_name.to_s)
303
355
  when :array
@@ -314,6 +366,11 @@ module Rospatent
314
366
  required_keys: rules[:required_keys] || [],
315
367
  allowed_keys: rules[:allowed_keys]
316
368
  )
369
+ when :filter
370
+ validate_filter(value, param_name.to_s)
371
+ when :boolean
372
+ # Convert to boolean, nil values remain nil
373
+ value.nil? ? nil : !!value
317
374
  else
318
375
  value
319
376
  end
@@ -334,5 +391,183 @@ module Rospatent
334
391
  def count_words(text)
335
392
  text.split.size
336
393
  end
394
+
395
+ # Validate filter parameter according to Rospatent API specification
396
+ # @param filter [Hash, nil] Filter hash to validate
397
+ # @param field_name [String] Name of the field for error messages
398
+ # @return [Hash] Validated filter hash
399
+ # @raise [ValidationError] If filter structure is invalid
400
+ def validate_filter(filter, field_name = "filter")
401
+ return nil if filter.nil?
402
+
403
+ unless filter.is_a?(Hash)
404
+ raise Errors::ValidationError,
405
+ "Invalid #{field_name} type. Expected Hash, got #{filter.class}"
406
+ end
407
+
408
+ validated_filter = {}
409
+
410
+ filter.each do |filter_field, filter_value|
411
+ case filter_field.to_s
412
+ when "authors", "patent_holders", "country", "kind", "ids",
413
+ "classification.ipc", "classification.ipc_group", "classification.ipc_subclass",
414
+ "classification.cpc", "classification.cpc_group", "classification.cpc_subclass"
415
+ # These fields use {"values": [...]} format
416
+ validated_filter[filter_field] = validate_filter_values(filter_value, filter_field)
417
+ when "date_published", "application.filing_date"
418
+ # These fields use {"range": {"gt": "20000101"}} format
419
+ validated_filter[filter_field] = validate_filter_range(filter_value, filter_field)
420
+ else
421
+ raise Errors::ValidationError,
422
+ "Invalid filter field '#{filter_field}'. Allowed fields: authors, patent_holders, " \
423
+ "country, kind, ids, date_published, application.filing_date, classification.ipc, " \
424
+ "classification.ipc_group, classification.ipc_subclass, classification.cpc, " \
425
+ "classification.cpc_group, classification.cpc_subclass"
426
+ end
427
+ end
428
+
429
+ validated_filter
430
+ end
431
+
432
+ # Validate filter values structure (for list-based filters)
433
+ # @param filter_value [Hash] Filter value to validate
434
+ # @param filter_field [String] Filter field name for error messages
435
+ # @return [Hash] Validated filter value
436
+ # @raise [ValidationError] If structure is invalid
437
+ def validate_filter_values(filter_value, filter_field)
438
+ unless filter_value.is_a?(Hash)
439
+ raise Errors::ValidationError,
440
+ "Invalid #{filter_field} filter structure. Expected Hash with 'values' key, got #{filter_value.class}"
441
+ end
442
+
443
+ unless filter_value.key?("values") || filter_value.key?(:values)
444
+ raise Errors::ValidationError,
445
+ "Missing required 'values' key in #{filter_field} filter. Expected format: {\"values\": [...]}"
446
+ end
447
+
448
+ values = filter_value["values"] || filter_value[:values]
449
+
450
+ unless values.is_a?(Array)
451
+ raise Errors::ValidationError,
452
+ "Invalid 'values' type in #{filter_field} filter. Expected Array, got #{values.class}"
453
+ end
454
+
455
+ if values.empty?
456
+ raise Errors::ValidationError,
457
+ "Empty 'values' array in #{filter_field} filter. At least one value must be provided"
458
+ end
459
+
460
+ # Validate each value is a string
461
+ values.each_with_index do |value, index|
462
+ unless value.is_a?(String) || value.is_a?(Symbol)
463
+ raise Errors::ValidationError,
464
+ "Invalid value type at index #{index} in #{filter_field} filter. Expected String, got #{value.class}"
465
+ end
466
+ end
467
+
468
+ { "values" => values.map(&:to_s) }
469
+ end
470
+
471
+ # Validate filter range structure (for date-based filters)
472
+ # @param filter_value [Hash] Filter value to validate
473
+ # @param filter_field [String] Filter field name for error messages
474
+ # @return [Hash] Validated filter value
475
+ # @raise [ValidationError] If structure is invalid
476
+ def validate_filter_range(filter_value, filter_field)
477
+ unless filter_value.is_a?(Hash)
478
+ raise Errors::ValidationError,
479
+ "Invalid #{filter_field} filter structure. Expected Hash with 'range' key, got #{filter_value.class}"
480
+ end
481
+
482
+ unless filter_value.key?("range") || filter_value.key?(:range)
483
+ raise Errors::ValidationError,
484
+ "Missing required 'range' key in #{filter_field} filter. Expected format: {\"range\": {\"gt\": \"20000101\"}}"
485
+ end
486
+
487
+ range = filter_value["range"] || filter_value[:range]
488
+
489
+ unless range.is_a?(Hash)
490
+ raise Errors::ValidationError,
491
+ "Invalid 'range' type in #{filter_field} filter. Expected Hash, got #{range.class}"
492
+ end
493
+
494
+ # Allowed range operators
495
+ allowed_operators = %w[gt gte lt lte]
496
+ validated_range = {}
497
+
498
+ if range.empty?
499
+ raise Errors::ValidationError,
500
+ "Empty 'range' object in #{filter_field} filter. At least one operator (gt, gte, lt, lte) must be provided"
501
+ end
502
+
503
+ range.each do |operator, value|
504
+ operator_str = operator.to_s
505
+
506
+ unless allowed_operators.include?(operator_str)
507
+ raise Errors::ValidationError,
508
+ "Invalid range operator '#{operator_str}' in #{filter_field} filter. " \
509
+ "Allowed operators: #{allowed_operators.join(', ')}"
510
+ end
511
+
512
+ # Validate date format (YYYYMMDD)
513
+ validated_date = validate_filter_date(value, filter_field, operator_str)
514
+ validated_range[operator_str] = validated_date
515
+ end
516
+
517
+ { "range" => validated_range }
518
+ end
519
+
520
+ # Validate date format for filter ranges
521
+ # @param date_value [String, Date] Date value to validate
522
+ # @param filter_field [String] Filter field name for error messages
523
+ # @param operator [String] Range operator for error messages
524
+ # @return [String] Validated date in YYYYMMDD format
525
+ # @raise [ValidationError] If date format is invalid
526
+ def validate_filter_date(date_value, filter_field, operator)
527
+ # Convert Date objects to string
528
+ return date_value.strftime("%Y%m%d") if date_value.is_a?(Date)
529
+
530
+ unless date_value.is_a?(String)
531
+ raise Errors::ValidationError,
532
+ "Invalid date type for '#{operator}' in #{filter_field} filter. Expected String or Date, got #{date_value.class}"
533
+ end
534
+
535
+ # Check if it's already in YYYYMMDD format
536
+ if date_value.match?(/^\d{8}$/)
537
+ # Validate that it's a real date
538
+ begin
539
+ year = date_value[0..3].to_i
540
+ month = date_value[4..5].to_i
541
+ day = date_value[6..7].to_i
542
+ Date.new(year, month, day)
543
+ return date_value
544
+ rescue ArgumentError
545
+ raise Errors::ValidationError,
546
+ "Invalid date '#{date_value}' for '#{operator}' in #{filter_field} filter. Not a valid date"
547
+ end
548
+ end
549
+
550
+ # Try to parse various date formats and convert to YYYYMMDD
551
+ begin
552
+ parsed_date = case date_value
553
+ when /^\d{4}-\d{2}-\d{2}$/ # YYYY-MM-DD
554
+ Date.parse(date_value)
555
+ when %r{^\d{4}/\d{2}/\d{2}$} # YYYY/MM/DD
556
+ Date.parse(date_value)
557
+ when %r{^\d{2}/\d{2}/\d{4}$} # MM/DD/YYYY
558
+ Date.strptime(date_value, "%m/%d/%Y")
559
+ when /^\d{2}-\d{2}-\d{4}$/ # MM-DD-YYYY
560
+ Date.strptime(date_value, "%m-%d-%Y")
561
+ else
562
+ Date.parse(date_value) # Let Date.parse try to handle it
563
+ end
564
+
565
+ parsed_date.strftime("%Y%m%d")
566
+ rescue ArgumentError
567
+ raise Errors::ValidationError,
568
+ "Invalid date format '#{date_value}' for '#{operator}' in #{filter_field} filter. " \
569
+ "Expected YYYYMMDD format (e.g., '20200101') or standard date formats (YYYY-MM-DD, etc.)"
570
+ end
571
+ end
337
572
  end
338
573
  end