rospatent 1.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +16 -0
- data/LICENSE.txt +21 -0
- data/README.md +1247 -0
- data/lib/generators/rospatent/install/install_generator.rb +21 -0
- data/lib/generators/rospatent/install/templates/README +29 -0
- data/lib/generators/rospatent/install/templates/initializer.rb +24 -0
- data/lib/rospatent/cache.rb +282 -0
- data/lib/rospatent/client.rb +698 -0
- data/lib/rospatent/configuration.rb +136 -0
- data/lib/rospatent/errors.rb +127 -0
- data/lib/rospatent/input_validator.rb +306 -0
- data/lib/rospatent/logger.rb +286 -0
- data/lib/rospatent/patent_parser.rb +141 -0
- data/lib/rospatent/railtie.rb +26 -0
- data/lib/rospatent/search.rb +222 -0
- data/lib/rospatent/version.rb +5 -0
- data/lib/rospatent.rb +117 -0
- metadata +167 -0
@@ -0,0 +1,286 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "logger"
|
4
|
+
require "json"
|
5
|
+
require "time"
|
6
|
+
|
7
|
+
module Rospatent
|
8
|
+
# Structured logger for API requests, responses, and events
|
9
|
+
class Logger
|
10
|
+
LEVELS = {
|
11
|
+
debug: ::Logger::DEBUG,
|
12
|
+
info: ::Logger::INFO,
|
13
|
+
warn: ::Logger::WARN,
|
14
|
+
error: ::Logger::ERROR,
|
15
|
+
fatal: ::Logger::FATAL
|
16
|
+
}.freeze
|
17
|
+
|
18
|
+
attr_reader :logger, :level
|
19
|
+
|
20
|
+
# Initialize a new logger
|
21
|
+
# @param output [IO, String] Output destination (STDOUT, file path, etc.)
|
22
|
+
# @param level [Symbol] Log level (:debug, :info, :warn, :error, :fatal)
|
23
|
+
# @param formatter [Symbol] Log format (:json, :text)
|
24
|
+
def initialize(output: $stdout, level: :info, formatter: :text)
|
25
|
+
@logger = ::Logger.new(output)
|
26
|
+
@level = level
|
27
|
+
@logger.level = LEVELS[level] || ::Logger::INFO
|
28
|
+
|
29
|
+
@logger.formatter = case formatter
|
30
|
+
when :json
|
31
|
+
method(:json_formatter)
|
32
|
+
else
|
33
|
+
method(:text_formatter)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
# Log an API request
|
38
|
+
# @param method [String] HTTP method
|
39
|
+
# @param endpoint [String] API endpoint
|
40
|
+
# @param params [Hash] Request parameters
|
41
|
+
# @param headers [Hash] Request headers (optional)
|
42
|
+
def log_request(method, endpoint, params = {}, headers = {})
|
43
|
+
return unless should_log?(:info)
|
44
|
+
|
45
|
+
safe_params = sanitize_params(params)
|
46
|
+
safe_headers = sanitize_headers(headers)
|
47
|
+
|
48
|
+
log_structured(:info, "API Request", {
|
49
|
+
http_method: method.upcase,
|
50
|
+
endpoint: endpoint,
|
51
|
+
params: safe_params,
|
52
|
+
headers: safe_headers,
|
53
|
+
timestamp: Time.now.iso8601,
|
54
|
+
request_id: generate_request_id
|
55
|
+
})
|
56
|
+
end
|
57
|
+
|
58
|
+
# Log an API response
|
59
|
+
# @param method [String] HTTP method
|
60
|
+
# @param endpoint [String] API endpoint
|
61
|
+
# @param status [Integer] Response status code
|
62
|
+
# @param duration [Float] Request duration in seconds
|
63
|
+
# @param response_size [Integer] Response body size (optional)
|
64
|
+
# @param request_id [String] Request ID for correlation
|
65
|
+
def log_response(method, endpoint, status, duration, response_size: nil, request_id: nil)
|
66
|
+
return unless should_log?(:info)
|
67
|
+
|
68
|
+
level = status >= 400 ? :warn : :info
|
69
|
+
|
70
|
+
log_structured(level, "API Response", {
|
71
|
+
http_method: method.upcase,
|
72
|
+
endpoint: endpoint,
|
73
|
+
status_code: status,
|
74
|
+
duration_ms: (duration * 1000).round(2),
|
75
|
+
response_size_bytes: response_size,
|
76
|
+
timestamp: Time.now.iso8601,
|
77
|
+
request_id: request_id
|
78
|
+
})
|
79
|
+
end
|
80
|
+
|
81
|
+
# Log an error with context
|
82
|
+
# @param error [Exception] The error object
|
83
|
+
# @param context [Hash] Additional context information
|
84
|
+
def log_error(error, context = {})
|
85
|
+
log_structured(:error, "Error occurred", {
|
86
|
+
error_class: error.class.name,
|
87
|
+
error_message: error.message,
|
88
|
+
error_backtrace: error.backtrace&.first(10),
|
89
|
+
context: context,
|
90
|
+
timestamp: Time.now.iso8601
|
91
|
+
})
|
92
|
+
end
|
93
|
+
|
94
|
+
# Log cache operations
|
95
|
+
# @param operation [String] Cache operation (hit, miss, set, delete)
|
96
|
+
# @param key [String] Cache key
|
97
|
+
# @param ttl [Integer] Time to live (for set operations)
|
98
|
+
def log_cache(operation, key, ttl: nil)
|
99
|
+
return unless should_log?(:debug)
|
100
|
+
|
101
|
+
log_structured(:debug, "Cache operation", {
|
102
|
+
operation: operation,
|
103
|
+
cache_key: key,
|
104
|
+
ttl_seconds: ttl,
|
105
|
+
timestamp: Time.now.iso8601
|
106
|
+
})
|
107
|
+
end
|
108
|
+
|
109
|
+
# Log performance metrics
|
110
|
+
# @param operation [String] Operation name
|
111
|
+
# @param duration [Float] Duration in seconds
|
112
|
+
# @param metadata [Hash] Additional metadata
|
113
|
+
def log_performance(operation, duration, metadata = {})
|
114
|
+
return unless should_log?(:info)
|
115
|
+
|
116
|
+
log_structured(:info, "Performance metric", {
|
117
|
+
operation: operation,
|
118
|
+
duration_ms: (duration * 1000).round(2),
|
119
|
+
metadata: metadata,
|
120
|
+
timestamp: Time.now.iso8601
|
121
|
+
})
|
122
|
+
end
|
123
|
+
|
124
|
+
# Log debug information
|
125
|
+
# @param message [String] Debug message
|
126
|
+
# @param data [Hash] Additional debug data
|
127
|
+
def debug(message, data = {})
|
128
|
+
log_structured(:debug, message, data)
|
129
|
+
end
|
130
|
+
|
131
|
+
# Log info message
|
132
|
+
# @param message [String] Info message
|
133
|
+
# @param data [Hash] Additional data
|
134
|
+
def info(message, data = {})
|
135
|
+
log_structured(:info, message, data)
|
136
|
+
end
|
137
|
+
|
138
|
+
# Log warning
|
139
|
+
# @param message [String] Warning message
|
140
|
+
# @param data [Hash] Additional data
|
141
|
+
def warn(message, data = {})
|
142
|
+
log_structured(:warn, message, data)
|
143
|
+
end
|
144
|
+
|
145
|
+
# Log error message
|
146
|
+
# @param message [String] Error message
|
147
|
+
# @param data [Hash] Additional data
|
148
|
+
def error(message, data = {})
|
149
|
+
log_structured(:error, message, data)
|
150
|
+
end
|
151
|
+
|
152
|
+
# Log fatal error
|
153
|
+
# @param message [String] Fatal error message
|
154
|
+
# @param data [Hash] Additional data
|
155
|
+
def fatal(message, data = {})
|
156
|
+
log_structured(:fatal, message, data)
|
157
|
+
end
|
158
|
+
|
159
|
+
private
|
160
|
+
|
161
|
+
# Check if we should log at the given level
|
162
|
+
# @param level [Symbol] Log level to check
|
163
|
+
# @return [Boolean] true if should log
|
164
|
+
def should_log?(level)
|
165
|
+
LEVELS[level] >= @logger.level
|
166
|
+
end
|
167
|
+
|
168
|
+
# Log structured data
|
169
|
+
# @param level [Symbol] Log level
|
170
|
+
# @param message [String] Log message
|
171
|
+
# @param data [Hash] Structured data
|
172
|
+
def log_structured(level, message, data = {})
|
173
|
+
return unless should_log?(level)
|
174
|
+
|
175
|
+
log_data = {
|
176
|
+
message: message,
|
177
|
+
level: level.to_s.upcase,
|
178
|
+
gem: "rospatent",
|
179
|
+
version: Rospatent::VERSION
|
180
|
+
}.merge(data)
|
181
|
+
|
182
|
+
@logger.send(level, log_data)
|
183
|
+
end
|
184
|
+
|
185
|
+
# Sanitize request parameters to remove sensitive data
|
186
|
+
# @param params [Hash] Request parameters
|
187
|
+
# @return [Hash] Sanitized parameters
|
188
|
+
def sanitize_params(params)
|
189
|
+
return {} unless params.is_a?(Hash)
|
190
|
+
|
191
|
+
sanitized = params.dup
|
192
|
+
sensitive_keys = %w[token password secret key auth authorization]
|
193
|
+
|
194
|
+
sensitive_keys.each do |key|
|
195
|
+
sanitized.each do |param_key, _value|
|
196
|
+
sanitized[param_key] = "[REDACTED]" if param_key.to_s.downcase.include?(key.downcase)
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
sanitized
|
201
|
+
end
|
202
|
+
|
203
|
+
# Sanitize headers to remove sensitive data
|
204
|
+
# @param headers [Hash] Request headers
|
205
|
+
# @return [Hash] Sanitized headers
|
206
|
+
def sanitize_headers(headers)
|
207
|
+
return {} unless headers.is_a?(Hash)
|
208
|
+
|
209
|
+
sanitized = headers.dup
|
210
|
+
sensitive_headers = %w[authorization x-api-key x-auth-token bearer]
|
211
|
+
|
212
|
+
sensitive_headers.each do |header|
|
213
|
+
sanitized.each do |header_key, _value|
|
214
|
+
sanitized[header_key] = "[REDACTED]" if header_key.to_s.downcase.include?(header.downcase)
|
215
|
+
end
|
216
|
+
end
|
217
|
+
|
218
|
+
sanitized
|
219
|
+
end
|
220
|
+
|
221
|
+
# Generate a unique request ID
|
222
|
+
# @return [String] Unique request identifier
|
223
|
+
def generate_request_id
|
224
|
+
"req_#{Time.now.to_f}_#{rand(10_000)}"
|
225
|
+
end
|
226
|
+
|
227
|
+
# JSON formatter for structured logging
|
228
|
+
# @param severity [String] Log severity
|
229
|
+
# @param datetime [Time] Log timestamp
|
230
|
+
# @param progname [String] Program name
|
231
|
+
# @param msg [Object] Log message/data
|
232
|
+
# @return [String] Formatted log entry
|
233
|
+
def json_formatter(severity, datetime, progname, msg)
|
234
|
+
log_entry = if msg.is_a?(Hash)
|
235
|
+
msg.merge(
|
236
|
+
severity: severity,
|
237
|
+
datetime: datetime.iso8601,
|
238
|
+
progname: progname
|
239
|
+
)
|
240
|
+
else
|
241
|
+
{
|
242
|
+
severity: severity,
|
243
|
+
datetime: datetime.iso8601,
|
244
|
+
progname: progname,
|
245
|
+
message: msg.to_s
|
246
|
+
}
|
247
|
+
end
|
248
|
+
|
249
|
+
"#{JSON.generate(log_entry)}\n"
|
250
|
+
end
|
251
|
+
|
252
|
+
# Text formatter for human-readable logging
|
253
|
+
# @param severity [String] Log severity
|
254
|
+
# @param datetime [Time] Log timestamp
|
255
|
+
# @param progname [String] Program name
|
256
|
+
# @param msg [Object] Log message/data
|
257
|
+
# @return [String] Formatted log entry
|
258
|
+
def text_formatter(severity, datetime, progname, msg)
|
259
|
+
timestamp = datetime.strftime("%Y-%m-%d %H:%M:%S")
|
260
|
+
|
261
|
+
if msg.is_a?(Hash)
|
262
|
+
message = msg[:message] || "Structured log"
|
263
|
+
details = msg.except(:message).map { |k, v| "#{k}=#{v}" }.join(" ")
|
264
|
+
formatted_msg = details.empty? ? message : "#{message} (#{details})"
|
265
|
+
else
|
266
|
+
formatted_msg = msg.to_s
|
267
|
+
end
|
268
|
+
|
269
|
+
"[#{timestamp}] #{severity} -- #{progname}: #{formatted_msg}\n"
|
270
|
+
end
|
271
|
+
end
|
272
|
+
|
273
|
+
# Null logger implementation for when logging is disabled
|
274
|
+
class NullLogger
|
275
|
+
def log_request(*args); end
|
276
|
+
def log_response(*args); end
|
277
|
+
def log_error(*args); end
|
278
|
+
def log_cache(*args); end
|
279
|
+
def log_performance(*args); end
|
280
|
+
def debug(*args); end
|
281
|
+
def info(*args); end
|
282
|
+
def warn(*args); end
|
283
|
+
def error(*args); end
|
284
|
+
def fatal(*args); end
|
285
|
+
end
|
286
|
+
end
|
@@ -0,0 +1,141 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Rospatent
|
4
|
+
# Module for parsing patent documents' XML content into structured formats
|
5
|
+
module PatentParser
|
6
|
+
# Extract and parse the abstract content from a patent document
|
7
|
+
# @param patent_data [Hash] The patent document data returned by Client#patent method
|
8
|
+
# @param format [Symbol] The desired output format (:text or :html)
|
9
|
+
# @param language [String] The language code (e.g., "ru", "en")
|
10
|
+
# @return [String, nil] The parsed abstract content in the requested format or nil if not found
|
11
|
+
# @example Get plain text abstract
|
12
|
+
# abstract = PatentParser.parse_abstract(patent_doc)
|
13
|
+
# @example Get HTML abstract in English
|
14
|
+
# abstract_html = PatentParser.parse_abstract(patent_doc, format: :html, language: "en")
|
15
|
+
def self.parse_abstract(patent_data, format: :text, language: "ru")
|
16
|
+
return nil unless patent_data && patent_data["abstract"] && patent_data["abstract"][language]
|
17
|
+
|
18
|
+
abstract_xml = patent_data["abstract"][language]
|
19
|
+
|
20
|
+
case format
|
21
|
+
when :html
|
22
|
+
# Extract the inner HTML content
|
23
|
+
extract_inner_html(abstract_xml)
|
24
|
+
else
|
25
|
+
# Extract plain text
|
26
|
+
extract_text_content(abstract_xml)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
# Extract and parse the description content from a patent document
|
31
|
+
# @param patent_data [Hash] The patent document data returned by Client#patent method
|
32
|
+
# @param format [Symbol] The desired output format (:text, :html, or :sections)
|
33
|
+
# @param language [String] The language code (e.g., "ru", "en")
|
34
|
+
# @return [String, Array, nil] The parsed description content in the requested format or nil if not found
|
35
|
+
# @example Get plain text description
|
36
|
+
# description = PatentParser.parse_description(patent_doc)
|
37
|
+
# @example Get HTML description
|
38
|
+
# description_html = PatentParser.parse_description(patent_doc, format: :html)
|
39
|
+
# @example Get description split into sections
|
40
|
+
# sections = PatentParser.parse_description(patent_doc, format: :sections)
|
41
|
+
def self.parse_description(patent_data, format: :text, language: "ru")
|
42
|
+
unless patent_data && patent_data["description"] && patent_data["description"][language]
|
43
|
+
return nil
|
44
|
+
end
|
45
|
+
|
46
|
+
description_xml = patent_data["description"][language]
|
47
|
+
|
48
|
+
case format
|
49
|
+
when :html
|
50
|
+
# Extract the inner HTML content
|
51
|
+
extract_inner_html(description_xml)
|
52
|
+
when :sections
|
53
|
+
# Split the description into numbered sections
|
54
|
+
extract_description_sections(description_xml)
|
55
|
+
else
|
56
|
+
# Extract plain text
|
57
|
+
extract_text_content(description_xml)
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
class << self
|
62
|
+
private
|
63
|
+
|
64
|
+
# Extract plain text content from XML
|
65
|
+
# @param xml_content [String] XML content
|
66
|
+
# @return [String] Plain text content
|
67
|
+
def extract_text_content(xml_content)
|
68
|
+
# Remove XML tags and normalize whitespace
|
69
|
+
text = xml_content.gsub(/<[^>]*>/, " ")
|
70
|
+
.gsub(/\s+/, " ")
|
71
|
+
.strip
|
72
|
+
|
73
|
+
# Decode HTML entities
|
74
|
+
decode_html_entities(text)
|
75
|
+
end
|
76
|
+
|
77
|
+
# Extract inner HTML content from XML
|
78
|
+
# @param xml_content [String] XML content
|
79
|
+
# @return [String] HTML content
|
80
|
+
def extract_inner_html(xml_content)
|
81
|
+
# Find content inside the main div tags
|
82
|
+
if xml_content =~ %r{<div[^>]*>(.*)</div>}m
|
83
|
+
::Regexp.last_match(1).strip
|
84
|
+
else
|
85
|
+
# Fallback: return everything between the root element tags
|
86
|
+
xml_content.sub(/^<[^>]*>/, "").sub(%r{</[^>]*>$}, "")
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
# Extract sections from description XML
|
91
|
+
# @param xml_content [String] XML description content
|
92
|
+
# @return [Array<Hash>] Array of section hashes with number and content
|
93
|
+
def extract_description_sections(xml_content)
|
94
|
+
sections = []
|
95
|
+
|
96
|
+
# Extract each div block which represents a section
|
97
|
+
xml_content.scan(%r{<div[^>]*>.*?</div>}m) do |div|
|
98
|
+
# Extract section number from the span tag
|
99
|
+
section_number = if div =~ %r{<span[^>]*>\[(\d+)\]</span>}
|
100
|
+
::Regexp.last_match(1).to_i
|
101
|
+
else
|
102
|
+
sections.size + 1
|
103
|
+
end
|
104
|
+
|
105
|
+
# Extract paragraph content
|
106
|
+
content = if div =~ %r{<p[^>]*>(.*?)</p>}m
|
107
|
+
::Regexp.last_match(1).strip
|
108
|
+
else
|
109
|
+
# Fallback
|
110
|
+
div.gsub(%r{<span[^>]*>.*?</span>}m, "").gsub(%r{</?div[^>]*>}, "").strip
|
111
|
+
end
|
112
|
+
|
113
|
+
sections << {
|
114
|
+
number: section_number,
|
115
|
+
content: decode_html_entities(content.gsub(/<[^>]*>/, " ").gsub(/\s+/, " ").strip)
|
116
|
+
}
|
117
|
+
end
|
118
|
+
|
119
|
+
# Sort by section number
|
120
|
+
sections.sort_by { |section| section[:number] }
|
121
|
+
end
|
122
|
+
|
123
|
+
# Decode HTML entities in text
|
124
|
+
# @param text [String] Text with HTML entities
|
125
|
+
# @return [String] Text with decoded HTML entities
|
126
|
+
def decode_html_entities(text)
|
127
|
+
text.gsub(/&([a-z]+);/i) do |entity|
|
128
|
+
case ::Regexp.last_match(1).downcase
|
129
|
+
when "lt" then "<"
|
130
|
+
when "gt" then ">"
|
131
|
+
when "amp" then "&"
|
132
|
+
when "quot" then '"'
|
133
|
+
when "apos" then "'"
|
134
|
+
when "nbsp" then " "
|
135
|
+
else entity
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|
139
|
+
end
|
140
|
+
end
|
141
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rails/railtie"
|
4
|
+
|
5
|
+
module Rospatent
|
6
|
+
class Railtie < ::Rails::Railtie
|
7
|
+
# Ensure generators are loaded when Rails loads
|
8
|
+
generators do
|
9
|
+
require "generators/rospatent/install/install_generator"
|
10
|
+
end
|
11
|
+
|
12
|
+
# Configure Rails integration
|
13
|
+
config.before_configuration do
|
14
|
+
# Ensure generators are discoverable
|
15
|
+
end
|
16
|
+
|
17
|
+
# Add Rails-specific initializer
|
18
|
+
initializer "rospatent.configure" do
|
19
|
+
# Set Rails-friendly defaults
|
20
|
+
if defined?(::Rails.logger) && Rospatent.configuration.log_level == :debug
|
21
|
+
# Use Rails logger in development
|
22
|
+
Rospatent.configuration.instance_variable_set(:@rails_integration, true)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,222 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "input_validator"
|
4
|
+
|
5
|
+
module Rospatent
|
6
|
+
# Search result class to handle API responses
|
7
|
+
class SearchResult
|
8
|
+
attr_reader :total, :available, :hits, :raw_response
|
9
|
+
|
10
|
+
# Initialize a search result from API response
|
11
|
+
# @param response [Hash] API response data
|
12
|
+
def initialize(response)
|
13
|
+
@raw_response = response
|
14
|
+
@total = response["total"]
|
15
|
+
@available = response["available"]
|
16
|
+
@hits = response["hits"] || []
|
17
|
+
end
|
18
|
+
|
19
|
+
# Check if the search has any results
|
20
|
+
# @return [Boolean] true if there are any hits
|
21
|
+
def any? = !@hits.empty?
|
22
|
+
|
23
|
+
# Return the number of hits in the current response
|
24
|
+
# @return [Integer] number of hits
|
25
|
+
def count = @hits.count
|
26
|
+
end
|
27
|
+
|
28
|
+
# Search class to handle search queries to the API
|
29
|
+
class Search
|
30
|
+
include InputValidator
|
31
|
+
|
32
|
+
# Initialize a new search instance
|
33
|
+
# @param client [Rospatent::Client] API client instance
|
34
|
+
def initialize(client)
|
35
|
+
@client = client
|
36
|
+
end
|
37
|
+
|
38
|
+
# Execute a search against the API
|
39
|
+
#
|
40
|
+
# @param q [String] Search query using the special query language
|
41
|
+
# @param qn [String] Natural language search query
|
42
|
+
# @param limit [Integer] Maximum number of results to return
|
43
|
+
# @param offset [Integer] Offset for pagination
|
44
|
+
# @param pre_tag [String] HTML tag to prepend to highlighted matches
|
45
|
+
# @param post_tag [String] HTML tag to append to highlighted matches
|
46
|
+
# @param sort [Symbol, String] Sort option (:relevance, :pub_date, :filing_date)
|
47
|
+
# @param group_by [Symbol, String] Grouping option (:patent_family)
|
48
|
+
# @param include_facets [Boolean] Whether to include facet information
|
49
|
+
# @param filter [Hash] Filters to apply to the search
|
50
|
+
# @param datasets [Array<String>] Datasets to search within
|
51
|
+
# @param highlight [Boolean] Whether to highlight matches
|
52
|
+
#
|
53
|
+
# @return [Rospatent::SearchResult] Search result object
|
54
|
+
def execute(
|
55
|
+
q: nil,
|
56
|
+
qn: nil,
|
57
|
+
limit: nil,
|
58
|
+
offset: nil,
|
59
|
+
pre_tag: nil,
|
60
|
+
post_tag: nil,
|
61
|
+
sort: nil,
|
62
|
+
group_by: nil,
|
63
|
+
include_facets: nil,
|
64
|
+
filter: nil,
|
65
|
+
datasets: nil,
|
66
|
+
highlight: nil
|
67
|
+
)
|
68
|
+
# Filter out nil parameters to only validate explicitly provided ones
|
69
|
+
params = {
|
70
|
+
q: q, qn: qn, limit: limit, offset: offset,
|
71
|
+
pre_tag: pre_tag, post_tag: post_tag, sort: sort,
|
72
|
+
group_by: group_by, include_facets: include_facets,
|
73
|
+
filter: filter, datasets: datasets, highlight: highlight
|
74
|
+
}.compact
|
75
|
+
|
76
|
+
# Validate and normalize parameters
|
77
|
+
validated_params = validate_and_normalize_params(**params)
|
78
|
+
|
79
|
+
payload = build_payload(**validated_params)
|
80
|
+
response = @client.post("/patsearch/v0.2/search", payload)
|
81
|
+
SearchResult.new(response)
|
82
|
+
end
|
83
|
+
|
84
|
+
private
|
85
|
+
|
86
|
+
# Validate and normalize all search parameters
|
87
|
+
# @param params [Hash] Search parameters
|
88
|
+
# @return [Hash] Validated and normalized parameters
|
89
|
+
# @raise [Rospatent::Errors::ValidationError] when validation fails
|
90
|
+
def validate_and_normalize_params(**params)
|
91
|
+
# Check that at least one query parameter is provided
|
92
|
+
unless params[:q] || params[:qn]
|
93
|
+
raise Errors::InvalidRequestError,
|
94
|
+
"Either 'q' or 'qn' parameter must be provided for search"
|
95
|
+
end
|
96
|
+
|
97
|
+
validated = {}
|
98
|
+
|
99
|
+
# Validate query parameters
|
100
|
+
validated[:q] = validate_string(params[:q], "q", max_length: 1000) if params[:q]
|
101
|
+
validated[:qn] = validate_string(params[:qn], "qn", max_length: 1000) if params[:qn]
|
102
|
+
|
103
|
+
# Validate pagination parameters (only if provided)
|
104
|
+
if params[:limit]
|
105
|
+
validated[:limit] =
|
106
|
+
validate_positive_integer(params[:limit], "limit", min_value: 1, max_value: 100)
|
107
|
+
end
|
108
|
+
if params[:offset]
|
109
|
+
validated[:offset] =
|
110
|
+
validate_positive_integer(params[:offset], "offset", min_value: 0, max_value: 10_000)
|
111
|
+
end
|
112
|
+
|
113
|
+
# Validate highlighting parameters (only if provided)
|
114
|
+
if params.key?(:highlight)
|
115
|
+
validated[:highlight] = !!params[:highlight]
|
116
|
+
if params[:highlight] && params[:pre_tag]
|
117
|
+
validated[:pre_tag] =
|
118
|
+
validate_string(params[:pre_tag], "pre_tag", max_length: 50)
|
119
|
+
end
|
120
|
+
if params[:highlight] && params[:post_tag]
|
121
|
+
validated[:post_tag] =
|
122
|
+
validate_string(params[:post_tag], "post_tag", max_length: 50)
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
# Validate sort parameter (only if provided)
|
127
|
+
validated[:sort] = validate_sort_parameter(params[:sort]) if params[:sort]
|
128
|
+
|
129
|
+
# Validate group_by parameter (only if provided)
|
130
|
+
if params[:group_by]
|
131
|
+
validated[:group_by] = validate_enum(params[:group_by], [:patent_family], "group_by")
|
132
|
+
end
|
133
|
+
|
134
|
+
# Validate boolean parameters (only if provided)
|
135
|
+
validated[:include_facets] = !params[:include_facets].nil? if params.key?(:include_facets)
|
136
|
+
|
137
|
+
# Validate filter parameter
|
138
|
+
validated[:filter] = validate_hash(params[:filter], "filter") if params[:filter]
|
139
|
+
|
140
|
+
# Validate datasets parameter
|
141
|
+
if params[:datasets]
|
142
|
+
validated[:datasets] = validate_array(params[:datasets], "datasets", max_size: 10) do |dataset|
|
143
|
+
validate_string(dataset, "dataset")
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
validated.compact
|
148
|
+
end
|
149
|
+
|
150
|
+
# Build the search payload
|
151
|
+
# @param params [Hash] Validated search parameters
|
152
|
+
# @return [Hash] Search request payload
|
153
|
+
def build_payload(**params)
|
154
|
+
payload = {}
|
155
|
+
|
156
|
+
# Add query parameters (required)
|
157
|
+
payload[:q] = params[:q] if params[:q]
|
158
|
+
payload[:qn] = params[:qn] if params[:qn]
|
159
|
+
|
160
|
+
# Add pagination parameters (only if explicitly provided)
|
161
|
+
payload[:limit] = params[:limit] if params[:limit]
|
162
|
+
payload[:offset] = params[:offset] if params[:offset]
|
163
|
+
|
164
|
+
# Add highlighting parameters (only if explicitly provided)
|
165
|
+
if params.key?(:highlight)
|
166
|
+
payload[:highlight] = params[:highlight]
|
167
|
+
payload[:pre_tag] = params[:pre_tag] if params[:pre_tag]
|
168
|
+
payload[:post_tag] = params[:post_tag] if params[:post_tag]
|
169
|
+
end
|
170
|
+
|
171
|
+
# Add sort parameter (only if explicitly provided)
|
172
|
+
payload[:sort] = params[:sort] if params[:sort]
|
173
|
+
|
174
|
+
# Add grouping parameter (only if explicitly provided)
|
175
|
+
payload[:group_by] = "patent_family" if params[:group_by] == :patent_family
|
176
|
+
|
177
|
+
# Add other parameters (only if explicitly provided)
|
178
|
+
payload[:include_facets] = params[:include_facets] if params.key?(:include_facets)
|
179
|
+
payload[:filter] = params[:filter] if params[:filter]
|
180
|
+
payload[:datasets] = params[:datasets] if params[:datasets]
|
181
|
+
|
182
|
+
payload
|
183
|
+
end
|
184
|
+
|
185
|
+
# Validate and normalize sort parameter according to API documentation
|
186
|
+
# @param sort_value [String, Symbol] Sort parameter value
|
187
|
+
# @return [String] Normalized sort parameter
|
188
|
+
# @raise [Rospatent::Errors::ValidationError] If sort parameter is invalid
|
189
|
+
def validate_sort_parameter(sort_value)
|
190
|
+
return nil unless sort_value
|
191
|
+
|
192
|
+
# Allowed values according to API documentation
|
193
|
+
allowed_values = [
|
194
|
+
"relevance",
|
195
|
+
"publication_date:asc",
|
196
|
+
"publication_date:desc",
|
197
|
+
"filing_date:asc",
|
198
|
+
"filing_date:desc"
|
199
|
+
]
|
200
|
+
|
201
|
+
# Convert and normalize the sort parameter
|
202
|
+
normalized = case sort_value.to_s
|
203
|
+
when "relevance" then "relevance"
|
204
|
+
when "pub_date" then "publication_date:desc" # Default to desc for backward compatibility
|
205
|
+
when "filing_date" then "filing_date:desc" # Default to desc for backward compatibility
|
206
|
+
when "publication_date:asc", "publication_date:desc",
|
207
|
+
"filing_date:asc", "filing_date:desc"
|
208
|
+
sort_value.to_s
|
209
|
+
else
|
210
|
+
sort_value.to_s
|
211
|
+
end
|
212
|
+
|
213
|
+
# Validate against allowed values
|
214
|
+
unless allowed_values.include?(normalized)
|
215
|
+
raise Errors::ValidationError,
|
216
|
+
"Invalid sort parameter. Allowed values: #{allowed_values.join(', ')}"
|
217
|
+
end
|
218
|
+
|
219
|
+
normalized
|
220
|
+
end
|
221
|
+
end
|
222
|
+
end
|