metal_archives 3.0.1 → 3.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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +59 -12
  3. data/.rspec +1 -0
  4. data/.rubocop.yml +34 -20
  5. data/CHANGELOG.md +4 -0
  6. data/LICENSE.md +17 -4
  7. data/README.md +29 -14
  8. data/bin/console +8 -11
  9. data/config/inflections.rb +7 -0
  10. data/config/initializers/.keep +0 -0
  11. data/docker-compose.yml +10 -1
  12. data/lib/metal_archives.rb +56 -22
  13. data/lib/metal_archives/cache/base.rb +40 -0
  14. data/lib/metal_archives/cache/memory.rb +68 -0
  15. data/lib/metal_archives/cache/null.rb +22 -0
  16. data/lib/metal_archives/cache/redis.rb +49 -0
  17. data/lib/metal_archives/collection.rb +3 -5
  18. data/lib/metal_archives/configuration.rb +28 -21
  19. data/lib/metal_archives/errors.rb +9 -1
  20. data/lib/metal_archives/http_client.rb +42 -46
  21. data/lib/metal_archives/models/artist.rb +55 -26
  22. data/lib/metal_archives/models/band.rb +43 -36
  23. data/lib/metal_archives/models/{base_model.rb → base.rb} +53 -53
  24. data/lib/metal_archives/models/label.rb +7 -8
  25. data/lib/metal_archives/models/release.rb +11 -17
  26. data/lib/metal_archives/parsers/artist.rb +40 -35
  27. data/lib/metal_archives/parsers/band.rb +73 -29
  28. data/lib/metal_archives/parsers/base.rb +14 -0
  29. data/lib/metal_archives/parsers/country.rb +21 -0
  30. data/lib/metal_archives/parsers/date.rb +31 -0
  31. data/lib/metal_archives/parsers/genre.rb +67 -0
  32. data/lib/metal_archives/parsers/label.rb +21 -13
  33. data/lib/metal_archives/parsers/parser.rb +15 -77
  34. data/lib/metal_archives/parsers/release.rb +22 -18
  35. data/lib/metal_archives/parsers/year.rb +29 -0
  36. data/lib/metal_archives/version.rb +3 -3
  37. data/metal_archives.env.example +7 -4
  38. data/metal_archives.gemspec +7 -4
  39. data/nginx/default.conf +2 -2
  40. metadata +76 -32
  41. data/.github/workflows/release.yml +0 -69
  42. data/.rubocop_todo.yml +0 -92
  43. data/lib/metal_archives/lru_cache.rb +0 -61
  44. data/lib/metal_archives/middleware/cache_check.rb +0 -18
  45. data/lib/metal_archives/middleware/encoding.rb +0 -16
  46. data/lib/metal_archives/middleware/headers.rb +0 -38
  47. data/lib/metal_archives/middleware/rewrite_endpoint.rb +0 -38
  48. data/lib/metal_archives/nil_date.rb +0 -91
  49. data/lib/metal_archives/range.rb +0 -69
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MetalArchives
4
+ module Cache
5
+ ##
6
+ # Generic cache interface
7
+ #
8
+ class Base
9
+ attr_accessor :options
10
+
11
+ def initialize(options = {})
12
+ @options = options
13
+
14
+ validate!
15
+ end
16
+
17
+ def validate!; end
18
+
19
+ def []
20
+ raise NotImplementedError
21
+ end
22
+
23
+ def []=(_key, _value)
24
+ raise NotImplementedError
25
+ end
26
+
27
+ def clear
28
+ raise NotImplementedError
29
+ end
30
+
31
+ def include?(_key)
32
+ raise NotImplementedError
33
+ end
34
+
35
+ def delete(_key)
36
+ raise NotImplementedError
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MetalArchives
4
+ module Cache
5
+ ##
6
+ # Generic LRU memory cache
7
+ #
8
+ class Memory < Base
9
+ def validate!
10
+ raise Errors::InvalidConfigurationError, "size has not been configured" if options[:size].blank?
11
+ raise Errors::InvalidConfigurationError, "size must be a number" unless options[:size].is_a? Integer
12
+ end
13
+
14
+ def [](key)
15
+ if keys.include? key
16
+ MetalArchives.config.logger.debug "Cache hit for #{key}"
17
+ keys.delete key
18
+ keys << key
19
+ else
20
+ MetalArchives.config.logger.debug "Cache miss for #{key}"
21
+ end
22
+
23
+ cache[key]
24
+ end
25
+
26
+ def []=(key, value)
27
+ cache[key] = value
28
+
29
+ keys.delete key if keys.include? key
30
+
31
+ keys << key
32
+
33
+ pop if keys.size > options[:size]
34
+ end
35
+
36
+ def clear
37
+ cache.clear
38
+ keys.clear
39
+ end
40
+
41
+ def include?(key)
42
+ cache.include? key
43
+ end
44
+
45
+ def delete(key)
46
+ cache.delete key
47
+ end
48
+
49
+ private
50
+
51
+ def cache
52
+ # Underlying data store
53
+ @cache ||= {}
54
+ end
55
+
56
+ def keys
57
+ # Array of keys in order of insertion
58
+ @keys ||= []
59
+ end
60
+
61
+ def pop
62
+ to_remove = keys.shift(keys.size - options[:size])
63
+
64
+ to_remove.each { |key| cache.delete key }
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MetalArchives
4
+ module Cache
5
+ ##
6
+ # Null cache
7
+ #
8
+ class Null < Base
9
+ def [](_key); end
10
+
11
+ def []=(_key, _value); end
12
+
13
+ def clear; end
14
+
15
+ def include?(_key)
16
+ false
17
+ end
18
+
19
+ def delete(_key); end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "redis"
4
+
5
+ module MetalArchives
6
+ module Cache
7
+ ##
8
+ # Redis-backed cache
9
+ #
10
+ class Redis < Base
11
+ def initialize(options = {})
12
+ super
13
+
14
+ # Default TTL is 1 month
15
+ options[:ttl] ||= (30 * 24 * 60 * 60)
16
+ end
17
+
18
+ def [](key)
19
+ redis.get cache_key_for(key)
20
+ end
21
+
22
+ def []=(key, value)
23
+ redis.set cache_key_for(key), value, ex: options[:ttl]
24
+ end
25
+
26
+ def clear
27
+ redis.keys(cache_key_for("*")).each { |key| redis.del key }
28
+ end
29
+
30
+ def include?(key)
31
+ redis.exists? cache_key_for(key)
32
+ end
33
+
34
+ def delete(key)
35
+ redis.del cache_key_for(key)
36
+ end
37
+
38
+ private
39
+
40
+ def cache_key_for(key)
41
+ "metal_archives.cache.#{key}"
42
+ end
43
+
44
+ def redis
45
+ @redis ||= ::Redis.new(**options.except(:ttl))
46
+ end
47
+ end
48
+ end
49
+ end
@@ -22,15 +22,13 @@ module MetalArchives
22
22
  # Calls the given block once for each element, passing that element as a parameter.
