nexus_mods 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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