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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: efdb25aee2a65f563017933ab77b1360d3ab04d26abf3f945cd9c1cf3d0a1a0d
4
- data.tar.gz: dc538a350901669fbca2e6cbc299daf091bcf0779f82c3e9b718398b5f61e877
3
+ metadata.gz: 7858789972e8674c6ba24c53d36e4af87b09b8636e84faced240ff0edd1eb3fa
4
+ data.tar.gz: 29df7fc4849e885e6a9aa705d1ae4868215e1181bdff8814d806787ff948790d
5
5
  SHA512:
6
- metadata.gz: 6e2f2ee9025ab0650fc96ad4d10ff41e8375caa8c6ab51e64ceec5527630e97806bfdbce74d2db2ba63bc58a45371392c98d4fb20ccbd7399b2711123fce125f
7
- data.tar.gz: a6519240cb31bc1cf25a5f0b7001be67e91b41588817739f50a8c8b4d1558a2129ecb43ba6239454717fe47d6aea47f152eeb08c374bce18c83fe84003a378fd
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
@@ -1,5 +1,5 @@
1
1
  class NexusMods
2
2
 
3
- VERSION = '0.2.0'
3
+ VERSION = '0.3.0'
4
4
 
5
5
  end
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 'faraday'
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
- # Initialize our HTTP client
58
- @http_client = Faraday.new do |builder|
59
- builder.adapter Faraday.default_adapter
60
- end
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.2.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