metal_archives 2.2.3 → 3.2.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 +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