nexus_mods 0.1.1 → 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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +12 -0
  3. data/lib/nexus_mods/api/api_limits.rb +64 -0
  4. data/lib/nexus_mods/api/category.rb +54 -0
  5. data/lib/nexus_mods/api/game.rb +106 -0
  6. data/lib/nexus_mods/api/mod.rb +141 -0
  7. data/lib/nexus_mods/api/mod_file.rb +116 -0
  8. data/lib/nexus_mods/api/user.rb +55 -0
  9. data/lib/nexus_mods/api_client.rb +182 -0
  10. data/lib/nexus_mods/cacheable_api.rb +52 -0
  11. data/lib/nexus_mods/cacheable_with_expiry.rb +70 -0
  12. data/lib/nexus_mods/core_extensions/cacheable/cache_adapters/persistent_json_adapter.rb +62 -0
  13. data/lib/nexus_mods/core_extensions/cacheable/method_generator.rb +62 -0
  14. data/lib/nexus_mods/file_cache.rb +71 -0
  15. data/lib/nexus_mods/version.rb +1 -1
  16. data/lib/nexus_mods.rb +32 -86
  17. data/spec/nexus_mods_test/factories/games.rb +135 -0
  18. data/spec/nexus_mods_test/factories/mod_files.rb +113 -0
  19. data/spec/nexus_mods_test/factories/mods.rb +144 -0
  20. data/spec/nexus_mods_test/helpers.rb +39 -14
  21. data/spec/nexus_mods_test/scenarios/nexus_mods/{api_limits_spec.rb → api/api_limits_spec.rb} +10 -3
  22. data/spec/nexus_mods_test/scenarios/nexus_mods/api/game_spec.rb +93 -0
  23. data/spec/nexus_mods_test/scenarios/nexus_mods/api/mod_file_spec.rb +73 -0
  24. data/spec/nexus_mods_test/scenarios/nexus_mods/api/mod_spec.rb +62 -0
  25. data/spec/nexus_mods_test/scenarios/nexus_mods_caching_spec.rb +88 -0
  26. metadata +37 -13
  27. data/lib/nexus_mods/api_limits.rb +0 -44
  28. data/lib/nexus_mods/category.rb +0 -37
  29. data/lib/nexus_mods/game.rb +0 -78
  30. data/lib/nexus_mods/mod.rb +0 -106
  31. data/lib/nexus_mods/mod_file.rb +0 -86
  32. data/lib/nexus_mods/user.rb +0 -37
  33. data/spec/nexus_mods_test/scenarios/nexus_mods/game_spec.rb +0 -180
  34. data/spec/nexus_mods_test/scenarios/nexus_mods/mod_file_spec.rb +0 -140
  35. data/spec/nexus_mods_test/scenarios/nexus_mods/mod_spec.rb +0 -185
@@ -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.1.1'
3
+ VERSION = '0.3.0'
4
4
 
5
5
  end