23
23
  # If no block is given, an Enumerator is returned.
24
24
  #
25
- def each
26
- return to_enum :each unless block_given?
25
+ def each(&block)
26
+ return to_enum :each unless block
27
27
 
28
28
  loop do
29
29
  items = instance_exec(&@proc)
30
30
 
31
- items.each do |item|
32
- yield item
33
- end
31
+ items.each(&block)
34
32
 
35
33
  break if items.empty?
36
34
  end
@@ -3,6 +3,8 @@
3
3
  require "logger"
4
4
 
5
5
  module MetalArchives
6
+ CACHE_STRATEGIES = %w(memory redis).freeze
7
+
6
8
  ##
7
9
  # Contains configuration options
8
10
  #
@@ -23,10 +25,9 @@ module MetalArchives
23
25
  attr_accessor :app_contact
24
26
 
25
27
  ##
26
- # Override Metal Archives endpoint (defaults to http://www.metal-archives.com/)
28
+ # Override Metal Archives endpoint (defaults to https://www.metal-archives.com/)
27
29
  #
28
30
  attr_accessor :endpoint
29
- attr_reader :default_endpoint
30
31
 
31
32
  ##
32
33
  # Endpoint HTTP Basic authentication
@@ -35,39 +36,45 @@ module MetalArchives
35
36
  attr_accessor :endpoint_password
36
37
 
37
38
  ##
38
- # Additional Faraday middleware
39
+ # Logger instance
39
40
  #
40
- attr_accessor :middleware
41
+ attr_accessor :logger
41
42
 
42
43
  ##
43
- # Request throttling rate (in seconds per request per path)
44
+ # Cache strategy
44
45
  #
45
- attr_accessor :request_rate
46
+ attr_accessor :cache_strategy
46
47
 
47
48
  ##
48
- # Request timeout (in seconds per request per path)
49
+ # Cache strategy options
49
50
  #
50
- attr_accessor :request_timeout
51
+ attr_accessor :cache_options
51
52
 
52
53
  ##
53
- # Logger instance
54
+ # Default configuration values
54
55
  #
55
- attr_accessor :logger
56
+ def initialize(**attributes)
57
+ attributes.each { |key, value| send(:"#{key}=", value) }
56
58
 
