nexus_mods 0.3.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7858789972e8674c6ba24c53d36e4af87b09b8636e84faced240ff0edd1eb3fa
4
- data.tar.gz: 29df7fc4849e885e6a9aa705d1ae4868215e1181bdff8814d806787ff948790d
3
+ metadata.gz: 79e4d81970dd1549db43105a2a155b6b275692df4f06827e2234ab02fce6276b
4
+ data.tar.gz: 4d96df8a39784c8a2eb6fb9bd72a247f3cafa2765aa03059dfc661afd9db7a38
5
5
  SHA512:
6
- metadata.gz: 522c72b5d4cabff4d57609f9c593c5e75345c675343397e7e75c0bc10c7b29c8df72c26d7fa51bc73265bb8f0c025d3b929ef5a1810711928f416b851bedfef9
7
- data.tar.gz: 17b804dfc30795f57c5e862a4990b39aec762f0b3058513956204443e2ebfe59df5285ceda0634a6644cdcd8e45619a8248964bf3028b2aa96852cbc4dd69f9f
6
+ metadata.gz: 9c13d188694e09dc06998846791322a4887bbb5163ff634d5e6f813abca187a6a2dc8aeb102e040b43812d6276feb6c1ee59665c5e5e408c6acbff4dca0539b3
7
+ data.tar.gz: 3e37eb153f00bb6335efb8e0b377065f4b0613cbef46066a1a5d7095b1e1767b1786d59f28681298e75be49e1cfeb739ea6ce2f7b13fefc6b28362b27d7536b5
data/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ # [v0.4.0](https://github.com/Muriel-Salvan/nexus_mods/compare/v0.3.0...v0.4.0) (2023-04-10 10:15:41)
2
+
3
+ ### Features
4
+
5
+ * [[Feature] Add persistent API caching in files](https://github.com/Muriel-Salvan/nexus_mods/commit/f124ecf58b0f478ecdca5f6ad2309779d1ab67ac)
6
+
1
7
  # [v0.3.0](https://github.com/Muriel-Salvan/nexus_mods/compare/v0.2.0...v0.3.0) (2023-04-10 09:13:43)
2
8
 
3
9
  ### Features
@@ -12,6 +12,13 @@ class NexusMods
12
12
 
13
13
  include CacheableApi
14
14
 
15
+ # Default expiry times, in seconds
16
+ DEFAULT_API_CACHE_EXPIRY = {
17
+ games: 24 * 60 * 60,
18
+ mod: 24 * 60 * 60,
19
+ mod_files: 24 * 60 * 60
20
+ }
21
+
15
22
  # Constructor
16
23
  #
17
24
  # Parameters::
@@ -31,8 +38,9 @@ class NexusMods
31
38
  logger: Logger.new($stdout)
32
39
  )
33
40
  @api_key = api_key
34
- ApiClient.api_cache_expiry = ApiClient.api_cache_expiry.merge(api_cache_expiry)
41
+ @api_cache_expiry = DEFAULT_API_CACHE_EXPIRY.merge(api_cache_expiry)
35
42
  @api_cache_file = api_cache_file
43
+ ApiClient.api_client = self
36
44
  @logger = logger
37
45
  # Initialize our HTTP client
38
46
  @http_cache = http_cache_file.nil? ? nil : FileCache.new(http_cache_file)
@@ -96,15 +104,15 @@ class NexusMods
96
104
  case key_components[0]
97
105
  when 'games'
98
106
  if key_components[1].nil?
99
- ApiClient.api_cache_expiry[:games]
107
+ ApiClient.api_client.api_cache_expiry[:games]
100
108
  else
101
109
  case key_components[2]
102
110
  when 'mods'
103
111
  case key_components[4]
104
112
  when nil
105
- ApiClient.api_cache_expiry[:mod]
113
+ ApiClient.api_client.api_cache_expiry[:mod]
106
114
  when 'files'
107
- ApiClient.api_cache_expiry[:mod_files]
115
+ ApiClient.api_client.api_cache_expiry[:mod_files]
108
116
  else
109
117
  raise "Unknown API path: #{key}"
