metal_archives 2.2.3 → 3.2.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 +16 -1
  6. data/LICENSE.md +17 -4
  7. data/README.md +37 -29
  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 +57 -21
  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} +57 -50
  24. data/lib/metal_archives/models/label.rb +7 -8
  25. data/lib/metal_archives/models/release.rb +21 -18
  26. data/lib/metal_archives/parsers/artist.rb +41 -36
  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 +17 -77
  34. data/lib/metal_archives/parsers/release.rb +29 -18
  35. data/lib/metal_archives/parsers/year.rb +31 -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
@@ -1,33 +1,49 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "openssl"
4
-
5
3
  require "zeitwerk"
6
- loader = Zeitwerk::Loader.for_gem
7
- load.enable_reloading if ENV["METAL_ARCHIVES_ENV"] == "development"
8
- loader.inflector.inflect(
9
- "id" => "ID",
10
- "api" => "API",
11
- "http_client" => "HTTPClient",
12
- "lru_cache" => "LRUCache"
13
- )
14
- loader.collapse("lib/metal_archives/models")
15
- loader.setup
4
+ require "byebug" if ENV["METAL_ARCHIVES_ENV"] == "development"
5
+ require "active_support/all"
16
6
 
17
7
  ##
18
8
  # Metal Archives Ruby API
19
9
  #
20
10
  module MetalArchives
21
11
  class << self
12
+ # Code loader instance
13
+ attr_reader :loader
14
+
15
+ ##
16
+ # Root path
17
+ #
18
+ def root
19
+ @root ||= Pathname.new(File.expand_path(File.join("..", ".."), __FILE__))
20
+ end
21
+
22
+ ##
23
+ # HTTP client
24
+ #
25
+ def http
26
+ @http ||= HTTPClient.new
27
+ end
28
+
22
29
  ##
23
30
  # API configuration
24
31
  #
25
32
  # Instance of rdoc-ref:MetalArchives::Configuration
26
33
  #
27
34
  def config
28
- raise MetalArchives::Errors::InvalidConfigurationError, "Gem has not been configured" unless @config
35
+ @config ||= Configuration.new
36
+ end
29
37
 
30
- @config
38
+ ##
39
+ # Cache instance
40
+ #
41
+ def cache
42
+ raise MetalArchives::Errors::InvalidConfigurationError, "cache has not been configured" unless config.cache_strategy
43
+
44
+ @cache ||= Cache
45
+ .const_get(loader.inflector.camelize(config.cache_strategy, root))
46
+ .new(config.cache_options)
31
47
  end
32
48
 
33
49
  ##
@@ -40,17 +56,37 @@ module MetalArchives
40
56
  # - rdoc-ref:InvalidConfigurationException
41
57
  #
42
58
  def configure
43
- raise MetalArchives::Errors::InvalidConfigurationError, "No configuration block given" unless block_given?
59
+ raise Errors::InvalidConfigurationError, "no configuration block given" unless block_given?
44
60
 
45
- @config = MetalArchives::Configuration.new
46
- yield @config
61
+ yield config
47
62
 
48
- raise MetalArchives::Errors::InvalidConfigurationError, "app_name has not been configured" unless MetalArchives.config.app_name && !MetalArchives.config.app_name.empty?
49
- raise MetalArchives::Errors::InvalidConfigurationError, "app_version has not been configured" unless MetalArchives.config.app_version && !MetalArchives.config.app_version.empty?
63
+ config.validate!
64
+ end
50
65
 
51
- return if MetalArchives.config.app_contact && !MetalArchives.config.app_contact.empty?
66
+ ##
67
+ # Set up application framework
68
+ #
69
+ def setup
70
+ @loader = Zeitwerk::Loader.for_gem
52
71
 
53
- raise MetalArchives::Errors::InvalidConfigurationError, "app_contact has not been configured"
72
+ # Register inflections
73
+ require root.join("config/inflections.rb")
74
+
75
+ # Set up code loader
76
+ loader.enable_reloading if ENV["METAL_ARCHIVES_ENV"] == "development"
77
+ loader.collapse(root.join("lib/metal_archives/models"))
78
+ loader.do_not_eager_load(root.join("lib/metal_archives/cache"))
79
+ loader.setup
80
+ loader.eager_load
81
+
82
+ # Load initializers
83
+ Dir[root.join("config/initializers/*.rb")].sort.each { |f| require f }
54
84
  end
55
85
  end
56
86
  end
87
+
88
+ def reload!
89
+ MetalArchives.loader.reload
90
+ end
91
+
92
+ MetalArchives.setup
@@ -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