57
- ##
58
- # Cache size (per object class)
59
- #
60
- attr_accessor :cache_size
59
+ @endpoint ||= "https://www.metal-archives.com/"
60
+ @logger ||= Logger.new $stdout
61
+
62
+ @cache_strategy ||= "memory"
63
+ @cache_options ||= { size: 100 }
64
+ end
61
65
 
62
66
  ##
63
- # Default configuration values
67
+ # Validate configuration
68
+ #
69
+ # [Raises]
70
+ # - rdoc-ref:MetalArchives::Errors::ConfigurationError when configuration is invalid
64
71
  #
65
- def initialize
66
- @default_endpoint = "https://www.metal-archives.com/"
67
- @throttle_rate = 1
68
- @throttle_wait = 3
69
- @logger = Logger.new STDOUT
70
- @cache_size = 100
72
+ def validate!
73
+ raise Errors::InvalidConfigurationError, "app_name has not been configured" if app_name.blank?
74
+ raise Errors::InvalidConfigurationError, "app_version has not been configured" if app_version.blank?
75
+ raise Errors::InvalidConfigurationError, "app_contact has not been configured" if app_contact.blank?
76
+ raise Errors::InvalidConfigurationError, "cache_strategy has not been configured" if cache_strategy.blank?
77
+ raise Errors::InvalidConfigurationError, "cache_strategy must be one of: #{CACHE_STRATEGIES.join(', ')}" if CACHE_STRATEGIES.exclude?(cache_strategy.to_s)
71
78
  end
72
79
  end
73
80
  end
@@ -32,7 +32,15 @@ module MetalArchives
32
32
  ##
33
33
  # Error in backend response
34
34
  #
35
- class APIError < Error; end
35
+ class APIError < Error
36
+ attr_reader :code
37
+
38
+ def initialize(response)
39
+ super("#{response.reason}: #{response.body}")
40
+
41
+ @code = response.code
42
+ end
43
+ end
36
44
 
37
45
  ##
38
46
  # Error in method argument
@@ -1,66 +1,62 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "faraday"
4
- require "faraday_throttler"
3
+ require "http"
5
4
 
6
5
  module MetalArchives
7
6
  ##
8
- # HTTP request client
7
+ # Generic HTTP client
9
8
  #
10
- class HTTPClient # :nodoc:
11
- class << self
12
- ##
13
- # Retrieve a HTTP resource
14
- #
15
- # [Raises]
16
- # - rdoc-ref:MetalArchives::Errors::InvalidIDError when receiving a status code == 404n
17
- # - rdoc-ref:MetalArchives::Errors::APIError when receiving a status code >= 400 (except 404)
18
- #
19
- def get(*params)
20
- response = client.get(*params)
9
+ class HTTPClient
10
+ attr_reader :endpoint, :metrics
21
11
 
22
- raise Errors::InvalidIDError, response.status if response.status == 404
23
- raise Errors::APIError, response.status if response.status >= 400
12
+ def initialize(endpoint = MetalArchives.config.endpoint)
13
+ @endpoint = endpoint
14
+ @metrics = { hit: 0, miss: 0 }
15
+ end
24
16
 
25
- response
26
- rescue Faraday::ClientError => e
27
- MetalArchives.config.logger.error e.response
28
- raise Errors::APIError, e
29
- end
17
+ def get(path, params = {})
18
+ response = http
19
+ .get(url_for(path), params: params)
30
20
 
31
- private
21
+ # Log cache status
22
+ status = response.headers["x-cache-status"]&.downcase&.to_sym
23
+ MetalArchives.config.logger.info "Cache #{status} for #{path}" if status
32
24
 
33
- ##
34
- # Retrieve a HTTP client
35
- #
36
- #
37
- def client
38
- raise Errors::InvalidConfigurationError, "Not configured yet" unless MetalArchives.config
25
+ case status
26
+ when :hit
27
+ metrics[:hit] += 1
28
+ when :miss, :bypass, :expired, :stale, :updating, :revalidated
29
+ metrics[:miss] += 1
30
+ end
31
+ raise Errors::InvalidIDError, response if response.code == 404
32
+ raise Errors::APIError, response unless response.status.success?
39
33
 
40
- @faraday ||= Faraday.new do |f|
41
- f.request :url_encoded # form-encode POST params
42
- f.response :logger, MetalArchives.config.logger
34
+ response
35
+ end
43
36
 
44
- f.use MetalArchives::Middleware::Headers
45
- f.use MetalArchives::Middleware::CacheCheck
46
- f.use MetalArchives::Middleware::RewriteEndpoint
47
- f.use MetalArchives::Middleware::Encoding
37
+ private
48
38
 