110
118
  end
@@ -118,6 +126,9 @@ class NexusMods
118
126
  else
119
127
  raise "Unknown API path: #{key}"
120
128
  end
129
+ end,
130
+ on_cache_update: proc do
131
+ ApiClient.api_client.save_api_cache
121
132
  end
122
133
  )
123
134
 
@@ -146,26 +157,22 @@ class NexusMods
146
157
  return unless @api_cache_file
147
158
 
148
159
  FileUtils.mkdir_p(File.dirname(@api_cache_file))
149
- Cacheable.cache_adapter.dump(@api_cache_file)
160
+ Cacheable.cache_adapter.save(@api_cache_file)
150
161
  end
151
162
 
163
+ # Some attributes exposed for the cacheable feature to work
164
+ attr_reader :api_cache_expiry
165
+
152
166
  private
153
167
 
154
168
  class << self
155
169
 
156
- # Hash<Symbol,Integer>: Expiry time, per expiry key
157
- attr_accessor :api_cache_expiry
170
+ # ApiClient: The API client to be used by the cacheable adapter (singleton pattern)
171
+ attr_accessor :api_client
158
172
 
159
173
  end
160
174
 
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
175
+ @api_client = nil
169
176
 
170
177
  # Get the real URI to query for a given API path
171
178
  #
@@ -28,7 +28,8 @@ class NexusMods
28
28
  # * *key* (String): The key for which we want the expiry time in seconds
29
29
  # * Result::
30
30
  # * Integer: Corresponding expiry time
31
- def cacheable_api(*original_method_names, expiry_from_key:)
31
+ # * *on_cache_update* (Proc): Proc called when the cache has been updated
32
+ def cacheable_api(*original_method_names, expiry_from_key:, on_cache_update:)
32
33
  cacheable_with_expiry(
33
34
  *original_method_names,
34
35
  key_format: lambda do |target, method_name, method_args, method_kwargs|
@@ -41,7 +42,12 @@ class NexusMods
41
42
  method_kwargs.map { |key, value| "#{key}:#{value}" }
42
43
  ).join('/')
43
44
  end,
44
- expiry_from_key:
45
+ expiry_from_key:,
46
+ cache_options: {
47
+ on_cache_update: proc do |_adapter, _key, _value, _options, _context|
48
+ on_cache_update.call
49
+ end
50
+ }
45
51
  )
46
52
  end
47
53
 
@@ -34,19 +34,21 @@ class NexusMods
34
34
  *original_method_names,
35
35
  **opts.merge(
36
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']
37
+ cache_options: (opts[:cache_options] || {}).merge(
38
+ {
39
+ expiry_from_key: opts[:expiry_from_key],
40
+ invalidate_if: proc do |key, options, context|
41
+ next true unless context['invalidate_time']
41
42
 
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
- }
43
+ # Find if we know already the expiry for this key
44
+ expiry_cache[key] = options[:expiry_from_key].call(key) unless expiry_cache.key?(key)
45
+ expiry_cache[key].nil? || (Time.now.utc - Time.parse(context['invalidate_time']).utc > expiry_cache[key])
46
+ end,
47
+ update_context_after_fetch: proc do |_key, _value, _options, context|
48
+ context['invalidate_time'] = Time.now.utc.strftime('%FT%TUTC')
49
+ end
50
+ }
51
+ )
50
52
  }
51
53
  )
52
54
  )
@@ -18,19 +18,26 @@ module Cacheable
18
18
  # Parameters::
19
19
  # * *key* (String): Key to be fetched
20
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:
21
+ # * *invalidate_if* (Proc or nil): Optional code called to know if the cache should be invalidated for a given key:
22
22
  # * Parameters::
23
23
  # * *key* (String): The key for which we check the cache invalidation
24
24
  # * *options* (Hash): Cache options linked to this key
25
25
  # * *key_context* (Hash): Context linked to this key, that can be set using the update_context_after_fetch callback.
26
26
  # * Result::
27
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
28
+ # * *update_context_after_fetch* (Proc or nil): Optional code called when the value has been fetched for real (without cache), used to update the context
29
29
  # * Parameters::
