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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +58 -0
- data/README.md +1169 -241
- data/lib/generators/rospatent/install/templates/initializer.rb +39 -15
- data/lib/rospatent/client.rb +91 -23
- data/lib/rospatent/configuration.rb +10 -9
- data/lib/rospatent/input_validator.rb +235 -0
- data/lib/rospatent/search.rb +27 -21
- data/lib/rospatent/version.rb +1 -1
- metadata +10 -10
@@ -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
|
-
#
|
11
|
-
config.token = Rails.application.credentials.
|
12
|
-
|
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
|
-
#
|
16
|
-
config.
|
17
|
-
|
18
|
-
|
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
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
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
|
data/lib/rospatent/client.rb
CHANGED
@@ -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
|
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}/#{
|
238
|
+
"#{validated_doc_type}/#{formatted_date}/#{formatted_number}/" \
|
236
239
|
"#{validated_filename}"
|
237
240
|
|
238
|
-
#
|
239
|
-
get(path)
|
241
|
+
# Get binary data
|
242
|
+
get(path, {}, binary: true)
|
240
243
|
end
|
241
244
|
|
242
|
-
#
|
243
|
-
# @param document_id [String]
|
244
|
-
# @param collection_id [String]
|
245
|
-
# @param filename [String]
|
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,
|
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
|
-
# @
|
390
|
-
|
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
|
-
|
399
|
-
|
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
|
-
|
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: :
|
496
|
-
post_tag: { type: :
|
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: :
|
513
|
+
group_by: { type: :string_enum, allowed_values: %w[family:docdb family:dwpi] },
|
499
514
|
include_facets: { type: :boolean },
|
500
|
-
highlight: { type: :
|
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
|
-
|
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
|
-
|
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
|