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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +59 -12
- data/.rspec +1 -0
- data/.rubocop.yml +34 -20
- data/CHANGELOG.md +16 -1
- data/LICENSE.md +17 -4
- data/README.md +37 -29
- data/bin/console +8 -11
- data/config/inflections.rb +7 -0
- data/config/initializers/.keep +0 -0
- data/docker-compose.yml +10 -1
- data/lib/metal_archives.rb +57 -21
- data/lib/metal_archives/cache/base.rb +40 -0
- data/lib/metal_archives/cache/memory.rb +68 -0
- data/lib/metal_archives/cache/null.rb +22 -0
- data/lib/metal_archives/cache/redis.rb +49 -0
- data/lib/metal_archives/collection.rb +3 -5
- data/lib/metal_archives/configuration.rb +28 -21
- data/lib/metal_archives/errors.rb +9 -1
- data/lib/metal_archives/http_client.rb +42 -46
- data/lib/metal_archives/models/artist.rb +55 -26
- data/lib/metal_archives/models/band.rb +43 -36
- data/lib/metal_archives/models/{base_model.rb → base.rb} +57 -50
- data/lib/metal_archives/models/label.rb +7 -8
- data/lib/metal_archives/models/release.rb +21 -18
- data/lib/metal_archives/parsers/artist.rb +41 -36
- data/lib/metal_archives/parsers/band.rb +73 -29
- data/lib/metal_archives/parsers/base.rb +14 -0
- data/lib/metal_archives/parsers/country.rb +21 -0
- data/lib/metal_archives/parsers/date.rb +31 -0
- data/lib/metal_archives/parsers/genre.rb +67 -0
- data/lib/metal_archives/parsers/label.rb +21 -13
- data/lib/metal_archives/parsers/parser.rb +17 -77
- data/lib/metal_archives/parsers/release.rb +29 -18
- data/lib/metal_archives/parsers/year.rb +31 -0
- data/lib/metal_archives/version.rb +3 -3
- data/metal_archives.env.example +7 -4
- data/metal_archives.gemspec +7 -4
- data/nginx/default.conf +2 -2
- metadata +76 -32
- data/.github/workflows/release.yml +0 -69
- data/.rubocop_todo.yml +0 -92
- data/lib/metal_archives/lru_cache.rb +0 -61
- data/lib/metal_archives/middleware/cache_check.rb +0 -18
- data/lib/metal_archives/middleware/encoding.rb +0 -16
- data/lib/metal_archives/middleware/headers.rb +0 -38
- data/lib/metal_archives/middleware/rewrite_endpoint.rb +0 -38
- data/lib/metal_archives/nil_date.rb +0 -91
- data/lib/metal_archives/range.rb +0 -69
data/lib/metal_archives.rb
CHANGED
@@ -1,33 +1,49 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require "openssl"
|
4
|
-
|
5
3
|
require "zeitwerk"
|
6
|
-
|
7
|
-
|
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
|
-
|
35
|
+
@config ||= Configuration.new
|
36
|
+
end
|
29
37
|
|
30
|
-
|
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
|
59
|
+
raise Errors::InvalidConfigurationError, "no configuration block given" unless block_given?
|
44
60
|
|
45
|
-
|
46
|
-
yield @config
|
61
|
+
yield config
|
47
62
|
|
48
|
-
|
49
|
-
|
63
|
+
config.validate!
|
64
|
+
end
|
50
65
|
|
51
|
-
|
66
|
+
##
|
67
|
+
# Set up application framework
|
68
|
+
#
|
69
|
+
def setup
|
70
|
+
@loader = Zeitwerk::Loader.for_gem
|
52
71
|
|
53
|
-
|
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
|
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
|
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
|
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
|
-
#
|
39
|
+
# Logger instance
|
39
40
|
#
|
40
|
-
attr_accessor :
|
41
|
+
attr_accessor :logger
|
41
42
|
|
42
43
|
##
|
43
|
-
#
|
44
|
+
# Cache strategy
|
44
45
|
#
|
45
|
-
attr_accessor :
|
46
|
+
attr_accessor :cache_strategy
|
46
47
|
|
47
48
|
##
|
48
|
-
#
|
49
|
+
# Cache strategy options
|
49
50
|
#
|
50
|
-
attr_accessor :
|
51
|
+
attr_accessor :cache_options
|
51
52
|
|
52
53
|
##
|
53
|
-
#
|
54
|
+
# Default configuration values
|
54
55
|
#
|
55
|
-
|
56
|
+
def initialize(**attributes)
|
57
|
+
attributes.each { |key, value| send(:"#{key}=", value) }
|
56
58
|
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
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
|
-
#
|
67
|
+
# Validate configuration
|
68
|
+
#
|
69
|
+
# [Raises]
|
70
|
+
# - rdoc-ref:MetalArchives::Errors::ConfigurationError when configuration is invalid
|
64
71
|
#
|
65
|
-
def
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
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
|
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 "
|
4
|
-
require "faraday_throttler"
|
3
|
+
require "http"
|
5
4
|
|
6
5
|
module MetalArchives
|
7
6
|
##
|
8
|
-
# HTTP
|
7
|
+
# Generic HTTP client
|
9
8
|
#
|
10
|
-
class HTTPClient
|
11
|
-
|
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
|
-
|
23
|
-
|
12
|
+
def initialize(endpoint = MetalArchives.config.endpoint)
|
13
|
+
@endpoint = endpoint
|
14
|
+
@metrics = { hit: 0, miss: 0 }
|
15
|
+
end
|
24
16
|
|
25
|
-
|
26
|
-
|
27
|
-
|
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
|
-
|
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
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
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
|
-
|
41
|
-
|
42
|
-
f.response :logger, MetalArchives.config.logger
|
34
|
+
response
|
35
|
+
end
|
43
36
|
|
44
|
-
|
45
|
-
f.use MetalArchives::Middleware::CacheCheck
|
46
|
-
f.use MetalArchives::Middleware::RewriteEndpoint
|
47
|
-
f.use MetalArchives::Middleware::Encoding
|
37
|
+
private
|
48
38
|
|
49
|
-
|
39
|
+
def http
|
40
|
+
@http ||= HTTP
|
41
|
+
.headers(headers)
|
42
|
+
.use(logging: { logger: MetalArchives.config.logger })
|
43
|
+
.encoding("utf-8")
|
50
44
|
|
51
|
-
|
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
|
-
|
47
|
+
@http
|
48
|
+
.basic_auth(user: MetalArchives.config.endpoint_user, pass: MetalArchives.config.endpoint_password)
|
49
|
+
end
|
57
50
|
|
58
|
-
|
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
|
-
|
61
|
-
|
62
|
-
end
|
63
|
-
end
|
58
|
+
def url_for(path)
|
59
|
+
"#{endpoint}#{path}"
|
64
60
|
end
|
65
61
|
end
|
66
62
|
end
|