30
30
  # * *key* (String): The key for which we just fetched the value
31
31
  # * *value* (Object): The value that has just been fetched
32
32
  # * *options* (Hash): Cache options linked to this key
33
33
  # * *key_context* (Hash): Context linked to this key, that is supposed to be updated in place by this callback
34
+ # * *on_cache_update* (Proc or nil): Optional code called once the cache has been updated
35
+ # * Parameters::
36
+ # * *adapter* (Object): Adapter that has the cache being updated
37
+ # * *key* (String): The key for which we just fetched the value
38
+ # * *value* (Object): The value that has just been fetched
39
+ # * *options* (Hash): Cache options linked to this key
40
+ # * *key_context* (Hash): Context linked to this key
34
41
  # * CodeBlock: Code called to fetch the value if not in the cache
35
42
  # Result::
36
43
  # * Object: The value for this key
@@ -43,6 +50,8 @@ module Cacheable
43
50
  value = yield
44
51
  options[:update_context_after_fetch]&.call(key, value, options, key_context)
45
52
  write(key, value)
53
+ options[:on_cache_update]&.call(self, key, value, options, key_context)
54
+ value
46
55
  end
47
56
 
48
57
  # Clear the cache
@@ -51,6 +60,33 @@ module Cacheable
51
60
  super
52
61
  end
53
62
 
63
+ # Save the cache and context into a JSON file
64
+ #
65
+ # Parameters::
66
+ # * *file* (String): The file to save to
67
+ def save(file)
68
+ # Remove from the context the keys that are not in the cache
69
+ File.write(
70
+ file,
71
+ JSON.dump(
72
+ {
73
+ 'cache' => cache,
74
+ 'context' => context.select { |key, _value| @cache.key?(key) }
75
+ }
76
+ )
77
+ )
78
+ end
79
+
80
+ # Load the cache and context from a JSON file
81
+ #
82
+ # Parameters::
83
+ # * *file* (String): The file to load from
84
+ def load(file)
85
+ loaded_content = JSON.parse(File.read(file))
86
+ @cache = loaded_content['cache']
87
+ @context = loaded_content['context']
88
+ end
89
+
54
90
  private
55
91
 
56
92
  attr_reader :context
@@ -1,5 +1,5 @@
1
1
  class NexusMods
2
2
 
3
- VERSION = '0.3.0'
3
+ VERSION = '0.4.0'
4
4
 
5
5
  end
data/lib/nexus_mods.rb CHANGED
@@ -44,6 +44,7 @@ class NexusMods
44
44
  # * *games*: Expiry associated to queries on games [default: 1 day]
45
45
  # * *mod*: Expiry associated to queries on mod [default: 1 day]
46
46
  # * *mod_files*: Expiry associated to queries on mod files [default: 1 day]
47
+ # * *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"]
47
48
  # * *logger* (Logger): The logger to be used for log messages [default: Logger.new(STDOUT)]
48
49
  def initialize(
49
50
  api_key: nil,
@@ -52,6 +53,7 @@ class NexusMods
52
53
  file_id: 1,
53
54
  http_cache_file: "#{Dir.tmpdir}/nexus_mods_http_cache.json",
54
55
  api_cache_expiry: {},
56
+ api_cache_file: "#{Dir.tmpdir}/nexus_mods_api_cache.json",
55
57
  logger: Logger.new($stdout)
56
58
  )
57
59
  @game_domain_name = game_domain_name
@@ -63,6 +65,7 @@ class NexusMods
63
65
  api_key:,
64
66
  http_cache_file:,
65
67
  api_cache_expiry:,
68
+ api_cache_file:,
66
69
  logger:
67
70
  )
68
71
 
@@ -55,6 +55,9 @@ module NexusModsTest
55
55
  def nexus_mods(**args)
56
56
  if @nexus_mods.nil?
57
57
  args[:api_key] = MOCKED_API_KEY unless args.key?(:api_key)
