remote-resource 0.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.
- checksums.yaml +7 -0
- data/.codeclimate.yml +19 -0
- data/.gitignore +9 -0
- data/.rspec +2 -0
- data/.rubocop.yml +1156 -0
- data/.travis.yml +17 -0
- data/Gemfile +15 -0
- data/Guardfile +17 -0
- data/LICENSE.txt +21 -0
- data/Procfile.dev +5 -0
- data/README.md +314 -0
- data/Rakefile +12 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/lib/remote_resource/association_builder.rb +47 -0
- data/lib/remote_resource/attribute_http_client.rb +41 -0
- data/lib/remote_resource/attribute_key.rb +26 -0
- data/lib/remote_resource/attribute_method_attacher.rb +117 -0
- data/lib/remote_resource/attribute_specification.rb +51 -0
- data/lib/remote_resource/attribute_storage_value.rb +63 -0
- data/lib/remote_resource/base/attributes.rb +44 -0
- data/lib/remote_resource/base/base_class_methods.rb +23 -0
- data/lib/remote_resource/base/dsl.rb +27 -0
- data/lib/remote_resource/base/rescue.rb +43 -0
- data/lib/remote_resource/base.rb +35 -0
- data/lib/remote_resource/bridge.rb +174 -0
- data/lib/remote_resource/configuration/logger.rb +24 -0
- data/lib/remote_resource/configuration/lookup_method.rb +24 -0
- data/lib/remote_resource/configuration/storage.rb +24 -0
- data/lib/remote_resource/errors.rb +40 -0
- data/lib/remote_resource/log_subscriber.rb +39 -0
- data/lib/remote_resource/lookup/default.rb +39 -0
- data/lib/remote_resource/notifications.rb +17 -0
- data/lib/remote_resource/railtie.rb +21 -0
- data/lib/remote_resource/scope_evaluator.rb +52 -0
- data/lib/remote_resource/storage/cache_control.rb +120 -0
- data/lib/remote_resource/storage/db_cache.rb +36 -0
- data/lib/remote_resource/storage/db_cache_factory.rb +38 -0
- data/lib/remote_resource/storage/memory.rb +27 -0
- data/lib/remote_resource/storage/null_storage_entry.rb +43 -0
- data/lib/remote_resource/storage/redis.rb +27 -0
- data/lib/remote_resource/storage/serializer.rb +15 -0
- data/lib/remote_resource/storage/serializers/marshal.rb +18 -0
- data/lib/remote_resource/storage/storage_entry.rb +69 -0
- data/lib/remote_resource/version.rb +3 -0
- data/lib/remote_resource.rb +34 -0
- data/remote-resource.gemspec +27 -0
- metadata +175 -0
@@ -0,0 +1,52 @@
|
|
1
|
+
module RemoteResource
|
2
|
+
class ScopeEvaluator
|
3
|
+
attr_reader :scope
|
4
|
+
|
5
|
+
def initialize(scope = nil)
|
6
|
+
@scope = normalize_scope(scope)
|
7
|
+
end
|
8
|
+
|
9
|
+
def evaluate_on(target_object)
|
10
|
+
scope = {}
|
11
|
+
@scope.each_pair do |attr_key, target_method|
|
12
|
+
scope[attr_key.to_sym] = target_object.send(target_method.to_sym)
|
13
|
+
end
|
14
|
+
scope
|
15
|
+
end
|
16
|
+
|
17
|
+
private
|
18
|
+
# Internal: Returns a Boolean indicating whether or not the scope should be
|
19
|
+
# looked up (evaluated on) the target_object. This allows direct values for
|
20
|
+
# the scope to be used, rather then references to methods on the target
|
21
|
+
# object.
|
22
|
+
def should_eval_attribute_scope?
|
23
|
+
@scope.values.all? { |scope_value| scope_value.is_a? Symbol }
|
24
|
+
end
|
25
|
+
|
26
|
+
# Internal: Returns a hash where the values of the scope have been evaluated
|
27
|
+
# on the provided target_object.
|
28
|
+
def eval_attribute_scope(target_object)
|
29
|
+
scope = {}
|
30
|
+
@scope.each_pair do |attr_key, target_method|
|
31
|
+
scope[attr_key.to_sym] = target_object.send(target_method.to_sym)
|
32
|
+
end
|
33
|
+
scope
|
34
|
+
end
|
35
|
+
|
36
|
+
# Internal: Normalizes the scope argument. Always returns the scope as a
|
37
|
+
# Hash, despite it being able to be specified as a Symbol, Array, or Hash.
|
38
|
+
# An undefined scope returns an empty Hash.
|
39
|
+
def normalize_scope(scope)
|
40
|
+
if ! scope
|
41
|
+
scope = {}
|
42
|
+
elsif scope.is_a? Symbol
|
43
|
+
scope = { scope => scope }
|
44
|
+
elsif scope.is_a? Array
|
45
|
+
scope = {}.tap do |hash|
|
46
|
+
scope.each { |method| hash[method.to_sym] = method.to_sym }
|
47
|
+
end
|
48
|
+
end
|
49
|
+
scope
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
@@ -0,0 +1,120 @@
|
|
1
|
+
module RemoteResource
|
2
|
+
# A class to represent the 'Cache-Control' header options.
|
3
|
+
# > This implementation is based on 'rack-cache' internals by Ryan Tomayko.
|
4
|
+
# It breaks the several directives into keys/values and stores them into
|
5
|
+
# a Hash.
|
6
|
+
# This file stolen from faraday-http-cache @ 907c33ec25eba8317931e53c84cfa
|
7
|
+
# See: https://github.com/plataformatec/faraday-http-cache
|
8
|
+
class CacheControl
|
9
|
+
# Initialize a new CacheControl.
|
10
|
+
def initialize(header)
|
11
|
+
@directives = parse(header.to_s)
|
12
|
+
end
|
13
|
+
|
14
|
+
# Checks if the 'public' directive is present.
|
15
|
+
def public?
|
16
|
+
@directives['public']
|
17
|
+
end
|
18
|
+
|
19
|
+
# Checks if the 'private' directive is present.
|
20
|
+
def private?
|
21
|
+
@directives['private']
|
22
|
+
end
|
23
|
+
|
24
|
+
# Checks if the 'no-cache' directive is present.
|
25
|
+
def no_cache?
|
26
|
+
@directives['no-cache']
|
27
|
+
end
|
28
|
+
|
29
|
+
# Checks if the 'no-store' directive is present.
|
30
|
+
def no_store?
|
31
|
+
@directives['no-store']
|
32
|
+
end
|
33
|
+
|
34
|
+
# Gets the 'max-age' directive as an Integer.
|
35
|
+
#
|
36
|
+
# Returns nil if the 'max-age' directive isn't present.
|
37
|
+
def max_age
|
38
|
+
@directives['max-age'].to_i if @directives.key?('max-age')
|
39
|
+
end
|
40
|
+
|
41
|
+
# Gets the 'max-age' directive as an Integer.
|
42
|
+
#
|
43
|
+
# takes the age header integer value and reduces the max-age and s-maxage
|
44
|
+
# if present to account for having to remove static age header when caching responses
|
45
|
+
def normalize_max_ages(age)
|
46
|
+
if age > 0
|
47
|
+
@directives['max-age'] = @directives['max-age'].to_i - age if @directives.key?('max-age')
|
48
|
+
@directives['s-maxage'] = @directives['s-maxage'].to_i - age if @directives.key?('s-maxage')
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
# Gets the 's-maxage' directive as an Integer.
|
53
|
+
#
|
54
|
+
# Returns nil if the 's-maxage' directive isn't present.
|
55
|
+
def shared_max_age
|
56
|
+
@directives['s-maxage'].to_i if @directives.key?('s-maxage')
|
57
|
+
end
|
58
|
+
alias_method :s_maxage, :shared_max_age
|
59
|
+
|
60
|
+
# Internal: Checks if the 'must-revalidate' directive is present.
|
61
|
+
def must_revalidate?
|
62
|
+
@directives['must-revalidate']
|
63
|
+
end
|
64
|
+
|
65
|
+
# Internal: Checks if the 'proxy-revalidate' directive is present.
|
66
|
+
def proxy_revalidate?
|
67
|
+
@directives['proxy-revalidate']
|
68
|
+
end
|
69
|
+
|
70
|
+
# Gets the String representation for the cache directives.
|
71
|
+
# Directives are joined by a '=' and then combined into a single String
|
72
|
+
# separated by commas. Directives with a 'true' value will omit the '='
|
73
|
+
# sign and their value.
|
74
|
+
#
|
75
|
+
# Returns the Cache Control string.
|
76
|
+
def to_s
|
77
|
+
booleans = []
|
78
|
+
values = []
|
79
|
+
|
80
|
+
@directives.each do |key, value|
|
81
|
+
if value == true
|
82
|
+
booleans << key
|
83
|
+
elsif value
|
84
|
+
values << "#{key}=#{value}"
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
(booleans.sort + values.sort).join(', ')
|
89
|
+
end
|
90
|
+
|
91
|
+
private
|
92
|
+
|
93
|
+
# Parses the Cache Control string to a Hash.
|
94
|
+
# Existing whitespace will be removed and the string is split on commas.
|
95
|
+
# For each part everything before a '=' will be treated as the key
|
96
|
+
# and the exceeding will be treated as the value. If only the key is
|
97
|
+
# present then the assigned value will default to true.
|
98
|
+
#
|
99
|
+
# Examples:
|
100
|
+
# parse("max-age=600")
|
101
|
+
# # => { "max-age" => "600"}
|
102
|
+
#
|
103
|
+
# parse("max-age")
|
104
|
+
# # => { "max-age" => true }
|
105
|
+
#
|
106
|
+
# Returns a Hash.
|
107
|
+
def parse(header)
|
108
|
+
directives = {}
|
109
|
+
|
110
|
+
header.delete(' ').split(',').each do |part|
|
111
|
+
next if part.empty?
|
112
|
+
|
113
|
+
name, value = part.split('=', 2)
|
114
|
+
directives[name.downcase] = (value || true) unless name.empty?
|
115
|
+
end
|
116
|
+
|
117
|
+
directives
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
require 'remote_resource/storage/serializers/marshal'
|
2
|
+
|
3
|
+
module RemoteResource
|
4
|
+
class DBCache
|
5
|
+
ADAPTERS = %i(active_record)
|
6
|
+
|
7
|
+
attr_reader :column_name
|
8
|
+
attr_accessor :target_instance
|
9
|
+
|
10
|
+
def initialize(_adapter, column_name, _serializer = :marshal)
|
11
|
+
@adapter = :active_record
|
12
|
+
@column_name = column_name.to_sym
|
13
|
+
@serializer = Serializers::MarshalSerializer.new
|
14
|
+
end
|
15
|
+
|
16
|
+
# always returns a hash
|
17
|
+
def read_column
|
18
|
+
raw = @target_instance.read_attribute(column_name)
|
19
|
+
raw ? @serializer.load(raw) : {}
|
20
|
+
end
|
21
|
+
|
22
|
+
def write_column(hash)
|
23
|
+
fail ArgumentError 'must be a hash!' unless hash.is_a? Hash
|
24
|
+
raw = @serializer.dump(hash)
|
25
|
+
@target_instance.update_attribute(column_name, raw)
|
26
|
+
end
|
27
|
+
|
28
|
+
def read_key(key)
|
29
|
+
read_column[key.to_sym]
|
30
|
+
end
|
31
|
+
|
32
|
+
def write_key(key, value)
|
33
|
+
write_column(read_column.merge(key.to_sym => value))
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
require 'remote_resource/storage/db_cache'
|
2
|
+
|
3
|
+
module RemoteResource
|
4
|
+
class UnsupportedDatabase < StandardError; end
|
5
|
+
|
6
|
+
class DBCacheFactory
|
7
|
+
def initialize(base_class, options)
|
8
|
+
@base_class = base_class
|
9
|
+
@options = options
|
10
|
+
end
|
11
|
+
|
12
|
+
def create_for_class(target_class)
|
13
|
+
db_cache_adapter = db_cache_adapter_for(target_class)
|
14
|
+
if db_cache_adapter
|
15
|
+
column_name = @options[:cache_column] ||
|
16
|
+
default_or_magic_column_name_for(@base_class)
|
17
|
+
DBCache.new(db_cache_adapter, column_name)
|
18
|
+
else
|
19
|
+
fail UnsupportedDatabase
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
def default_or_magic_column_name_for(base_class)
|
26
|
+
"#{base_class.underscore}_cache"
|
27
|
+
end
|
28
|
+
|
29
|
+
def db_cache_adapter_for(klass)
|
30
|
+
DBCache::ADAPTERS.detect do |adapter_name|
|
31
|
+
klass.ancestors.any? do |parent_class|
|
32
|
+
next unless (class_name = parent_class.name)
|
33
|
+
class_name.split('::').first.underscore.to_sym == adapter_name
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
require 'remote_resource/storage/storage_entry'
|
2
|
+
|
3
|
+
module RemoteResource
|
4
|
+
module Storage
|
5
|
+
class Memory
|
6
|
+
attr_accessor :memory_value
|
7
|
+
|
8
|
+
def initialize
|
9
|
+
@memory_value = {}
|
10
|
+
end
|
11
|
+
|
12
|
+
def read_key(key)
|
13
|
+
value = @memory_value[key]
|
14
|
+
return nil unless value.is_a? Hash
|
15
|
+
StorageEntry.new(value[:headers], value[:data])
|
16
|
+
end
|
17
|
+
|
18
|
+
def write_key(key, storage_entry)
|
19
|
+
if @memory_value[key]
|
20
|
+
@memory_value[key].merge!(storage_entry.to_hash)
|
21
|
+
else
|
22
|
+
@memory_value[key] = storage_entry.to_hash
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
require 'active_support/time'
|
2
|
+
|
3
|
+
require 'remote_resource/storage/cache_control'
|
4
|
+
|
5
|
+
module RemoteResource
|
6
|
+
# An unset storage entry
|
7
|
+
class NullStorageEntry
|
8
|
+
attr_reader :headers, :data
|
9
|
+
|
10
|
+
def initialize
|
11
|
+
@headers = {}
|
12
|
+
@data = {}
|
13
|
+
end
|
14
|
+
|
15
|
+
def to_hash
|
16
|
+
{ data: @data, headers: @headers }
|
17
|
+
end
|
18
|
+
|
19
|
+
def cache_control
|
20
|
+
@cache_control ||= CacheControl.new('')
|
21
|
+
end
|
22
|
+
|
23
|
+
def expired?
|
24
|
+
true
|
25
|
+
end
|
26
|
+
|
27
|
+
def data?
|
28
|
+
false
|
29
|
+
end
|
30
|
+
|
31
|
+
def exists?
|
32
|
+
false
|
33
|
+
end
|
34
|
+
|
35
|
+
def headers_for_validation
|
36
|
+
{}
|
37
|
+
end
|
38
|
+
|
39
|
+
def validateable?
|
40
|
+
false
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
require 'remote_resource/storage/serializers/marshal'
|
2
|
+
require 'remote_resource/storage/storage_entry'
|
3
|
+
|
4
|
+
module RemoteResource
|
5
|
+
module Storage
|
6
|
+
class Redis
|
7
|
+
def initialize(redis, serializer = nil)
|
8
|
+
@redis = redis
|
9
|
+
@serializer = serializer || Serializers::MarshalSerializer.new
|
10
|
+
end
|
11
|
+
|
12
|
+
def read_key(key)
|
13
|
+
redis_value = @redis.hgetall key
|
14
|
+
StorageEntry.new @serializer.load(redis_value['headers']),
|
15
|
+
@serializer.load(redis_value['data'])
|
16
|
+
end
|
17
|
+
|
18
|
+
def write_key(storage_key, storage_entry)
|
19
|
+
write_args = []
|
20
|
+
storage_entry.to_hash.each_pair do |key, value|
|
21
|
+
write_args.concat([key, @serializer.dump(value)]) unless value.empty?
|
22
|
+
end
|
23
|
+
@redis.hmset storage_key, *write_args
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
module RemoteResource
|
2
|
+
module Storage
|
3
|
+
class UnImplemntedError < StandardError; end
|
4
|
+
|
5
|
+
class Serializer
|
6
|
+
def load(_object)
|
7
|
+
fail UnImplemntedError 'Must implement serializer#load'
|
8
|
+
end
|
9
|
+
|
10
|
+
def dump(_object)
|
11
|
+
fail UnImplemntedError 'Must implement serializer#dump'
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
require_relative '../serializer'
|
2
|
+
|
3
|
+
module RemoteResource
|
4
|
+
module Storage
|
5
|
+
module Serializers
|
6
|
+
class MarshalSerializer < Serializer
|
7
|
+
def load(object)
|
8
|
+
return nil unless object
|
9
|
+
Marshal.load(object)
|
10
|
+
end
|
11
|
+
|
12
|
+
def dump(object)
|
13
|
+
Marshal.dump(object)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,69 @@
|
|
1
|
+
require 'active_support/time'
|
2
|
+
|
3
|
+
require 'remote_resource/storage/cache_control'
|
4
|
+
|
5
|
+
module RemoteResource
|
6
|
+
# A storage entry closely resembles a network response. This seeks to
|
7
|
+
# counteract the impedance mismatch because API responses are done on the
|
8
|
+
# resource level and we want to query storages at the attribute level. Headers
|
9
|
+
# are also handled on the resource (response) level as well, and thus apply
|
10
|
+
# for many attributes.
|
11
|
+
class StorageEntry
|
12
|
+
def self.from_response(response)
|
13
|
+
new(response.headers, response.data)
|
14
|
+
end
|
15
|
+
|
16
|
+
attr_reader :headers, :data
|
17
|
+
|
18
|
+
def initialize(headers, data)
|
19
|
+
@headers = headers.try(:to_hash) || {}
|
20
|
+
@data = data.try(:to_hash) || {}
|
21
|
+
end
|
22
|
+
|
23
|
+
def to_hash
|
24
|
+
{}.tap do |hash|
|
25
|
+
hash[:data] = @data unless @data.size == 0
|
26
|
+
hash[:headers] = @headers unless @headers.size == 0
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def cache_control
|
31
|
+
@cache_control ||= CacheControl.new(headers['cache-control'])
|
32
|
+
end
|
33
|
+
|
34
|
+
# TODO: Extract this and make it better
|
35
|
+
# See: https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.9.4
|
36
|
+
# Cache-Control (http 1.1) overrides Expires header (http: 1.0)
|
37
|
+
def expired?
|
38
|
+
if cache_control.must_revalidate?
|
39
|
+
true
|
40
|
+
elsif cache_control.max_age
|
41
|
+
expire = DateTime.parse(headers['date']) + cache_control.max_age.seconds
|
42
|
+
Time.now > expire
|
43
|
+
else
|
44
|
+
false
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def data?
|
49
|
+
!data.empty?
|
50
|
+
end
|
51
|
+
|
52
|
+
def exists?
|
53
|
+
!headers.empty? || data?
|
54
|
+
end
|
55
|
+
|
56
|
+
def headers_for_validation
|
57
|
+
v_headers = {}
|
58
|
+
v_headers['If-None-Match'] = headers['etag'] if headers['etag']
|
59
|
+
if headers['last-modified']
|
60
|
+
v_headers['If-Modified-Since'] = headers['last-modified']
|
61
|
+
end
|
62
|
+
v_headers
|
63
|
+
end
|
64
|
+
|
65
|
+
def validateable?
|
66
|
+
headers.key?('last-modified') || headers.key?('etag')
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|