49
- MetalArchives.config.middleware&.each { |m| f.use m }
39
+ def http
40
+ @http ||= HTTP
41
+ .headers(headers)
42
+ .use(logging: { logger: MetalArchives.config.logger })
43
+ .encoding("utf-8")
50
44
 
51
- f.use :throttler,
52
- rate: MetalArchives.config.request_rate,
53
- wait: MetalArchives.config.request_timeout,
54
- logger: MetalArchives.config.logger
45
+ return @http unless MetalArchives.config.endpoint_user && MetalArchives.config.endpoint_password
55
46
 
56
- f.adapter Faraday.default_adapter
47
+ @http
48
+ .basic_auth(user: MetalArchives.config.endpoint_user, pass: MetalArchives.config.endpoint_password)
49
+ end
57
50
 
58
- next unless MetalArchives.config.endpoint_user
51
+ def headers
52
+ {
53
+ user_agent: "#{MetalArchives.config.app_name}/#{MetalArchives.config.app_version} (#{MetalArchives.config.app_contact})",
54
+ accept: "application/json",
55
+ }
56
+ end
59
57
 
60
- f.basic_auth MetalArchives.config.endpoint_user,
61
- MetalArchives.config.endpoint_password
62
- end
63
- end
58
+ def url_for(path)
59
+ "#{endpoint}#{path}"
64
60
  end
65
61
  end
66
62
  end
@@ -8,7 +8,7 @@ module MetalArchives
8
8
  ##
9
9
  # Represents a single performer (but not a solo artist)
10
10
  #
11
- class Artist < MetalArchives::BaseModel
11
+ class Artist < Base
12
12
  ##
13
13
  # :attr_reader: id
14
14
  #
@@ -63,24 +63,24 @@ module MetalArchives
63
63
  ##
64
64
  # :attr_reader: date_of_birth
65
65
  #
66
- # Returns rdoc-ref:NilDate
66
+ # Returns rdoc-ref:Date
67
67
  #
68
68
  # [Raises]
69
69
  # - rdoc-ref:MetalArchives::Errors::InvalidIDError when no or invalid id
70
70
  # - rdoc-ref:MetalArchives::Errors::APIError when receiving a status code >= 400 (except 404)
71
71
  #
72
- property :date_of_birth, type: NilDate
72
+ property :date_of_birth, type: Date
73
73
 
74
74
  ##
75
75
  # :attr_reader: date_of_death
76
76
  #
77
- # Returns rdoc-ref:NilDate
77
+ # Returns rdoc-ref:Date
78
78
  #
79
79
  # [Raises]
80
80
  # - rdoc-ref:MetalArchives::Errors::InvalidIDError when no or invalid id
81
81
  # - rdoc-ref:MetalArchives::Errors::APIError when receiving a status code >= 400 (except 404)
82
82
  #
83
- property :date_of_death, type: NilDate
83
+ property :date_of_death, type: Date
84
84
 
85
85
  ##
86
86
  # :attr_reader: cause_of_death
@@ -102,7 +102,7 @@ module MetalArchives
102
102
  # - rdoc-ref:MetalArchives::Errors::InvalidIDError when no or invalid id
103
103
  # - rdoc-ref:MetalArchives::Errors::APIError when receiving a status code >= 400 (except 404)
104
104
  #
105
- enum :gender, values: %i(male female)
105
+ enum :gender, values: [:male, :female]
106
106
 
107
107
  ##
108
108
  # :attr_reader: biography
@@ -165,7 +165,7 @@ module MetalArchives
165
165
  # [+bands+]
166
166
  # - +:band+: rdoc-ref:Band
167
167
  # - +:active+: Boolean
168
- # - +:date_active+: +Array+ of rdoc-ref:Range containing rdoc-ref:NilDate
168
+ # - +:years_active+: +Array+ of rdoc-ref:Range containing +Integer+
169
169
  # - +:role+: +String+
170
170
  #
171
171
  property :bands, type: Hash, multiple: true
@@ -173,6 +173,41 @@ module MetalArchives
173
173
  # TODO: guest/session bands
174
174
  # TODO: misc bands
175
175
 
