epo-ops 0.2.6 → 0.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/.gitignore +3 -0
- data/.travis.yml +6 -0
- data/README.md +78 -38
- data/epo-ops.gemspec +2 -2
- data/lib/epo_ops.rb +46 -0
- data/lib/epo_ops/client.rb +46 -0
- data/lib/epo_ops/error.rb +87 -0
- data/lib/epo_ops/factories.rb +9 -0
- data/lib/epo_ops/factories/name_and_address_factory.rb +54 -0
- data/lib/epo_ops/factories/patent_application_factory.rb +116 -0
- data/lib/epo_ops/factories/register_search_result_factory.rb +42 -0
- data/lib/epo_ops/ipc_class_hierarchy.rb +146 -0
- data/lib/epo_ops/ipc_class_hierarchy_loader.rb +60 -0
- data/lib/epo_ops/ipc_class_util.rb +71 -0
- data/lib/epo_ops/limits.rb +20 -0
- data/lib/epo_ops/logger.rb +15 -0
- data/lib/epo_ops/name_and_address.rb +58 -0
- data/lib/epo_ops/patent_application.rb +159 -0
- data/lib/epo_ops/rate_limit.rb +47 -0
- data/lib/epo_ops/register.rb +100 -0
- data/lib/epo_ops/register_search_result.rb +40 -0
- data/lib/epo_ops/search_query_builder.rb +65 -0
- data/lib/epo_ops/token_store.rb +33 -0
- data/lib/epo_ops/token_store/redis.rb +45 -0
- data/lib/epo_ops/util.rb +52 -0
- data/lib/epo_ops/version.rb +3 -0
- metadata +26 -20
- data/lib/epo/ops.rb +0 -43
- data/lib/epo/ops/address.rb +0 -60
- data/lib/epo/ops/bibliographic_document.rb +0 -196
- data/lib/epo/ops/client.rb +0 -27
- data/lib/epo/ops/error.rb +0 -89
- data/lib/epo/ops/ipc_class_hierarchy.rb +0 -148
- data/lib/epo/ops/ipc_class_hierarchy_loader.rb +0 -62
- data/lib/epo/ops/ipc_class_util.rb +0 -73
- data/lib/epo/ops/limits.rb +0 -22
- data/lib/epo/ops/logger.rb +0 -11
- data/lib/epo/ops/rate_limit.rb +0 -49
- data/lib/epo/ops/register.rb +0 -152
- data/lib/epo/ops/search_query_builder.rb +0 -65
- data/lib/epo/ops/token_store.rb +0 -35
- data/lib/epo/ops/token_store/redis.rb +0 -47
- data/lib/epo/ops/util.rb +0 -32
- data/lib/epo/ops/version.rb +0 -6
@@ -0,0 +1,47 @@
|
|
1
|
+
module EpoOps
|
2
|
+
class RateLimit
|
3
|
+
WEEKLY_QUOTA_RESET_TIME = 604_800
|
4
|
+
HOURLY_QUOTA_RESET_TIME = 600
|
5
|
+
BASE_RESET_TIME = 60
|
6
|
+
|
7
|
+
attr_reader :attr
|
8
|
+
|
9
|
+
def initialize(http_header)
|
10
|
+
fail "Rate Limit data should be a Hash but is #{http_header.inspect} (#{http_header.class.name})" unless http_header.is_a?(Hash)
|
11
|
+
@attr = http_header
|
12
|
+
end
|
13
|
+
|
14
|
+
def limit_reached?
|
15
|
+
@attr.key?('x-rejection-reason')
|
16
|
+
end
|
17
|
+
|
18
|
+
def rejection_reason
|
19
|
+
return nil unless @attr['x-rejection-reason']
|
20
|
+
case @attr['x-rejection-reason']
|
21
|
+
when 'RegisteredQuotaPerWeek' then :weekly_quota
|
22
|
+
when 'IndividualQuotaPerHour' then :hourly_quota
|
23
|
+
else :unknown_reason
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def hourly_quota
|
28
|
+
quota = @attr['x-individualquotaperhour-used']
|
29
|
+
quota.to_i if quota
|
30
|
+
end
|
31
|
+
|
32
|
+
def weekly_quota
|
33
|
+
quota = @attr['x-registeredquotaperweek-used']
|
34
|
+
quota.to_i if quota
|
35
|
+
end
|
36
|
+
|
37
|
+
def reset_at
|
38
|
+
return unless limit_reached?
|
39
|
+
|
40
|
+
case rejection_reason
|
41
|
+
when :weekly_quota then Time.now.to_i + WEEKLY_QUOTA_RESET_TIME
|
42
|
+
when :hourly_quota then Time.now.to_i + HOURLY_QUOTA_RESET_TIME
|
43
|
+
else Time.now.to_i + BASE_RESET_TIME
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,100 @@
|
|
1
|
+
require 'epo_ops'
|
2
|
+
require 'epo_ops/client'
|
3
|
+
require 'epo_ops/util'
|
4
|
+
require 'epo_ops/logger'
|
5
|
+
require 'epo_ops/ipc_class_util'
|
6
|
+
|
7
|
+
module EpoOps
|
8
|
+
# Access to the {http://ops.epo.org/3.1/rest-services/register register}
|
9
|
+
# endpoint of the EPO OPS API.
|
10
|
+
#
|
11
|
+
# By now you can search and retrieve patents by using the type `application`
|
12
|
+
# in the `epodoc` format.
|
13
|
+
#
|
14
|
+
# Search queries are limited by size, not following these limits
|
15
|
+
# will result in errors. You should probably use {.search} which handles the
|
16
|
+
# limits itself.
|
17
|
+
#
|
18
|
+
# For more fine grained control use {.raw_search} and {.raw_biblio}
|
19
|
+
#
|
20
|
+
# @see Limits
|
21
|
+
# @see SearchQueryBuilder
|
22
|
+
class Register
|
23
|
+
# A helper method which creates queries that take API limits into account.
|
24
|
+
# @param patent_count [Integer] number of overall results expected.
|
25
|
+
# See {.published_patents_count}
|
26
|
+
#
|
27
|
+
# @return [Array] of Strings, each a query to put into {Register.raw_search}
|
28
|
+
# @see EpoOps::Limits
|
29
|
+
def self.split_by_size_limits(ipc_class, date, patent_count)
|
30
|
+
max_interval = Limits::MAX_QUERY_INTERVAL
|
31
|
+
(1..patent_count).step(max_interval).map do |start|
|
32
|
+
range_end = [start + max_interval - 1, patent_count].min
|
33
|
+
EpoOps::SearchQueryBuilder.build(ipc_class, date, start, range_end)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
# Makes the requests to find how many patents are in each top
|
38
|
+
# level ipc class on a given date.
|
39
|
+
#
|
40
|
+
# @param date [Date] date on which patents should be counted
|
41
|
+
# @return [Hash] Hash ipc_class => count (ipc_class A-H)
|
42
|
+
def self.patent_counts_per_ipc_class(date)
|
43
|
+
%w( A B C D E F G H ).inject({}) do |mem, icc|
|
44
|
+
mem[icc] = published_patents_counts(icc, date)
|
45
|
+
mem
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
# @param date [Date]
|
50
|
+
# @param ipc_class [String] up to now should only be between A-H
|
51
|
+
# @return [Integer] number of patents with given parameters
|
52
|
+
def self.published_patents_counts(ipc_class = nil, date = nil)
|
53
|
+
query = SearchQueryBuilder.build(ipc_class, date, 1, 2)
|
54
|
+
minimum_result_set = Register.raw_search(query)
|
55
|
+
minimum_result_set.count
|
56
|
+
end
|
57
|
+
|
58
|
+
# Search method returning all unique register references on a given
|
59
|
+
# date, with optional ipc_class.
|
60
|
+
# @note This method does more than one query; it may happen that you
|
61
|
+
# exceed your API limits
|
62
|
+
# @return [Array] Array of {SearchEntry}
|
63
|
+
def self.search(ipc_class = nil, date = nil)
|
64
|
+
queries = all_queries(ipc_class, date)
|
65
|
+
search_entries = queries.map { |query| raw_search(query) }
|
66
|
+
applications = search_entries.collect(&:patents)
|
67
|
+
|
68
|
+
EpoOps::RegisterSearchResult.new(applications,applications.count)
|
69
|
+
end
|
70
|
+
|
71
|
+
# @return [Array] Array of Strings containing queries applicable to
|
72
|
+
# {Register.raw_search}.
|
73
|
+
# builds all queries necessary to find all patent references on a given
|
74
|
+
# date.
|
75
|
+
def self.all_queries(ipc_class = nil, date = nil)
|
76
|
+
count = published_patents_counts(ipc_class, date)
|
77
|
+
if count > Limits::MAX_QUERY_RANGE
|
78
|
+
IpcClassUtil.children(ipc_class).flat_map { |ic| all_queries(ic, date) }
|
79
|
+
else
|
80
|
+
split_by_size_limits(ipc_class, date, count)
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
# @param query A query built with {EpoOps::SearchQueryBuilder}
|
85
|
+
# @param raw if `true` the result will be the raw response as a nested
|
86
|
+
# hash. if false(default) the result will be parsed further, returning a
|
87
|
+
# list of [SearchEntry]
|
88
|
+
# @return [RegisterSearchResult]
|
89
|
+
def self.raw_search(query, raw = false)
|
90
|
+
data = Client.request(
|
91
|
+
:get,
|
92
|
+
'/3.1/rest-services/register/search?' + query
|
93
|
+
).parsed
|
94
|
+
|
95
|
+
EpoOps::Factories::RegisterSearchResultFactory.build(data)
|
96
|
+
rescue EpoOps::Error::NotFound
|
97
|
+
raw ? nil : EpoOps::RegisterSearchResult::NullResult.new
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
module EpoOps
|
2
|
+
# A simple wrapper for register search query result.
|
3
|
+
class RegisterSearchResult
|
4
|
+
include Enumerable
|
5
|
+
|
6
|
+
def initialize(patents,count,raw_data = nil)
|
7
|
+
@patents = patents
|
8
|
+
@count = count
|
9
|
+
@raw_data = raw_data
|
10
|
+
end
|
11
|
+
|
12
|
+
# The number of patents that match the query string. Offsets and API query limits do not apply
|
13
|
+
# so that the actual number of patents returned can be much smaller.
|
14
|
+
# @see EpoOps::Limits
|
15
|
+
# @return [integer] The number of applications matching the query.
|
16
|
+
attr_reader :count
|
17
|
+
|
18
|
+
# @return [Array] the patents returned by the search. Patentapplication data is not complete
|
19
|
+
attr_reader :patents
|
20
|
+
|
21
|
+
def each
|
22
|
+
patents.each do |patent|
|
23
|
+
yield(patent)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
# Represents queries with no results
|
28
|
+
class NullResult < EpoOps::RegisterSearchResult
|
29
|
+
def initialize(data=nil) ; end
|
30
|
+
|
31
|
+
def count
|
32
|
+
0
|
33
|
+
end
|
34
|
+
|
35
|
+
def patents
|
36
|
+
[]
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
require 'epo_ops/limits'
|
2
|
+
require 'epo_ops/logger'
|
3
|
+
|
4
|
+
module EpoOps
|
5
|
+
# This Builder helps creating a search query using
|
6
|
+
# {https://www.loc.gov/standards/sru/cql/ CQL} (Common Query Language or
|
7
|
+
# Contextual Query Language) with the identifiers specified by the EPO in
|
8
|
+
# the OPS Documentation chapter 4.2 ({https://www.epo.org/searching-for-patents/technical/espacenet/ops.html Link})
|
9
|
+
# - use tab Downloads and see file 'OPS version 3.1 documentation').
|
10
|
+
class SearchQueryBuilder
|
11
|
+
# Build the query with the given parameters. Invalid ranges are fixed
|
12
|
+
# automatically and you will be notified about the changes
|
13
|
+
# @return [String]
|
14
|
+
def self.build(ipc_class, date, range_start = nil, range_end = nil)
|
15
|
+
validated_range = validate_range range_start, range_end
|
16
|
+
"q=#{build_params(ipc_class, date)}&Range=#{validated_range[0]}-#{validated_range[1]}"
|
17
|
+
end
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
def self.build_params(ipc_class, date)
|
22
|
+
[build_date(date), build_class(ipc_class)].compact.join(' and ')
|
23
|
+
end
|
24
|
+
|
25
|
+
def self.build_date(date)
|
26
|
+
if date
|
27
|
+
"pd=#{('%04d' % date.year)}"\
|
28
|
+
"#{('%02d' % date.month)}"\
|
29
|
+
"#{('%02d' % date.day)}"
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def self.build_class(ipc_class)
|
34
|
+
"ic=#{ipc_class}" if ipc_class
|
35
|
+
end
|
36
|
+
|
37
|
+
# Fixes the range given so that they meed the EPO APIs rules. The range
|
38
|
+
# may only be 100 elements long, the maximum allowed value is 2000.
|
39
|
+
# If the given window is out of range, it will be moved preserving the
|
40
|
+
# distance covered.
|
41
|
+
# @see EpoOps::Limits
|
42
|
+
# @return array with two elements: [range_start, range_end]
|
43
|
+
def self.validate_range(range_start, range_end)
|
44
|
+
range_start = 1 unless range_start
|
45
|
+
range_end = 10 unless range_end
|
46
|
+
if range_start > range_end
|
47
|
+
range_start, range_end = range_end, range_start
|
48
|
+
Logger.debug('range_start was bigger than range_end, swapped values')
|
49
|
+
elsif range_end - range_start > Limits::MAX_QUERY_INTERVAL - 1
|
50
|
+
range_end = range_start + Limits::MAX_QUERY_INTERVAL - 1
|
51
|
+
Logger.debug("range invalid, set to: #{[range_start, range_end]}")
|
52
|
+
end
|
53
|
+
if range_start < 1
|
54
|
+
range_end = range_end - range_start + 1
|
55
|
+
range_start = 1
|
56
|
+
Logger.debug("range_start must be > 0, set to: #{[range_start, range_end]}")
|
57
|
+
elsif range_end > Limits::MAX_QUERY_RANGE
|
58
|
+
range_start = Limits::MAX_QUERY_RANGE - (range_end - range_start)
|
59
|
+
range_end = Limits::MAX_QUERY_RANGE
|
60
|
+
Logger.debug("range_end was too big, set to: #{[range_start, range_end]}")
|
61
|
+
end
|
62
|
+
[range_start, range_end]
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
require 'oauth2'
|
2
|
+
require 'epo_ops'
|
3
|
+
|
4
|
+
module EpoOps
|
5
|
+
# This class saves the token in memory, you may want to subclass this and
|
6
|
+
# overwrite #token if you want to store it somewhere else.
|
7
|
+
#
|
8
|
+
class TokenStore
|
9
|
+
def token
|
10
|
+
@token = generate_token if !@token || @token.expired?
|
11
|
+
|
12
|
+
@token
|
13
|
+
end
|
14
|
+
|
15
|
+
def reset
|
16
|
+
@token = nil
|
17
|
+
end
|
18
|
+
|
19
|
+
protected
|
20
|
+
|
21
|
+
def generate_token
|
22
|
+
client = OAuth2::Client.new(
|
23
|
+
EpoOps.config.consumer_key,
|
24
|
+
EpoOps.config.consumer_secret,
|
25
|
+
site: 'https://ops.epo.org/',
|
26
|
+
token_url: '/3.1/auth/accesstoken',
|
27
|
+
raise_errors: false
|
28
|
+
)
|
29
|
+
|
30
|
+
client.client_credentials.get_token
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
require 'redis'
|
2
|
+
require 'connection_pool'
|
3
|
+
|
4
|
+
module EpoOps
|
5
|
+
class TokenStore
|
6
|
+
class Redis < TokenStore
|
7
|
+
def initialize(redis_host)
|
8
|
+
fail "Please install gems 'redis' and 'connection_pool' to use this feature" unless defined?(::Redis) && defined?(ConnectionPool)
|
9
|
+
|
10
|
+
@redis = ConnectionPool.new(size: 5, timeout: 5) { ::Redis.new(host: redis_host) }
|
11
|
+
end
|
12
|
+
|
13
|
+
def token
|
14
|
+
token = nil
|
15
|
+
@redis.with do |conn|
|
16
|
+
token = conn.get("epo_token_#{id}")
|
17
|
+
end
|
18
|
+
|
19
|
+
token.present? ? OAuth2::AccessToken.new(client, token) : generate_token
|
20
|
+
end
|
21
|
+
|
22
|
+
def reset
|
23
|
+
@redis.with do |conn|
|
24
|
+
conn.del("epo_token_#{id}")
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
def id
|
31
|
+
Digest::MD5.hexdigest(EpoOps.config.consumer_key + EpoOps.config.consumer_secret)
|
32
|
+
end
|
33
|
+
|
34
|
+
def generate_token
|
35
|
+
token = super
|
36
|
+
|
37
|
+
@redis.with do |conn|
|
38
|
+
conn.set("epo_token_#{id}", token.token, ex: token.expires_in, nx: true)
|
39
|
+
end
|
40
|
+
|
41
|
+
token
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
data/lib/epo_ops/util.rb
ADDED
@@ -0,0 +1,52 @@
|
|
1
|
+
module EpoOps
|
2
|
+
class Util
|
3
|
+
# the path should be an array of strings indicating the path you want to go in the hash
|
4
|
+
def self.find_in_data(epo_hash, path)
|
5
|
+
path.reduce(epo_hash) { |res, c| parse_hash_flat(res, c) }
|
6
|
+
end
|
7
|
+
|
8
|
+
def self.parse_hash_flat(hash_layer, target)
|
9
|
+
result = []
|
10
|
+
if hash_layer.nil?
|
11
|
+
return []
|
12
|
+
elsif hash_layer.class == String
|
13
|
+
return []
|
14
|
+
elsif hash_layer.class == Array
|
15
|
+
result.concat(hash_layer.map { |x| parse_hash_flat(x, target) })
|
16
|
+
elsif hash_layer[target]
|
17
|
+
result << hash_layer[target]
|
18
|
+
elsif hash_layer.class == Hash || hash_layer.respond_to?(:to_h)
|
19
|
+
result.concat(hash_layer.to_h.map { |_x, y| parse_hash_flat(y, target) })
|
20
|
+
end
|
21
|
+
result.flatten
|
22
|
+
end
|
23
|
+
|
24
|
+
def self.dig(data,*path)
|
25
|
+
path.flatten.inject(data) do |d,key|
|
26
|
+
if d.is_a? Hash
|
27
|
+
d[key]
|
28
|
+
else
|
29
|
+
nil
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def self.flat_dig(data,*path)
|
35
|
+
path.flatten.inject(data) do |d,key|
|
36
|
+
if d.is_a? Hash
|
37
|
+
d[key].is_a?(Array) ? d[key] : [d[key]]
|
38
|
+
elsif d.is_a? Array
|
39
|
+
d.select {|element| element.is_a? Hash}.flat_map {|element| element[key]}
|
40
|
+
else
|
41
|
+
[]
|
42
|
+
end
|
43
|
+
end.reject(&:nil?)
|
44
|
+
end
|
45
|
+
|
46
|
+
def self.parse_change_gazette_num(num)
|
47
|
+
res = /^(?<year>\d{4})\/(?<week>\d{2})$/.match(num)
|
48
|
+
return nil if res.nil?
|
49
|
+
Date.commercial(Integer(res[:year], 10), week = Integer(res[:week], 10))
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: epo-ops
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.3.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Max Kießling
|
@@ -10,7 +10,7 @@ authors:
|
|
10
10
|
autorequire:
|
11
11
|
bindir: exe
|
12
12
|
cert_chain: []
|
13
|
-
date:
|
13
|
+
date: 2016-05-10 00:00:00.000000000 Z
|
14
14
|
dependencies:
|
15
15
|
- !ruby/object:Gem::Dependency
|
16
16
|
name: bundler
|
@@ -174,28 +174,34 @@ extensions: []
|
|
174
174
|
extra_rdoc_files: []
|
175
175
|
files:
|
176
176
|
- ".gitignore"
|
177
|
+
- ".travis.yml"
|
177
178
|
- Gemfile
|
178
179
|
- LICENSE
|
179
180
|
- README.md
|
180
181
|
- Rakefile
|
181
182
|
- epo-ops.gemspec
|
182
|
-
- lib/
|
183
|
-
- lib/
|
184
|
-
- lib/
|
185
|
-
- lib/
|
186
|
-
- lib/
|
187
|
-
- lib/
|
188
|
-
- lib/
|
189
|
-
- lib/
|
190
|
-
- lib/
|
191
|
-
- lib/
|
192
|
-
- lib/
|
193
|
-
- lib/
|
194
|
-
- lib/
|
195
|
-
- lib/
|
196
|
-
- lib/
|
197
|
-
- lib/
|
198
|
-
- lib/
|
183
|
+
- lib/epo_ops.rb
|
184
|
+
- lib/epo_ops/client.rb
|
185
|
+
- lib/epo_ops/error.rb
|
186
|
+
- lib/epo_ops/factories.rb
|
187
|
+
- lib/epo_ops/factories/name_and_address_factory.rb
|
188
|
+
- lib/epo_ops/factories/patent_application_factory.rb
|
189
|
+
- lib/epo_ops/factories/register_search_result_factory.rb
|
190
|
+
- lib/epo_ops/ipc_class_hierarchy.rb
|
191
|
+
- lib/epo_ops/ipc_class_hierarchy_loader.rb
|
192
|
+
- lib/epo_ops/ipc_class_util.rb
|
193
|
+
- lib/epo_ops/limits.rb
|
194
|
+
- lib/epo_ops/logger.rb
|
195
|
+
- lib/epo_ops/name_and_address.rb
|
196
|
+
- lib/epo_ops/patent_application.rb
|
197
|
+
- lib/epo_ops/rate_limit.rb
|
198
|
+
- lib/epo_ops/register.rb
|
199
|
+
- lib/epo_ops/register_search_result.rb
|
200
|
+
- lib/epo_ops/search_query_builder.rb
|
201
|
+
- lib/epo_ops/token_store.rb
|
202
|
+
- lib/epo_ops/token_store/redis.rb
|
203
|
+
- lib/epo_ops/util.rb
|
204
|
+
- lib/epo_ops/version.rb
|
199
205
|
homepage: https://github.com/FHG-IMW/epo-ops
|
200
206
|
licenses: []
|
201
207
|
metadata: {}
|
@@ -215,7 +221,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
215
221
|
version: '0'
|
216
222
|
requirements: []
|
217
223
|
rubyforge_project:
|
218
|
-
rubygems_version: 2.
|
224
|
+
rubygems_version: 2.4.8
|
219
225
|
signing_key:
|
220
226
|
specification_version: 4
|
221
227
|
summary: Ruby interface to the European Patent Office API (OPS)
|