nexus_mods 0.2.0 → 0.3.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +6 -0
- data/lib/nexus_mods/api_client.rb +182 -0
- data/lib/nexus_mods/cacheable_api.rb +52 -0
- data/lib/nexus_mods/cacheable_with_expiry.rb +70 -0
- data/lib/nexus_mods/core_extensions/cacheable/cache_adapters/persistent_json_adapter.rb +62 -0
- data/lib/nexus_mods/core_extensions/cacheable/method_generator.rb +62 -0
- data/lib/nexus_mods/file_cache.rb +71 -0
- data/lib/nexus_mods/version.rb +1 -1
- data/lib/nexus_mods.rb +20 -74
- data/spec/nexus_mods_test/helpers.rb +7 -0
- data/spec/nexus_mods_test/scenarios/nexus_mods_caching_spec.rb +88 -0
- metadata +22 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 7858789972e8674c6ba24c53d36e4af87b09b8636e84faced240ff0edd1eb3fa
|
4
|
+
data.tar.gz: 29df7fc4849e885e6a9aa705d1ae4868215e1181bdff8814d806787ff948790d
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 522c72b5d4cabff4d57609f9c593c5e75345c675343397e7e75c0bc10c7b29c8df72c26d7fa51bc73265bb8f0c025d3b929ef5a1810711928f416b851bedfef9
|
7
|
+
data.tar.gz: 17b804dfc30795f57c5e862a4990b39aec762f0b3058513956204443e2ebfe59df5285ceda0634a6644cdcd8e45619a8248964bf3028b2aa96852cbc4dd69f9f
|
data/CHANGELOG.md
CHANGED
@@ -1,3 +1,9 @@
|
|
1
|
+
# [v0.3.0](https://github.com/Muriel-Salvan/nexus_mods/compare/v0.2.0...v0.3.0) (2023-04-10 09:13:43)
|
2
|
+
|
3
|
+
### Features
|
4
|
+
|
5
|
+
* [[Feature] Implement caching of API queries with configurable expiry times](https://github.com/Muriel-Salvan/nexus_mods/commit/7f74e25d046adbcec394ce33a3802ed9e91f3a52)
|
6
|
+
|
1
7
|
# [v0.2.0](https://github.com/Muriel-Salvan/nexus_mods/compare/v0.1.1...v0.2.0) (2023-04-10 08:45:44)
|
2
8
|
|
3
9
|
### Features
|
@@ -0,0 +1,182 @@
|
|
1
|
+
require 'fileutils'
|
2
|
+
require 'faraday'
|
3
|
+
require 'faraday-http-cache'
|
4
|
+
require 'nexus_mods/file_cache'
|
5
|
+
require 'nexus_mods/cacheable_api'
|
6
|
+
|
7
|
+
class NexusMods
|
8
|
+
|
9
|
+
# Base class handling HTTP calls to the NexusMods API.
|
10
|
+
# Handle caching if needed.
|
11
|
+
class ApiClient
|
12
|
+
|
13
|
+
include CacheableApi
|
14
|
+
|
15
|
+
# Constructor
|
16
|
+
#
|
17
|
+
# Parameters::
|
18
|
+
# * *api_key* (String or nil): The API key to be used, or nil for another authentication [default: nil]
|
19
|
+
# * *http_cache_file* (String): File used to store the HTTP cache, or nil for no cache [default: "#{Dir.tmpdir}/nexus_mods_http_cache.json"]
|
20
|
+
# * *api_cache_expiry* (Hash<Symbol,Integer>): Expiry times in seconds, per expiry key. Possible keys are:
|
21
|
+
# * *games*: Expiry associated to queries on games [default: 1 day]
|
22
|
+
# * *mod*: Expiry associated to queries on mod [default: 1 day]
|
23
|
+
# * *mod_files*: Expiry associated to queries on mod files [default: 1 day]
|
24
|
+
# * *api_cache_file* (String): File used to store the NexusMods API cache, or nil for no cache [default: "#{Dir.tmpdir}/nexus_mods_api_cache.json"]
|
25
|
+
# * *logger* (Logger): The logger to be used for log messages [default: Logger.new(STDOUT)]
|
26
|
+
def initialize(
|
27
|
+
api_key: nil,
|
28
|
+
http_cache_file: "#{Dir.tmpdir}/nexus_mods_http_cache.json",
|
29
|
+
api_cache_expiry: DEFAULT_API_CACHE_EXPIRY,
|
30
|
+
api_cache_file: "#{Dir.tmpdir}/nexus_mods_api_cache.json",
|
31
|
+
logger: Logger.new($stdout)
|
32
|
+
)
|
33
|
+
@api_key = api_key
|
34
|
+
ApiClient.api_cache_expiry = ApiClient.api_cache_expiry.merge(api_cache_expiry)
|
35
|
+
@api_cache_file = api_cache_file
|
36
|
+
@logger = logger
|
37
|
+
# Initialize our HTTP client
|
38
|
+
@http_cache = http_cache_file.nil? ? nil : FileCache.new(http_cache_file)
|
39
|
+
@http_client = Faraday.new do |builder|
|
40
|
+
# Indicate that the cache is not shared, meaning that private resources (depending on the session) can be cached as we consider only 1 user is using it for a given file cache.
|
41
|
+
# Use Marshal serializer as some URLs can't get decoded correctly due to UTF-8 issues
|
42
|
+
builder.use :http_cache,
|
43
|
+
store: @http_cache,
|
44
|
+
shared_cache: false,
|
45
|
+
serializer: Marshal
|
46
|
+
builder.adapter Faraday.default_adapter
|
47
|
+
end
|
48
|
+
Cacheable.cache_adapter = :persistent_json
|
49
|
+
load_api_cache
|
50
|
+
end
|
51
|
+
|
52
|
+
# Send an HTTP request to the API and get back the answer as a JSON.
|
53
|
+
# Use caching.
|
54
|
+
#
|
55
|
+
# Parameters::
|
56
|
+
# * *path* (String): API path to contact (from v1/ and without .json)
|
57
|
+
# * *verb* (Symbol): Verb to be used (:get, :post...) [default: :get]
|
58
|
+
# Result::
|
59
|
+
# * Object: The JSON response
|
60
|
+
def api(path, verb: :get)
|
61
|
+
res = http(path, verb:)
|
62
|
+
json = JSON.parse(res.body)
|
63
|
+
uri = api_uri(path)
|
64
|
+
@logger.debug "[API call] - #{verb} #{uri} => #{res.status}\n#{
|
65
|
+
JSON.
|
66
|
+
pretty_generate(json).
|
67
|
+
split("\n").
|
68
|
+
map { |line| " #{line}" }.
|
69
|
+
join("\n")
|
70
|
+
}\n#{
|
71
|
+
res.
|
72
|
+
headers.
|
73
|
+
map { |header, value| " #{header}: #{value}" }.
|
74
|
+
join("\n")
|
75
|
+
}"
|
76
|
+
case res.status
|
77
|
+
when 200
|
78
|
+
# Happy
|
79
|
+
when 429
|
80
|
+
# Some limits of the API have been reached
|
81
|
+
raise LimitsExceededError, "Exceeding limits of API calls: #{res.headers.select { |header, _value| header =~ /^x-rl-.+$/ }}"
|
82
|
+
else
|
83
|
+
raise ApiError, "API #{uri} returned error code #{res.status}" unless res.status == '200'
|
84
|
+
end
|
85
|
+
json
|
86
|
+
end
|
87
|
+
cacheable_api(
|
88
|
+
:api,
|
89
|
+
expiry_from_key: proc do |key|
|
90
|
+
# Example of keys:
|
91
|
+
# NexusMods::ApiClient/api/games
|
92
|
+
# NexusMods::ApiClient/api/games/skyrimspecialedition/mods/2014
|
93
|
+
# NexusMods::ApiClient/api/games/skyrimspecialedition/mods/2014/files
|
94
|
+
# NexusMods::ApiClient/api/users/validate
|
95
|
+
key_components = key.split('/')[2..]
|
96
|
+
case key_components[0]
|
97
|
+
when 'games'
|
98
|
+
if key_components[1].nil?
|
99
|
+
ApiClient.api_cache_expiry[:games]
|
100
|
+
else
|
101
|
+
case key_components[2]
|
102
|
+
when 'mods'
|
103
|
+
case key_components[4]
|
104
|
+
when nil
|
105
|
+
ApiClient.api_cache_expiry[:mod]
|
106
|
+
when 'files'
|
107
|
+
ApiClient.api_cache_expiry[:mod_files]
|
108
|
+
else
|
109
|
+
raise "Unknown API path: #{key}"
|
110
|
+
end
|
111
|
+
else
|
112
|
+
raise "Unknown API path: #{key}"
|
113
|
+
end
|
114
|
+
end
|
115
|
+
when 'users'
|
116
|
+
# Don't cache this path as it is used to know API limits
|
117
|
+
0
|
118
|
+
else
|
119
|
+
raise "Unknown API path: #{key}"
|
120
|
+
end
|
121
|
+
end
|
122
|
+
)
|
123
|
+
|
124
|
+
# Send an HTTP request to the API and get back the HTTP response
|
125
|
+
#
|
126
|
+
# Parameters::
|
127
|
+
# * *path* (String): API path to contact (from v1/ and without .json)
|
128
|
+
# * *verb* (Symbol): Verb to be used (:get, :post...) [default: :get]
|
129
|
+
# Result::
|
130
|
+
# * Faraday::Response: The HTTP response
|
131
|
+
def http(path, verb: :get)
|
132
|
+
@http_client.send(verb) do |req|
|
133
|
+
req.url api_uri(path)
|
134
|
+
req.headers['apikey'] = @api_key
|
135
|
+
req.headers['User-Agent'] = "nexus_mods (#{RUBY_PLATFORM}) Ruby/#{RUBY_VERSION}"
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
# Load the API cache if a file was given to this client
|
140
|
+
def load_api_cache
|
141
|
+
Cacheable.cache_adapter.load(@api_cache_file) if @api_cache_file && File.exist?(@api_cache_file)
|
142
|
+
end
|
143
|
+
|
144
|
+
# Save the API cache if a file was given to this client
|
145
|
+
def save_api_cache
|
146
|
+
return unless @api_cache_file
|
147
|
+
|
148
|
+
FileUtils.mkdir_p(File.dirname(@api_cache_file))
|
149
|
+
Cacheable.cache_adapter.dump(@api_cache_file)
|
150
|
+
end
|
151
|
+
|
152
|
+
private
|
153
|
+
|
154
|
+
class << self
|
155
|
+
|
156
|
+
# Hash<Symbol,Integer>: Expiry time, per expiry key
|
157
|
+
attr_accessor :api_cache_expiry
|
158
|
+
|
159
|
+
end
|
160
|
+
|
161
|
+
# Default expiry times, in seconds
|
162
|
+
DEFAULT_API_CACHE_EXPIRY = {
|
163
|
+
games: 24 * 60 * 60,
|
164
|
+
mod: 24 * 60 * 60,
|
165
|
+
mod_files: 24 * 60 * 60
|
166
|
+
}
|
167
|
+
|
168
|
+
@api_cache_expiry = DEFAULT_API_CACHE_EXPIRY
|
169
|
+
|
170
|
+
# Get the real URI to query for a given API path
|
171
|
+
#
|
172
|
+
# Parameters::
|
173
|
+
# * *path* (String): API path to contact (from v1/ and without .json)
|
174
|
+
# Result::
|
175
|
+
# * String: The URI
|
176
|
+
def api_uri(path)
|
177
|
+
"https://api.nexusmods.com/v1/#{path}.json"
|
178
|
+
end
|
179
|
+
|
180
|
+
end
|
181
|
+
|
182
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
require 'cacheable'
|
2
|
+
require 'nexus_mods/core_extensions/cacheable/method_generator'
|
3
|
+
require 'nexus_mods/cacheable_with_expiry'
|
4
|
+
|
5
|
+
class NexusMods
|
6
|
+
|
7
|
+
# Provide cacheable helpers for API methods that can be invalidated with an expiry time in seconds
|
8
|
+
module CacheableApi
|
9
|
+
|
10
|
+
# Callback when the module is included in another module/class
|
11
|
+
#
|
12
|
+
# Parameters::
|
13
|
+
# * *base* (Class or Module): The class/module including this module
|
14
|
+
def self.included(base)
|
15
|
+
base.include CacheableWithExpiry
|
16
|
+
base.extend CacheableApi::CacheableHelpers
|
17
|
+
end
|
18
|
+
|
19
|
+
# Some class helpers to make API calls easily cacheable
|
20
|
+
module CacheableHelpers
|
21
|
+
|
22
|
+
# Cache methods used for the NexusMods API with a given expiry time in seconds
|
23
|
+
#
|
24
|
+
# Parameters::
|
25
|
+
# * *original_method_names* (Array<Symbol>): List of methods to which this cache apply
|
26
|
+
# * *expiry_from_key* (Proc): Code giving the number of seconds of cache expiry from the key
|
27
|
+
# * Parameters::
|
28
|
+
# * *key* (String): The key for which we want the expiry time in seconds
|
29
|
+
# * Result::
|
30
|
+
# * Integer: Corresponding expiry time
|
31
|
+
def cacheable_api(*original_method_names, expiry_from_key:)
|
32
|
+
cacheable_with_expiry(
|
33
|
+
*original_method_names,
|
34
|
+
key_format: lambda do |target, method_name, method_args, method_kwargs|
|
35
|
+
(
|
36
|
+
[
|
37
|
+
target.class,
|
38
|
+
method_name
|
39
|
+
] +
|
40
|
+
method_args +
|
41
|
+
method_kwargs.map { |key, value| "#{key}:#{value}" }
|
42
|
+
).join('/')
|
43
|
+
end,
|
44
|
+
expiry_from_key:
|
45
|
+
)
|
46
|
+
end
|
47
|
+
|
48
|
+
end
|
49
|
+
|
50
|
+
end
|
51
|
+
|
52
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
require 'time'
|
2
|
+
require 'nexus_mods/core_extensions/cacheable/cache_adapters/persistent_json_adapter'
|
3
|
+
|
4
|
+
class NexusMods
|
5
|
+
|
6
|
+
# Add cacheable properties that can be expired using time in seconds
|
7
|
+
module CacheableWithExpiry
|
8
|
+
|
9
|
+
# Callback when the module is included in another module/class
|
10
|
+
#
|
11
|
+
# Parameters::
|
12
|
+
# * *base* (Class or Module): The class/module including this module
|
13
|
+
def self.included(base)
|
14
|
+
base.include Cacheable
|
15
|
+
base.extend CacheableWithExpiry::CacheableHelpers
|
16
|
+
end
|
17
|
+
|
18
|
+
# Some class helpers to make cacheable calls easy with expiry proc
|
19
|
+
module CacheableHelpers
|
20
|
+
|
21
|
+
# Wrap Cacheable's cacheable method to add the expiry_rules kwarg and use to invalidate the cache of a PersistentJsonAdapter based on the key format.
|
22
|
+
#
|
23
|
+
# Parameters::
|
24
|
+
# * *original_method_names* (Array<Symbol>): List of methods to which this cache apply
|
25
|
+
# * *opts* (Hash<Symbol,Object>): kwargs that will be transferred to the cacheable method, with the following ones interpreted:
|
26
|
+
# * *expiry_from_key* (Proc): Code giving the number of seconds of cache expiry from the key
|
27
|
+
# * Parameters::
|
28
|
+
# * *key* (String): The key for which we want the expiry time in seconds
|
29
|
+
# * Result::
|
30
|
+
# * Integer: Corresponding expiry time
|
31
|
+
def cacheable_with_expiry(*original_method_names, **opts)
|
32
|
+
expiry_cache = {}
|
33
|
+
cacheable(
|
34
|
+
*original_method_names,
|
35
|
+
**opts.merge(
|
36
|
+
{
|
37
|
+
cache_options: {
|
38
|
+
expiry_from_key: opts[:expiry_from_key],
|
39
|
+
invalidate_if: proc do |key, options, context|
|
40
|
+
next true unless context['invalidate_time']
|
41
|
+
|
42
|
+
# Find if we know already the expiry for this key
|
43
|
+
expiry_cache[key] = options[:expiry_from_key].call(key) unless expiry_cache.key?(key)
|
44
|
+
expiry_cache[key].nil? || (Time.now.utc - Time.parse(context['invalidate_time']).utc > expiry_cache[key])
|
45
|
+
end,
|
46
|
+
update_context_after_fetch: proc do |_key, _value, _options, context|
|
47
|
+
context['invalidate_time'] = Time.now.utc.strftime('%FT%TUTC')
|
48
|
+
end
|
49
|
+
}
|
50
|
+
}
|
51
|
+
)
|
52
|
+
)
|
53
|
+
@_cacheable_expiry_caches = [] unless defined?(@_cacheable_expiry_caches)
|
54
|
+
@_cacheable_expiry_caches << expiry_cache
|
55
|
+
end
|
56
|
+
|
57
|
+
# Clear expiry times caches
|
58
|
+
def clear_cacheable_expiry_caches
|
59
|
+
return unless defined?(@_cacheable_expiry_caches)
|
60
|
+
|
61
|
+
@_cacheable_expiry_caches.each do |expiry_cache|
|
62
|
+
expiry_cache.replace({})
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
end
|
67
|
+
|
68
|
+
end
|
69
|
+
|
70
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
require 'cacheable'
|
2
|
+
require 'json'
|
3
|
+
|
4
|
+
module Cacheable
|
5
|
+
|
6
|
+
module CacheAdapters
|
7
|
+
|
8
|
+
# Adapter that adds JSON serialization functionality to save and load in files.
|
9
|
+
# Also adds contexts linked to keys being fetched to support more complex cache-invalidation schemes.
|
10
|
+
# This works only if:
|
11
|
+
# * The cached values are JSON serializable.
|
12
|
+
# * The cache keys are strings.
|
13
|
+
# * The context information is JSON serializable.
|
14
|
+
class PersistentJsonAdapter < MemoryAdapter
|
15
|
+
|
16
|
+
# Fetch a key with the givien cache options
|
17
|
+
#
|
18
|
+
# Parameters::
|
19
|
+
# * *key* (String): Key to be fetched
|
20
|
+
# * *options* (Hash): Cache options. The following options are interpreted by this fetch:
|
21
|
+
# * *invalidate_if* (Proc): Code called to know if the cache should be invalidated for a given key:
|
22
|
+
# * Parameters::
|
23
|
+
# * *key* (String): The key for which we check the cache invalidation
|
24
|
+
# * *options* (Hash): Cache options linked to this key
|
25
|
+
# * *key_context* (Hash): Context linked to this key, that can be set using the update_context_after_fetch callback.
|
26
|
+
# * Result::
|
27
|
+
# * Boolean: Should we invalidate the cached value of this key?
|
28
|
+
# * *update_context_after_fetch* (Proc): Code called when the value has been fetched for real (without cache), used to update the context
|
29
|
+
# * Parameters::
|
30
|
+
# * *key* (String): The key for which we just fetched the value
|
31
|
+
# * *value* (Object): The value that has just been fetched
|
32
|
+
# * *options* (Hash): Cache options linked to this key
|
33
|
+
# * *key_context* (Hash): Context linked to this key, that is supposed to be updated in place by this callback
|
34
|
+
# * CodeBlock: Code called to fetch the value if not in the cache
|
35
|
+
# Result::
|
36
|
+
# * Object: The value for this key
|
37
|
+
def fetch(key, options = {})
|
38
|
+
context[key] = {} unless context.key?(key)
|
39
|
+
key_context = context[key]
|
40
|
+
delete(key) if options[:invalidate_if]&.call(key, options, key_context)
|
41
|
+
return read(key) if exist?(key)
|
42
|
+
|
43
|
+
value = yield
|
44
|
+
options[:update_context_after_fetch]&.call(key, value, options, key_context)
|
45
|
+
write(key, value)
|
46
|
+
end
|
47
|
+
|
48
|
+
# Clear the cache
|
49
|
+
def clear
|
50
|
+
@context = {}
|
51
|
+
super
|
52
|
+
end
|
53
|
+
|
54
|
+
private
|
55
|
+
|
56
|
+
attr_reader :context
|
57
|
+
|
58
|
+
end
|
59
|
+
|
60
|
+
end
|
61
|
+
|
62
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
class NexusMods
|
2
|
+
|
3
|
+
module CoreExtensions
|
4
|
+
|
5
|
+
module Cacheable
|
6
|
+
|
7
|
+
# Make the cacheable method generators compatible with methods having kwargs
|
8
|
+
# TODO: Remove this core extension when cacheable will be compatible with kwargs
|
9
|
+
module MethodGenerator
|
10
|
+
|
11
|
+
private
|
12
|
+
|
13
|
+
# Create all methods to allow cacheable functionality, for a given original method name
|
14
|
+
#
|
15
|
+
# Parameters::
|
16
|
+
# * *original_method_name* (Symbol): The original method name
|
17
|
+
# * *opts* (Hash): The options given to the cacheable call
|
18
|
+
def create_cacheable_methods(original_method_name, opts = {})
|
19
|
+
method_names = create_method_names(original_method_name)
|
20
|
+
key_format_proc = opts[:key_format] || default_key_format
|
21
|
+
|
22
|
+
const_get(method_interceptor_module_name).class_eval do
|
23
|
+
define_method(method_names[:key_format_method_name]) do |*args, **kwargs|
|
24
|
+
key_format_proc.call(self, original_method_name, args, kwargs)
|
25
|
+
end
|
26
|
+
|
27
|
+
define_method(method_names[:clear_cache_method_name]) do |*args, **kwargs|
|
28
|
+
::Cacheable.cache_adapter.delete(__send__(method_names[:key_format_method_name], *args, **kwargs))
|
29
|
+
end
|
30
|
+
|
31
|
+
define_method(method_names[:without_cache_method_name]) do |*args, **kwargs|
|
32
|
+
original_method = method(original_method_name).super_method
|
33
|
+
original_method.call(*args, **kwargs)
|
34
|
+
end
|
35
|
+
|
36
|
+
define_method(method_names[:with_cache_method_name]) do |*args, **kwargs|
|
37
|
+
::Cacheable.cache_adapter.fetch(__send__(method_names[:key_format_method_name], *args, **kwargs), opts[:cache_options]) do
|
38
|
+
__send__(method_names[:without_cache_method_name], *args, **kwargs)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
define_method(original_method_name) do |*args, **kwargs|
|
43
|
+
unless_proc = opts[:unless].is_a?(Symbol) ? opts[:unless].to_proc : opts[:unless]
|
44
|
+
|
45
|
+
if unless_proc&.call(self, original_method_name, args)
|
46
|
+
__send__(method_names[:without_cache_method_name], *args, **kwargs)
|
47
|
+
else
|
48
|
+
__send__(method_names[:with_cache_method_name], *args, **kwargs)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
end
|
55
|
+
|
56
|
+
end
|
57
|
+
|
58
|
+
end
|
59
|
+
|
60
|
+
end
|
61
|
+
|
62
|
+
Cacheable::MethodGenerator.prepend NexusMods::CoreExtensions::Cacheable::MethodGenerator
|
@@ -0,0 +1,71 @@
|
|
1
|
+
class NexusMods
|
2
|
+
|
3
|
+
# Simple key/value file cache
|
4
|
+
class FileCache
|
5
|
+
|
6
|
+
# Constructor
|
7
|
+
#
|
8
|
+
# Parameters::
|
9
|
+
# * *file* (String): File to use as a cache
|
10
|
+
def initialize(file)
|
11
|
+
@file = file
|
12
|
+
@cache_content = File.exist?(file) ? JSON.parse(File.read(file)) : {}
|
13
|
+
end
|
14
|
+
|
15
|
+
# Dump the cache in file
|
16
|
+
def dump
|
17
|
+
File.write(@file, @cache_content.to_json)
|
18
|
+
end
|
19
|
+
|
20
|
+
# Get the cache content as a Hash
|
21
|
+
#
|
22
|
+
# Result::
|
23
|
+
# * Hash<String, Object>: Cache content
|
24
|
+
def to_h
|
25
|
+
@cache_content
|
26
|
+
end
|
27
|
+
|
28
|
+
# Is a given key present in the cache?
|
29
|
+
#
|
30
|
+
# Parameters::
|
31
|
+
# * *key* (String): The key
|
32
|
+
# Result::
|
33
|
+
# * Boolean: Is a given key present in the cache?
|
34
|
+
def key?(key)
|
35
|
+
@cache_content.key?(key)
|
36
|
+
end
|
37
|
+
|
38
|
+
# Read a key from the cache
|
39
|
+
#
|
40
|
+
# Parameters:
|
41
|
+
# * *key* (String): The cache key
|
42
|
+
# Result::
|
43
|
+
# * Object or nil: JSON-serializable object storing the value, or nil in case of cache-miss
|
44
|
+
def read(key)
|
45
|
+
@cache_content.key?(key) ? @cache_content[key] : nil
|
46
|
+
end
|
47
|
+
|
48
|
+
alias [] read
|
49
|
+
|
50
|
+
# Write a key/value in the cache
|
51
|
+
#
|
52
|
+
# Parameters:
|
53
|
+
# * *key* (String): The key
|
54
|
+
# * *value* (Object): JSON-serializable object storing the value
|
55
|
+
def write(key, value)
|
56
|
+
@cache_content[key] = value
|
57
|
+
end
|
58
|
+
|
59
|
+
alias []= write
|
60
|
+
|
61
|
+
# Delete a key in the cache
|
62
|
+
#
|
63
|
+
# Parameters:
|
64
|
+
# * *key* (String): The key
|
65
|
+
def delete(key)
|
66
|
+
@cache_content.delete(key)
|
67
|
+
end
|
68
|
+
|
69
|
+
end
|
70
|
+
|
71
|
+
end
|
data/lib/nexus_mods/version.rb
CHANGED
data/lib/nexus_mods.rb
CHANGED
@@ -1,8 +1,7 @@
|
|
1
|
-
require 'addressable/uri'
|
2
1
|
require 'json'
|
3
2
|
require 'time'
|
4
3
|
require 'tmpdir'
|
5
|
-
require '
|
4
|
+
require 'nexus_mods/api_client'
|
6
5
|
require 'nexus_mods/api/api_limits'
|
7
6
|
require 'nexus_mods/api/category'
|
8
7
|
require 'nexus_mods/api/game'
|
@@ -40,27 +39,36 @@ class NexusMods
|
|
40
39
|
# * *game_domain_name* (String): Game domain name to query by default [default: 'skyrimspecialedition']
|
41
40
|
# * *mod_id* (Integer): Mod to query by default [default: 1]
|
42
41
|
# * *file_id* (Integer): File to query by default [default: 1]
|
42
|
+
# * *http_cache_file* (String): File used to store the HTTP cache, or nil for no cache [default: "#{Dir.tmpdir}/nexus_mods_http_cache.json"]
|
43
|
+
# * *api_cache_expiry* (Hash<Symbol,Integer>): Expiry times in seconds, per expiry key. Possible keys are:
|
44
|
+
# * *games*: Expiry associated to queries on games [default: 1 day]
|
45
|
+
# * *mod*: Expiry associated to queries on mod [default: 1 day]
|
46
|
+
# * *mod_files*: Expiry associated to queries on mod files [default: 1 day]
|
43
47
|
# * *logger* (Logger): The logger to be used for log messages [default: Logger.new(STDOUT)]
|
44
48
|
def initialize(
|
45
49
|
api_key: nil,
|
46
50
|
game_domain_name: 'skyrimspecialedition',
|
47
51
|
mod_id: 1,
|
48
52
|
file_id: 1,
|
53
|
+
http_cache_file: "#{Dir.tmpdir}/nexus_mods_http_cache.json",
|
54
|
+
api_cache_expiry: {},
|
49
55
|
logger: Logger.new($stdout)
|
50
56
|
)
|
51
|
-
@api_key = api_key
|
52
57
|
@game_domain_name = game_domain_name
|
53
58
|
@mod_id = mod_id
|
54
59
|
@file_id = file_id
|
55
60
|
@logger = logger
|
56
61
|
@premium = false
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
62
|
+
@api_client = ApiClient.new(
|
63
|
+
api_key:,
|
64
|
+
http_cache_file:,
|
65
|
+
api_cache_expiry:,
|
66
|
+
logger:
|
67
|
+
)
|
68
|
+
|
61
69
|
# Check that the key is correct and know if the user is premium
|
62
70
|
begin
|
63
|
-
@premium = api('users/validate')['is_premium?']
|
71
|
+
@premium = @api_client.api('users/validate')['is_premium?']
|
64
72
|
rescue LimitsExceededError
|
65
73
|
raise
|
66
74
|
rescue ApiError
|
@@ -74,7 +82,7 @@ class NexusMods
|
|
74
82
|
# Result::
|
75
83
|
# * ApiLimits: API calls limits
|
76
84
|
def api_limits
|
77
|
-
api_limits_headers = http('users/validate').headers
|
85
|
+
api_limits_headers = @api_client.http('users/validate').headers
|
78
86
|
Api::ApiLimits.new(
|
79
87
|
daily_limit: Integer(api_limits_headers['x-rl-daily-limit']),
|
80
88
|
daily_remaining: Integer(api_limits_headers['x-rl-daily-remaining']),
|
@@ -90,7 +98,7 @@ class NexusMods
|
|
90
98
|
# Result::
|
91
99
|
# * Array<Game>: List of games
|
92
100
|
def games
|
93
|
-
api('games').map do |game_json|
|
101
|
+
@api_client.api('games').map do |game_json|
|
94
102
|
# First create categories tree
|
95
103
|
# Hash<Integer, [Category, Integer]>: Category and its parent category id, per category id
|
96
104
|
categories = game_json['categories'].to_h do |category_json|
|
@@ -137,7 +145,7 @@ class NexusMods
|
|
137
145
|
# Result::
|
138
146
|
# * Mod: Mod information
|
139
147
|
def mod(game_domain_name: @game_domain_name, mod_id: @mod_id)
|
140
|
-
mod_json = api "games/#{game_domain_name}/mods/#{mod_id}"
|
148
|
+
mod_json = @api_client.api "games/#{game_domain_name}/mods/#{mod_id}"
|
141
149
|
Api::Mod.new(
|
142
150
|
uid: mod_json['uid'],
|
143
151
|
mod_id: mod_json['mod_id'],
|
@@ -185,7 +193,7 @@ class NexusMods
|
|
185
193
|
# Result::
|
186
194
|
# * Array<ModFile>: List of mod's files
|
187
195
|
def mod_files(game_domain_name: @game_domain_name, mod_id: @mod_id)
|
188
|
-
api("games/#{game_domain_name}/mods/#{mod_id}/files")['files'].map do |file_json|
|
196
|
+
@api_client.api("games/#{game_domain_name}/mods/#{mod_id}/files")['files'].map do |file_json|
|
189
197
|
category_id = FILE_CATEGORIES[file_json['category_id']]
|
190
198
|
raise "Unknown file category: #{file_json['category_id']}" if category_id.nil?
|
191
199
|
|
@@ -210,66 +218,4 @@ class NexusMods
|
|
210
218
|
end
|
211
219
|
end
|
212
220
|
|
213
|
-
private
|
214
|
-
|
215
|
-
# Send an HTTP request to the API and get back the answer as a JSON
|
216
|
-
#
|
217
|
-
# Parameters::
|
218
|
-
# * *path* (String): API path to contact (from v1/ and without .json)
|
219
|
-
# * *verb* (Symbol): Verb to be used (:get, :post...) [default: :get]
|
220
|
-
# Result::
|
221
|
-
# * Object: The JSON response
|
222
|
-
def api(path, verb: :get)
|
223
|
-
res = http(path, verb:)
|
224
|
-
json = JSON.parse(res.body)
|
225
|
-
uri = api_uri(path)
|
226
|
-
@logger.debug "[API call] - #{verb} #{uri} => #{res.status}\n#{
|
227
|
-
JSON.
|
228
|
-
pretty_generate(json).
|
229
|
-
split("\n").
|
230
|
-
map { |line| " #{line}" }.
|
231
|
-
join("\n")
|
232
|
-
}\n#{
|
233
|
-
res.
|
234
|
-
headers.
|
235
|
-
map { |header, value| " #{header}: #{value}" }.
|
236
|
-
join("\n")
|
237
|
-
}"
|
238
|
-
case res.status
|
239
|
-
when 200
|
240
|
-
# Happy
|
241
|
-
when 429
|
242
|
-
# Some limits of the API have been reached
|
243
|
-
raise LimitsExceededError, "Exceeding limits of API calls: #{res.headers.select { |header, _value| header =~ /^x-rl-.+$/ }}"
|
244
|
-
else
|
245
|
-
raise ApiError, "API #{uri} returned error code #{res.status}" unless res.status == '200'
|
246
|
-
end
|
247
|
-
json
|
248
|
-
end
|
249
|
-
|
250
|
-
# Send an HTTP request to the API and get back the HTTP response
|
251
|
-
#
|
252
|
-
# Parameters::
|
253
|
-
# * *path* (String): API path to contact (from v1/ and without .json)
|
254
|
-
# * *verb* (Symbol): Verb to be used (:get, :post...) [default: :get]
|
255
|
-
# Result::
|
256
|
-
# * Faraday::Response: The HTTP response
|
257
|
-
def http(path, verb: :get)
|
258
|
-
@http_client.send(verb) do |req|
|
259
|
-
req.url api_uri(path)
|
260
|
-
req.headers['apikey'] = @api_key
|
261
|
-
req.headers['User-Agent'] = "nexus_mods (#{RUBY_PLATFORM}) Ruby/#{RUBY_VERSION}"
|
262
|
-
end
|
263
|
-
end
|
264
|
-
|
265
|
-
# Get the real URI to query for a given API path
|
266
|
-
#
|
267
|
-
# Parameters::
|
268
|
-
# * *path* (String): API path to contact (from v1/ and without .json)
|
269
|
-
# Result::
|
270
|
-
# * String: The URI
|
271
|
-
def api_uri(path)
|
272
|
-
"https://api.nexusmods.com/v1/#{path}.json"
|
273
|
-
end
|
274
|
-
|
275
221
|
end
|
@@ -145,6 +145,8 @@ RSpec.configure do |config|
|
|
145
145
|
config.include NexusModsTest::Factories::ModFiles
|
146
146
|
config.before do
|
147
147
|
@nexus_mods = nil
|
148
|
+
# Reload the ApiClient as it stores caches at class level
|
149
|
+
NexusMods::ApiClient.clear_cacheable_expiry_caches
|
148
150
|
# Keep a list of the etags we should have returned, so that we know when queries should contain them
|
149
151
|
# Array<String>
|
150
152
|
@expected_returned_etags = []
|
@@ -152,6 +154,11 @@ RSpec.configure do |config|
|
|
152
154
|
# Array< [ WebMock::RequestStub, Integer ] >
|
153
155
|
@expected_stubs = []
|
154
156
|
end
|
157
|
+
config.after do
|
158
|
+
@expected_stubs.each do |(stub, times)|
|
159
|
+
expect(stub).to have_been_made.times(times)
|
160
|
+
end
|
161
|
+
end
|
155
162
|
end
|
156
163
|
|
157
164
|
# Set a bigger output length for expectation errors, as most error messages include API keys and headers which can be lengthy
|
@@ -0,0 +1,88 @@
|
|
1
|
+
describe NexusMods do
|
2
|
+
|
3
|
+
context 'when testing caching' do
|
4
|
+
|
5
|
+
it 'does not cache user queries' do
|
6
|
+
expect_validate_user(times: 3)
|
7
|
+
nexus_mods.api_limits
|
8
|
+
nexus_mods.api_limits
|
9
|
+
end
|
10
|
+
|
11
|
+
it 'caches games queries' do
|
12
|
+
expect_validate_user
|
13
|
+
expect_http_call_to(
|
14
|
+
path: '/v1/games.json',
|
15
|
+
json: [
|
16
|
+
json_game100,
|
17
|
+
json_game101
|
18
|
+
]
|
19
|
+
)
|
20
|
+
games = nexus_mods.games
|
21
|
+
expect(nexus_mods.games).to eq(games)
|
22
|
+
end
|
23
|
+
|
24
|
+
it 'caches mod queries' do
|
25
|
+
expect_validate_user
|
26
|
+
expect_http_call_to(
|
27
|
+
path: '/v1/games/skyrimspecialedition/mods/2014.json',
|
28
|
+
json: json_complete_mod
|
29
|
+
)
|
30
|
+
mod = nexus_mods.mod(game_domain_name: 'skyrimspecialedition', mod_id: 2014)
|
31
|
+
expect(nexus_mods.mod(game_domain_name: 'skyrimspecialedition', mod_id: 2014)).to eq(mod)
|
32
|
+
end
|
33
|
+
|
34
|
+
it 'caches mod files queries' do
|
35
|
+
expect_validate_user
|
36
|
+
expect_http_call_to(
|
37
|
+
path: '/v1/games/skyrimspecialedition/mods/2014/files.json',
|
38
|
+
json: { files: [json_mod_file2472, json_mod_file2487] }
|
39
|
+
)
|
40
|
+
mod_files = nexus_mods.mod_files(game_domain_name: 'skyrimspecialedition', mod_id: 2014)
|
41
|
+
expect(nexus_mods.mod_files(game_domain_name: 'skyrimspecialedition', mod_id: 2014)).to eq(mod_files)
|
42
|
+
end
|
43
|
+
|
44
|
+
it 'expires games queries cache' do
|
45
|
+
expect_validate_user
|
46
|
+
expect_http_call_to(
|
47
|
+
path: '/v1/games.json',
|
48
|
+
json: [
|
49
|
+
json_game100,
|
50
|
+
json_game101
|
51
|
+
],
|
52
|
+
times: 2
|
53
|
+
)
|
54
|
+
nexus_mods_instance = nexus_mods(api_cache_expiry: { games: 1 })
|
55
|
+
games = nexus_mods_instance.games
|
56
|
+
sleep 2
|
57
|
+
expect(nexus_mods_instance.games).to eq(games)
|
58
|
+
end
|
59
|
+
|
60
|
+
it 'expires mod queries cache' do
|
61
|
+
expect_validate_user
|
62
|
+
expect_http_call_to(
|
63
|
+
path: '/v1/games/skyrimspecialedition/mods/2014.json',
|
64
|
+
json: json_complete_mod,
|
65
|
+
times: 2
|
66
|
+
)
|
67
|
+
nexus_mods_instance = nexus_mods(api_cache_expiry: { mod: 1 })
|
68
|
+
mod = nexus_mods_instance.mod(game_domain_name: 'skyrimspecialedition', mod_id: 2014)
|
69
|
+
sleep 2
|
70
|
+
expect(nexus_mods_instance.mod(game_domain_name: 'skyrimspecialedition', mod_id: 2014)).to eq(mod)
|
71
|
+
end
|
72
|
+
|
73
|
+
it 'expires mod files queries cache' do
|
74
|
+
expect_validate_user
|
75
|
+
expect_http_call_to(
|
76
|
+
path: '/v1/games/skyrimspecialedition/mods/2014/files.json',
|
77
|
+
json: { files: [json_mod_file2472, json_mod_file2487] },
|
78
|
+
times: 2
|
79
|
+
)
|
80
|
+
nexus_mods_instance = nexus_mods(api_cache_expiry: { mod_files: 1 })
|
81
|
+
mod_files = nexus_mods_instance.mod_files(game_domain_name: 'skyrimspecialedition', mod_id: 2014)
|
82
|
+
sleep 2
|
83
|
+
expect(nexus_mods_instance.mod_files(game_domain_name: 'skyrimspecialedition', mod_id: 2014)).to eq(mod_files)
|
84
|
+
end
|
85
|
+
|
86
|
+
end
|
87
|
+
|
88
|
+
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: nexus_mods
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.3.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Muriel Salvan
|
@@ -38,6 +38,20 @@ dependencies:
|
|
38
38
|
- - "~>"
|
39
39
|
- !ruby/object:Gem::Version
|
40
40
|
version: '2.4'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: cacheable
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '2.0'
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '2.0'
|
41
55
|
- !ruby/object:Gem::Dependency
|
42
56
|
name: rspec
|
43
57
|
requirement: !ruby/object:Gem::Requirement
|
@@ -128,6 +142,12 @@ files:
|
|
128
142
|
- lib/nexus_mods/api/mod.rb
|
129
143
|
- lib/nexus_mods/api/mod_file.rb
|
130
144
|
- lib/nexus_mods/api/user.rb
|
145
|
+
- lib/nexus_mods/api_client.rb
|
146
|
+
- lib/nexus_mods/cacheable_api.rb
|
147
|
+
- lib/nexus_mods/cacheable_with_expiry.rb
|
148
|
+
- lib/nexus_mods/core_extensions/cacheable/cache_adapters/persistent_json_adapter.rb
|
149
|
+
- lib/nexus_mods/core_extensions/cacheable/method_generator.rb
|
150
|
+
- lib/nexus_mods/file_cache.rb
|
131
151
|
- lib/nexus_mods/version.rb
|
132
152
|
- spec/nexus_mods_test/factories/games.rb
|
133
153
|
- spec/nexus_mods_test/factories/mod_files.rb
|
@@ -138,6 +158,7 @@ files:
|
|
138
158
|
- spec/nexus_mods_test/scenarios/nexus_mods/api/mod_file_spec.rb
|
139
159
|
- spec/nexus_mods_test/scenarios/nexus_mods/api/mod_spec.rb
|
140
160
|
- spec/nexus_mods_test/scenarios/nexus_mods_access_spec.rb
|
161
|
+
- spec/nexus_mods_test/scenarios/nexus_mods_caching_spec.rb
|
141
162
|
- spec/nexus_mods_test/scenarios/rubocop_spec.rb
|
142
163
|
- spec/spec_helper.rb
|
143
164
|
homepage: https://github.com/Muriel-Salvan/nexus_mods
|