58
+ # By default running tests should not persistent cache files
59
+ args[:http_cache_file] = nil unless args.key?(:http_cache_file)
60
+ args[:api_cache_file] = nil unless args.key?(:api_cache_file)
58
61
  # Redirect any log into a string so that they don't pollute the tests output and they could be asserted.
59
62
  nexus_mods_logger = StringIO.new
60
63
  args[:logger] = Logger.new(nexus_mods_logger)
@@ -134,6 +137,19 @@ module NexusModsTest
134
137
  )
135
138
  end
136
139
 
140
+ # Setup an API cache file to be used (does not create it)
141
+ #
142
+ # Parameters::
143
+ # * CodeBlock: The code called when the API cache file is reserved
144
+ # * Parameters::
145
+ # * *api_cache_file* (String): File name to be used for the API cache file
146
+ def with_api_cache_file
147
+ api_cache_file = "#{Dir.tmpdir}/nexus_mods_test/api_cache.json"
148
+ FileUtils.mkdir_p(File.dirname(api_cache_file))
149
+ FileUtils.rm_f(api_cache_file)
150
+ yield api_cache_file
151
+ end
152
+
137
153
  end
138
154
 
139
155
  end
@@ -1,3 +1,5 @@
1
+ require 'fileutils'
2
+
1
3
  describe NexusMods do
2
4
 
3
5
  context 'when testing caching' do
@@ -83,6 +85,73 @@ describe NexusMods do
83
85
  expect(nexus_mods_instance.mod_files(game_domain_name: 'skyrimspecialedition', mod_id: 2014)).to eq(mod_files)
84
86
  end
85
87
 
88
+ context 'with file persistence' do
89
+
90
+ it 'persists API cache in a file' do
91
+ with_api_cache_file do |api_cache_file|
92
+ expect_validate_user
93
+ expect_http_call_to(
94
+ path: '/v1/games.json',
95
+ json: [
96
+ json_game100,
97
+ json_game101
98
+ ]
99
+ )
100
+ nexus_mods(api_cache_file:).games
101
+ expect(File.exist?(api_cache_file)).to be true
102
+ expect(File.size(api_cache_file)).to be > 0
103
+ end
104
+ end
105
+
106
+ it 'uses API cache from a file' do
107
+ with_api_cache_file do |api_cache_file|
108
+ expect_validate_user(times: 2)
109
+ expect_http_call_to(
110
+ path: '/v1/games.json',
111
+ json: [
112
+ json_game100,
113
+ json_game101
114
+ ]
115
+ )
116
+ # Generate the cache first
117
+ games = nexus_mods(api_cache_file:).games
118
+ # Force a new instance of NexusMods API to run
119
+ @nexus_mods = nil
120
+ expect(nexus_mods(api_cache_file:).games).to eq games
121
+ end
122
+ end
123
+
124
+ it 'completes the API cache from a file' do
125
+ with_api_cache_file do |api_cache_file|
126
+ expect_validate_user(times: 3)
127
+ # Generate the cache first for games only
128
+ expect_http_call_to(
129
+ path: '/v1/games.json',
130
+ json: [
131
+ json_game100,
132
+ json_game101
133
+ ]
134
+ )
135
+ games = nexus_mods(api_cache_file:).games
136
+ # Force a new instance of NexusMods API to run
137
+ @nexus_mods = nil
138
+ # Complete the cache with a mod
139
+ expect_http_call_to(
140
+ path: '/v1/games/skyrimspecialedition/mods/2014.json',
141
+ json: json_complete_mod
142
+ )
143
+ mod = nexus_mods(api_cache_file:).mod(game_domain_name: 'skyrimspecialedition', mod_id: 2014)
144
+ # Force a new instance of NexusMods API to run
145
+ @nexus_mods = nil
146
+ # Check that both API calls were cached correctly
147
+ nexus_mods_instance = nexus_mods(api_cache_file:)
148
+ expect(nexus_mods_instance.games).to eq games
149
+ expect(nexus_mods_instance.mod(game_domain_name: 'skyrimspecialedition', mod_id: 2014)).to eq mod
150
+ end
151
+ end
152
+
153
+ end
154
+
86
155
  end
87
156
 
88
157
  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.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Muriel Salvan