nexus_mods 0.1.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.
data/lib/nexus_mods.rb ADDED
@@ -0,0 +1,274 @@
1
+ require 'addressable/uri'
2
+ require 'json'
3
+ require 'time'
4
+ require 'tmpdir'
5
+ require 'faraday'
6
+ require 'nexus_mods/api_limits'
7
+ require 'nexus_mods/category'
8
+ require 'nexus_mods/game'
9
+ require 'nexus_mods/user'
10
+ require 'nexus_mods/mod'
11
+ require 'nexus_mods/mod_file'
12
+
13
+ # Ruby API to access NexusMods REST API
14
+ class NexusMods
15
+
16
+ # Error raised by the API calls
17
+ class ApiError < RuntimeError
18
+ end
19
+
20
+ # Error raised when the API calls have exceed their usage limits
21
+ class LimitsExceededError < ApiError
22
+ end
23
+
24
+ # Error raised when the API key is invalid
25
+ class InvalidApiKeyError < ApiError
26
+ end
27
+
28
+ # The default game domain name to be queried
29
+ # String
30
+ attr_accessor :game_domain_name
31
+
32
+ # The default mod id to be queried
33
+ # Integer
34
+ attr_accessor :mod_id
35
+
36
+ # Constructor
37
+ #
38
+ # Parameters::
39
+ # * *api_key* (String or nil): The API key to be used, or nil for another authentication [default: nil]
40
+ # * *game_domain_name* (String): Game domain name to query by default [default: 'skyrimspecialedition']
41
+ # * *mod_id* (Integer): Mod to query by default [default: 1]
42
+ # * *file_id* (Integer): File to query by default [default: 1]
43
+ # * *logger* (Logger): The logger to be used for log messages [default: Logger.new(STDOUT)]
44
+ def initialize(
45
+ api_key: nil,
46
+ game_domain_name: 'skyrimspecialedition',
47
+ mod_id: 1,
48
+ file_id: 1,
49
+ logger: Logger.new($stdout)
50
+ )
51
+ @api_key = api_key
52
+ @game_domain_name = game_domain_name
53
+ @mod_id = mod_id
54
+ @file_id = file_id
55
+ @logger = logger
56
+ @premium = false
57
+ # Initialize our HTTP client
58
+ @http_client = Faraday.new do |builder|
59
+ builder.adapter Faraday.default_adapter
60
+ end
61
+ # Check that the key is correct and know if the user is premium
62
+ begin
63
+ @premium = api('users/validate')['is_premium?']
64
+ rescue LimitsExceededError
65
+ raise
66
+ rescue ApiError
67
+ raise InvalidApiKeyError, 'Invalid API key'
68
+ end
69
+ end
70
+
71
+ # Get limits of API calls.
72
+ # This call does not count in the limits.
73
+ #
74
+ # Result::
75
+ # * ApiLimits: API calls limits
76
+ def api_limits
77
+ api_limits_headers = http('users/validate').headers
78
+ ApiLimits.new(
79
+ daily_limit: Integer(api_limits_headers['x-rl-daily-limit']),
80
+ daily_remaining: Integer(api_limits_headers['x-rl-daily-remaining']),
81
+ daily_reset: Time.parse(api_limits_headers['x-rl-daily-reset']).utc,
82
+ hourly_limit: Integer(api_limits_headers['x-rl-hourly-limit']),
83
+ hourly_remaining: Integer(api_limits_headers['x-rl-hourly-remaining']),
84
+ hourly_reset: Time.parse(api_limits_headers['x-rl-hourly-reset']).utc
85
+ )
86
+ end
87
+
88
+ # Get the list of games
89
+ #
90
+ # Result::
91
+ # * Array<Game>: List of games
92
+ def games
93
+ api('games').map do |game_json|
94
+ # First create categories tree
95
+ # Hash<Integer, [Category, Integer]>: Category and its parent category id, per category id
96
+ categories = game_json['categories'].to_h do |category_json|
97
+ category_id = category_json['category_id']
98
+ [
99
+ category_id,
100
+ [
101
+ Category.new(
102
+ id: category_id,
103
+ name: category_json['name']
104
+ ),
105
+ category_json['parent_category']
106
+ ]
107
+ ]
108
+ end
109
+ categories.each_value do |(category, parent_category_id)|
110
+ category.parent_category = categories[parent_category_id].first if parent_category_id
111
+ end
112
+ Game.new(
113
+ id: game_json['id'],
114
+ name: game_json['name'],
115
+ forum_url: game_json['forum_url'],
116
+ nexusmods_url: game_json['nexusmods_url'],
117
+ genre: game_json['genre'],
118
+ domain_name: game_json['domain_name'],
119
+ approved_date: Time.at(game_json['approved_date']),
120
+ files_count: game_json['file_count'],
121
+ files_views: game_json['file_views'],
122
+ files_endorsements: game_json['file_endorsements'],
123
+ downloads_count: game_json['downloads'],
124
+ authors_count: game_json['authors'],
125
+ mods_count: game_json['mods'],
126
+ categories: categories.values.map { |(category, _parent_category_id)| category }
127
+ )
128
+ end
129
+ end
130
+
131
+ # Get information about a mod
132
+ #
133
+ # Parameters::
134
+ # * *game_domain_name* (String): Game domain name to query by default [default: @game_domain_name]
135
+ # * *mod_id* (Integer): The mod ID [default: @mod_id]
136
+ # Result::
137
+ # * Mod: Mod information
138
+ def mod(game_domain_name: @game_domain_name, mod_id: @mod_id)
139
+ mod_json = api "games/#{game_domain_name}/mods/#{mod_id}"
140
+ Mod.new(
141
+ uid: mod_json['uid'],
142
+ mod_id: mod_json['mod_id'],
143
+ game_id: mod_json['game_id'],
144
+ allow_rating: mod_json['allow_rating'],
145
+ domain_name: mod_json['domain_name'],
146
+ category_id: mod_json['category_id'],
147
+ version: mod_json['version'],
148
+ created_time: Time.parse(mod_json['created_time']),
149
+ updated_time: Time.parse(mod_json['updated_time']),
150
+ author: mod_json['author'],
151
+ contains_adult_content: mod_json['contains_adult_content'],
152
+ status: mod_json['status'],
153
+ available: mod_json['available'],
154
+ uploader: User.new(
155
+ member_id: mod_json['user']['member_id'],
156
+ member_group_id: mod_json['user']['member_group_id'],
157
+ name: mod_json['user']['name'],
158
+ profile_url: mod_json['uploaded_users_profile_url']
159
+ ),
160
+ name: mod_json['name'],
161
+ summary: mod_json['summary'],
162
+ description: mod_json['description'],
163
+ picture_url: mod_json['picture_url'],
164
+ downloads_count: mod_json['mod_downloads'],
165
+ unique_downloads_count: mod_json['mod_unique_downloads'],
166
+ endorsements_count: mod_json['endorsement_count']
167
+ )
168
+ end
169
+
170
+ # Enum of file categories from the API
171
+ FILE_CATEGORIES = {
172
+ 1 => :main,
173
+ 2 => :patch,
174
+ 3 => :optional,
175
+ 4 => :old,
176
+ 6 => :deleted
177
+ }
178
+
179
+ # Get files belonging to a mod
180
+ #
181
+ # Parameters::
182
+ # * *game_domain_name* (String): Game domain name to query by default [default: @game_domain_name]
183
+ # * *mod_id* (Integer): The mod ID [default: @mod_id]
184
+ # Result::
185
+ # * Array<ModFile>: List of mod's files
186
+ def mod_files(game_domain_name: @game_domain_name, mod_id: @mod_id)
187
+ api("games/#{game_domain_name}/mods/#{mod_id}/files")['files'].map do |file_json|
188
+ category_id = FILE_CATEGORIES[file_json['category_id']]
189
+ raise "Unknown file category: #{file_json['category_id']}" if category_id.nil?
190
+
191
+ ModFile.new(
192
+ ids: file_json['id'],
193
+ uid: file_json['uid'],
194
+ id: file_json['file_id'],
195
+ name: file_json['name'],
196
+ version: file_json['version'],
197
+ category_id:,
198
+ category_name: file_json['category_name'],
199
+ is_primary: file_json['is_primary'],
200
+ size: file_json['size_in_bytes'],
201
+ file_name: file_json['file_name'],
202
+ uploaded_time: Time.parse(file_json['uploaded_time']),
203
+ mod_version: file_json['mod_version'],
204
+ external_virus_scan_url: file_json['external_virus_scan_url'],
205
+ description: file_json['description'],
206
+ changelog_html: file_json['changelog_html'],
207
+ content_preview_url: file_json['content_preview_link']
208
+ )
209
+ end
210
+ end
211
+
212
+ private
213
+
214
+ # Send an HTTP request to the API and get back the answer as a JSON
215
+ #
216
+ # Parameters::
217
+ # * *path* (String): API path to contact (from v1/ and without .json)
218
+ # * *verb* (Symbol): Verb to be used (:get, :post...) [default: :get]
219
+ # Result::
220
+ # * Object: The JSON response
221
+ def api(path, verb: :get)
222
+ res = http(path, verb:)
223
+ json = JSON.parse(res.body)
224
+ uri = api_uri(path)
225
+ @logger.debug "[API call] - #{verb} #{uri} => #{res.status}\n#{
226
+ JSON.
227
+ pretty_generate(json).
228
+ split("\n").
229
+ map { |line| " #{line}" }.
230
+ join("\n")
231
+ }\n#{
232
+ res.
233
+ headers.
234
+ map { |header, value| " #{header}: #{value}" }.
235
+ join("\n")
236
+ }"
237
+ case res.status
238
+ when 200
239
+ # Happy
240
+ when 429
241
+ # Some limits of the API have been reached
242
+ raise LimitsExceededError, "Exceeding limits of API calls: #{res.headers.select { |header, _value| header =~ /^x-rl-.+$/ }}"
243
+ else
244
+ raise ApiError, "API #{uri} returned error code #{res.status}" unless res.status == '200'
245
+ end
246
+ json
247
+ end
248
+
249
+ # Send an HTTP request to the API and get back the HTTP response
250
+ #
251
+ # Parameters::
252
+ # * *path* (String): API path to contact (from v1/ and without .json)
253
+ # * *verb* (Symbol): Verb to be used (:get, :post...) [default: :get]
254
+ # Result::
255
+ # * Faraday::Response: The HTTP response
256
+ def http(path, verb: :get)
257
+ @http_client.send(verb) do |req|
258
+ req.url api_uri(path)
259
+ req.headers['apikey'] = @api_key
260
+ req.headers['User-Agent'] = "nexus_mods (#{RUBY_PLATFORM}) Ruby/#{RUBY_VERSION}"
261
+ end
262
+ end
263
+
264
+ # Get the real URI to query for a given API path
265
+ #
266
+ # Parameters::
267
+ # * *path* (String): API path to contact (from v1/ and without .json)
268
+ # Result::
269
+ # * String: The URI
270
+ def api_uri(path)
271
+ "https://api.nexusmods.com/v1/#{path}.json"
272
+ end
273
+
274
+ end
@@ -0,0 +1,140 @@
1
+ require 'digest'
2
+ require 'webmock/rspec'
3
+ require 'rspec/support/object_formatter'
4
+ require 'nexus_mods'
5
+
6
+ module NexusModsTests
7
+
8
+ module Helpers
9
+
10
+ # Integer: Mocked user ID
11
+ MOCKED_USER_ID = 1_234_567
12
+
13
+ # String: Mocked API key
14
+ MOCKED_API_KEY = '1234567891234566546543123546879s8df46s5df4sd5f4sd6f87wer9f846sf54sd65v16x5v48r796rwe84f654f35sd1v5df6v54687rUGZWcG0rdz09--62dcd41bb308d2d660548a3cd1ef4094162c4379'
15
+
16
+ # String: Mocked user name
17
+ MOCKED_USER_NAME = 'NexusModsUser'
18
+
19
+ # String: Mocked user email
20
+ MOCKED_USER_EMAIL = 'nexus_mods_user@test_mail.com'
21
+
22
+ # Hash<String, String>: Default HTTP headers returned by the HTTP responses
23
+ DEFAULT_API_HEADERS = {
24
+ 'date' => 'Wed, 02 Oct 2019 15:08:43 GMT',
25
+ 'content-type' => 'application/json; charset=utf-8',
26
+ 'transfer-encoding' => 'chunked',
27
+ 'connection' => 'close',
28
+ 'set-cookie' => '__cfduid=1234561234561234561234561234561234560028923; expires=Thu, 01-Oct-20 15:08:43 GMT; path=/; domain=.nexusmods.com; HttpOnly; Secure',
29
+ 'vary' => 'Accept-Encoding, Origin',
30
+ 'userid' => MOCKED_USER_ID.to_s,
31
+ 'x-rl-hourly-limit' => '100',
32
+ 'x-rl-hourly-remaining' => '100',
33
+ 'x-rl-hourly-reset' => '2019-10-02T16:00:00+00:00',
34
+ 'x-rl-daily-limit' => '2500',
35
+ 'x-rl-daily-remaining' => '2500',
36
+ 'x-rl-daily-reset' => '2019-10-03 00:00:00 +0000',
37
+ 'cache-control' => 'max-age=0, private, must-revalidate',
38
+ 'x-request-id' => '1234561234561234561daf88a8134abc',
39
+ 'x-runtime' => '0.022357',
40
+ 'strict-transport-security' => 'max-age=15724800; includeSubDomains',
41
+ 'expect-ct' => 'max-age=604800, report-uri="https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct"',
42
+ 'server' => 'cloudflare',
43
+ 'cf-ray' => '1234561234561234-MAD'
44
+ }
45
+
46
+ # Return the NexusMods instance to be tested.
47
+ # Handle the API key.
48
+ # Cache it for the scope of a test case.
49
+ #
50
+ # Parameters::
51
+ # * *args* (Hash): List of named arguments to give the constructor for the first time usage
52
+ def nexus_mods(**args)
53
+ if @nexus_mods.nil?
54
+ args[:api_key] = MOCKED_API_KEY unless args.key?(:api_key)
55
+ # Redirect any log into a string so that they don't pollute the tests output and they could be asserted.
56
+ nexus_mods_logger = StringIO.new
57
+ args[:logger] = Logger.new(nexus_mods_logger)
58
+ @nexus_mods = NexusMods.new(**args)
59
+ end
60
+ @nexus_mods
61
+ end
62
+
63
+ # Expect an HTTP API call to be made, and mock the corresponding HTTP response.
64
+ # Handle the API key and user agent.
65
+ #
66
+ # Parameters::
67
+ # * *host* (String): Expected host being targeted [default: 'api.nexusmodstoto.com']
68
+ # * *http_method* (Symbol): Expected requested HTTP method [default: :get]
69
+ # * *path* (String): Expected requested path [default: '/']
70
+ # * *api_key* (String): Expected API key to be used in request headers [default: MOCKED_API_KEY]
71
+ # * *code* (Integer): Mocked return code [default: 200]
72
+ # * *message* (String): Mocked returned message [default: 'OK']
73
+ # * *json* (Object): Mocked JSON body [default: {}]
74
+ # * *headers* (Hash<String,String>): Mocked additional HTTP headers [default: {}]
75
+ def expect_http_call_to(
76
+ host: 'api.nexusmods.com',
77
+ http_method: :get,
78
+ path: '/',
79
+ api_key: MOCKED_API_KEY,
80
+ code: 200,
81
+ message: 'OK',
82
+ json: {},
83
+ headers: {}
84
+ )
85
+ json_as_str = json.to_json
86
+ mocked_etag = "W/\"#{Digest::MD5.hexdigest("#{path}|#{json_as_str}")}\""
87
+ expected_request_headers = {
88
+ 'User-Agent' => "nexus_mods (#{RUBY_PLATFORM}) Ruby/#{RUBY_VERSION}",
89
+ 'apikey' => api_key
90
+ }
91
+ if @expected_returned_etags.include? mocked_etag
92
+ expected_request_headers['If-None-Match'] = mocked_etag
93
+ else
94
+ @expected_returned_etags << mocked_etag
95
+ end
96
+ stub_request(http_method, "https://#{host}#{path}").with(headers: expected_request_headers).to_return(
97
+ status: [code, message],
98
+ body: json_as_str,
99
+ headers: DEFAULT_API_HEADERS.
100
+ merge(
101
+ 'etag' => mocked_etag
102
+ ).
103
+ merge(headers)
104
+ )
105
+ end
106
+
107
+ # Expect a successfull call made to validate the user
108
+ def expect_validate_user
109
+ expect_http_call_to(
110
+ path: '/v1/users/validate.json',
111
+ json: {
112
+ user_id: MOCKED_USER_ID,
113
+ key: MOCKED_API_KEY,
114
+ name: MOCKED_USER_NAME,
115
+ email: MOCKED_USER_EMAIL,
116
+ is_premium?: false,
117
+ is_supporter?: false,
118
+ profile_url: 'https://www.nexusmods.com/Contents/Images/noavatar.gif',
119
+ is_supporter: false,
120
+ is_premium: false
121
+ }
122
+ )
123
+ end
124
+
125
+ end
126
+
127
+ end
128
+
129
+ RSpec.configure do |config|
130
+ config.include NexusModsTests::Helpers
131
+ config.before do
132
+ @nexus_mods = nil
133
+ # Keep a list of the etags we should have returned, so that we know when queries should contain them
134
+ # Array<String>
135
+ @expected_returned_etags = []
136
+ end
137
+ end
138
+
139
+ # Set a bigger output length for expectation errors, as most error messages include API keys and headers which can be lengthy
140
+ RSpec::Support::ObjectFormatter.default_instance.max_formatted_output_length = 16_384
@@ -0,0 +1,19 @@
1
+ describe NexusMods::ApiLimits do
2
+
3
+ context 'when testing API limits' do
4
+
5
+ it 'gets api limits' do
6
+ expect_validate_user
7
+ expect_validate_user
8
+ api_limits = nexus_mods.api_limits
9
+ expect(api_limits.daily_limit).to eq 2500
10
+ expect(api_limits.daily_remaining).to eq 2500
11
+ expect(api_limits.daily_reset).to eq Time.parse('2019-10-03 00:00:00 +0000')
12
+ expect(api_limits.hourly_limit).to eq 100
13
+ expect(api_limits.hourly_remaining).to eq 100
14
+ expect(api_limits.hourly_reset).to eq Time.parse('2019-10-02T16:00:00+00:00')
15
+ end
16
+
17
+ end
18
+
19
+ end
@@ -0,0 +1,122 @@
1
+ describe NexusMods::Game do
2
+
3
+ context 'when testing games' do
4
+
5
+ it 'returns the games list' do
6
+ expect_validate_user
7
+ expect_http_call_to(
8
+ path: '/v1/games.json',
9
+ json: [
10
+ {
11
+ 'id' => 100,
12
+ 'name' => 'Morrowind',
13
+ 'forum_url' => 'https://forums.nexusmods.com/index.php?/forum/111-morrowind/',
14
+ 'nexusmods_url' => 'http://www.nexusmods.com/morrowind',
15
+ 'genre' => 'RPG',
16
+ 'file_count' => 14_143,
17
+ 'downloads' => 20_414_985,
18
+ 'domain_name' => 'morrowind',
19
+ 'approved_date' => 1,
20
+ 'file_views' => 100_014_750,
21
+ 'authors' => 2062,
22
+ 'file_endorsements' => 719_262,
23
+ 'mods' => 6080,
24
+ 'categories' => [
25
+ {
26
+ 'category_id' => 1,
27
+ 'name' => 'Morrowind',
28
+ 'parent_category' => false
29
+ },
30
+ {
31
+ 'category_id' => 2,
32
+ 'name' => 'Buildings',
33
+ 'parent_category' => 1
34
+ }
35
+ ]
36
+ },
37
+ {
38
+ 'id' => 101,
39
+ 'name' => 'Oblivion',
40
+ 'forum_url' => 'https://forums.nexusmods.com/index.php?/forum/131-oblivion/',
41
+ 'nexusmods_url' => 'http://www.nexusmods.com/oblivion',
42
+ 'genre' => 'RPG',
43
+ 'file_count' => 52_775,
44
+ 'downloads' => 187_758_634,
45
+ 'domain_name' => 'oblivion',
46
+ 'approved_date' => 1,
47
+ 'file_views' => 880_508_188,
48
+ 'authors' => 10_673,
49
+ 'file_endorsements' => 4_104_067,
50
+ 'mods' => 29_220,
51
+ 'categories' => [
52
+ {
53
+ 'category_id' => 20,
54
+ 'name' => 'Oblivion',
55
+ 'parent_category' => false
56
+ },
57
+ {
58
+ 'category_id' => 22,
59
+ 'name' => 'New structures - Buildings',
60
+ 'parent_category' => 20
61
+ }
62
+ ]
63
+ }
64
+ ]
65
+ )
66
+ games = nexus_mods.games.sort_by(&:id)
67
+ expect(games.size).to eq 2
68
+ first_game = games.first
69
+ expect(first_game.id).to eq 100
70
+ expect(first_game.name).to eq 'Morrowind'
71
+ expect(first_game.forum_url).to eq 'https://forums.nexusmods.com/index.php?/forum/111-morrowind/'
72
+ expect(first_game.nexusmods_url).to eq 'http://www.nexusmods.com/morrowind'
73
+ expect(first_game.genre).to eq 'RPG'
74
+ expect(first_game.files_count).to eq 14_143
75
+ expect(first_game.downloads_count).to eq 20_414_985
76
+ expect(first_game.domain_name).to eq 'morrowind'
77
+ expect(first_game.approved_date).to eq Time.parse('1970-01-01 00:00:01 +0000')
78
+ expect(first_game.files_views).to eq 100_014_750
79
+ expect(first_game.authors_count).to eq 2062
80
+ expect(first_game.files_endorsements).to eq 719_262
81
+ expect(first_game.mods_count).to eq 6080
82
+ first_game_categories = first_game.categories
83
+ expect(first_game_categories.size).to eq 2
84
+ expect(first_game_categories.first.id).to eq 1
85
+ expect(first_game_categories.first.name).to eq 'Morrowind'
86
+ expect(first_game_categories.first.parent_category).to be_nil
87
+ expect(first_game_categories[1].id).to eq 2
88
+ expect(first_game_categories[1].name).to eq 'Buildings'
89
+ expect(first_game_categories[1].parent_category).not_to be_nil
90
+ expect(first_game_categories[1].parent_category.id).to eq 1
91
+ expect(first_game_categories[1].parent_category.name).to eq 'Morrowind'
92
+ expect(first_game_categories[1].parent_category.parent_category).to be_nil
93
+ second_game = games[1]
94
+ expect(second_game.id).to eq 101
95
+ expect(second_game.name).to eq 'Oblivion'
96
+ expect(second_game.forum_url).to eq 'https://forums.nexusmods.com/index.php?/forum/131-oblivion/'
97
+ expect(second_game.nexusmods_url).to eq 'http://www.nexusmods.com/oblivion'
98
+ expect(second_game.genre).to eq 'RPG'
99
+ expect(second_game.files_count).to eq 52_775
100
+ expect(second_game.downloads_count).to eq 187_758_634
101
+ expect(second_game.domain_name).to eq 'oblivion'
102
+ expect(second_game.approved_date).to eq Time.parse('1970-01-01 00:00:01 +0000')
103
+ expect(second_game.files_views).to eq 880_508_188
104
+ expect(second_game.authors_count).to eq 10_673
105
+ expect(second_game.files_endorsements).to eq 4_104_067
106
+ expect(second_game.mods_count).to eq 29_220
107
+ second_game_categories = second_game.categories
108
+ expect(second_game_categories.size).to eq 2
109
+ expect(second_game_categories.first.id).to eq 20
110
+ expect(second_game_categories.first.name).to eq 'Oblivion'
111
+ expect(second_game_categories.first.parent_category).to be_nil
112
+ expect(second_game_categories[1].id).to eq 22
113
+ expect(second_game_categories[1].name).to eq 'New structures - Buildings'
114
+ expect(second_game_categories[1].parent_category).not_to be_nil
115
+ expect(second_game_categories[1].parent_category.id).to eq 20
116
+ expect(second_game_categories[1].parent_category.name).to eq 'Oblivion'
117
+ expect(second_game_categories[1].parent_category.parent_category).to be_nil
118
+ end
119
+
120
+ end
121
+
122
+ end