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.
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rospatent
4
+ VERSION = "1.0.0"
5
+ end