metal_archives 3.0.1 → 3.1.0

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