176
+ ##
177
+ # Serialize to hash
178
+ #
179
+ def to_h
180
+ {
181
+ type: "artist",
182
+ id: id,
183
+ name: name,
184
+ aliases: aliases || [],
185
+ country: country&.alpha3,
186
+ location: location,
187
+ date_of_birth: date_of_birth&.iso8601,
188
+ date_of_death: date_of_death&.iso8601,
189
+ cause_of_death: cause_of_death,
190
+ gender: gender,
191
+ biography: biography,
192
+ trivia: trivia,
193
+ photo: photo,
194
+ links: links || [],
195
+ bands: bands || [],
196
+ }
197
+ end
198
+
199
+ ##
200
+ # Deserialize from hash
201
+ #
202
+ def self.from_h(hash)
203
+ return unless hash.fetch(:type) == "artist"
204
+
205
+ new(hash.slice(:id, :name, :aliases, :location, :cause_of_death, :gender, :biography, :trivial, :photo, :links, :bands))
206
+ .tap { |m| m.country = ISO3166::Country[hash[:country]] }
207
+ .tap { |m| m.date_of_birth = Date.parse(hash[:date_of_birth]) if hash[:date_of_birth] }
208
+ .tap { |m| m.date_of_death = Date.parse(hash[:date_of_death]) if hash[:date_of_death] }
209
+ end
210
+
176
211
  protected
177
212
 
178
213
  ##
@@ -184,28 +219,24 @@ module MetalArchives
184
219
  #
185
220
  def assemble # :nodoc:
186
221
  ## Base attributes
187
- url = "#{MetalArchives.config.default_endpoint}artist/view/id/#{id}"
188
- response = HTTPClient.get url
222
+ response = MetalArchives.http.get "/artist/view/id/#{id}"
189
223
 
190
- properties = Parsers::Artist.parse_html response.body
224
+ properties = Parsers::Artist.parse_html response.to_s
191
225
 
192
226
  ## Biography
193
- url = "#{MetalArchives.config.default_endpoint}artist/read-more/id/#{id}/field/biography"
194
- response = HTTPClient.get url
227
+ response = MetalArchives.http.get "/artist/read-more/id/#{id}/field/biography"
195
228
 
196
- properties[:biography] = response.body
229
+ properties[:biography] = response.to_s
197
230
 
198
231
  ## Trivia
199
- url = "#{MetalArchives.config.default_endpoint}artist/read-more/id/#{id}/field/trivia"
200
- response = HTTPClient.get url
232
+ response = MetalArchives.http.get "/artist/read-more/id/#{id}/field/trivia"
201
233
 
202
- properties[:trivia] = response.body
234
+ properties[:trivia] = response.to_s
203
235
 
204
236
  ## Related links
205
- url = "#{MetalArchives.config.default_endpoint}link/ajax-list/type/person/id/#{id}"
206
- response = HTTPClient.get url
237
+ response = MetalArchives.http.get "/link/ajax-list/type/person/id/#{id}"
207
238
 
208
- properties[:links] = Parsers::Artist.parse_links_html response.body
239
+ properties[:links] = Parsers::Artist.parse_links_html response.to_s
209
240
 
210
241
  properties
211
242
  end
@@ -220,7 +251,7 @@ module MetalArchives
220
251
  # +Integer+
221
252
  #
222
253
  def find(id)
223
- return cache[id] if cache.include? id
254
+ return MetalArchives.cache[id] if MetalArchives.cache.include? id
224
255
 
225
256
  Artist.new id: id
226
257
  end
@@ -262,11 +293,10 @@ module MetalArchives
262
293
  def find_by(query)
263
294
  raise MetalArchives::Errors::ArgumentError unless query.include? :name
264
295
 
265
- url = "#{MetalArchives.config.default_endpoint}search/ajax-artist-search/"
266
296
  params = Parsers::Artist.map_params query
267
297
 
268
- response = HTTPClient.get url, params
269
- json = JSON.parse response.body
298
+ response = MetalArchives.http.get "/search/ajax-artist-search/", params
299
+ json = JSON.parse response.to_s
270
300
 
271
301
  return nil if json["aaData"].empty?
272
302
 
@@ -314,7 +344,6 @@ module MetalArchives
314
344
  def search(name)
315
345
  raise MetalArchives::Errors::ArgumentError unless name.is_a? String
316
346
 
317
- url = "#{MetalArchives.config.default_endpoint}search/ajax-artist-search/"
318
347
  query = { name: name }
319
348
 
320
349
  params = Parsers::Artist.map_params query
@@ -325,8 +354,8 @@ module MetalArchives
325
354
  if @max_items && @start >= @max_items
326
355
  []
327
356
  else
328
- response = HTTPClient.get url, params.merge(iDisplayStart: @start)
329
- json = JSON.parse response.body
357
+ response = MetalArchives.http.get "/search/ajax-artist-search/", params.merge(iDisplayStart: @start)
358
+ json = JSON.parse response.to_s
330
359
 
331
360
  @max_items = json["iTotalRecords"]
332
361