nexus_mods 0.2.0 → 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/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
|