shaf_client 0.3.0 → 0.4.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 +4 -4
- checksums.yaml.gz.sig +0 -0
- data.tar.gz.sig +0 -0
- data/lib/shaf_client.rb +51 -25
- data/lib/shaf_client/base_resource.rb +29 -6
- data/lib/shaf_client/form.rb +32 -4
- data/lib/shaf_client/middleware/http_cache.rb +93 -0
- data/lib/shaf_client/middleware/http_cache/accessor.rb +25 -0
- data/lib/shaf_client/middleware/http_cache/base.rb +84 -0
- data/lib/shaf_client/middleware/http_cache/entry.rb +104 -0
- data/lib/shaf_client/middleware/http_cache/file_storage.rb +186 -0
- data/lib/shaf_client/middleware/http_cache/in_memory.rb +67 -0
- data/lib/shaf_client/middleware/http_cache/key.rb +15 -0
- data/lib/shaf_client/middleware/http_cache/query.rb +40 -0
- data/lib/shaf_client/middleware/redirect.rb +10 -10
- data/lib/shaf_client/resource.rb +5 -5
- metadata +10 -3
- metadata.gz.sig +0 -0
- data/lib/shaf_client/middleware/cache.rb +0 -186
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 7299a5c0ec8bb7cb0922bb5cddee002c4007e40181bf3ded4a8d28c503185858
|
4
|
+
data.tar.gz: 576b70f8568906c69127f94ca123c9e154cd32cd0ac08efd99a14213f42aa7c6
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 598ebc50a8981d76c9bc32bf878e7900d4a8d3d5c3a8b5c8eb7d75a8e1c7754f17d7b2a3104964fd3ba34502d9eb8d56171b23c76f87de9805f9bbef48826edb
|
7
|
+
data.tar.gz: 536c50642866b57a5697d2e95f1fab76981af719e69c54538415aeb739f166562a3d67d10415f03e28b64ee9b2f10f18cf7bf7f464123c79e3704461aa932e23
|
checksums.yaml.gz.sig
CHANGED
Binary file
|
data.tar.gz.sig
CHANGED
Binary file
|
data/lib/shaf_client.rb
CHANGED
@@ -2,38 +2,31 @@
|
|
2
2
|
|
3
3
|
require 'faraday'
|
4
4
|
require 'json'
|
5
|
-
require 'shaf_client/middleware/
|
5
|
+
require 'shaf_client/middleware/http_cache'
|
6
6
|
require 'shaf_client/middleware/redirect'
|
7
7
|
require 'shaf_client/resource'
|
8
8
|
require 'shaf_client/form'
|
9
9
|
|
10
10
|
class ShafClient
|
11
|
-
|
11
|
+
class Error < StandardError; end
|
12
12
|
|
13
13
|
MIME_TYPE_JSON = 'application/json'
|
14
|
+
MIME_TYPE_HAL = 'application/hal+json'
|
15
|
+
DEFAULT_ADAPTER = :net_http
|
14
16
|
|
15
17
|
def initialize(root_uri, **options)
|
16
18
|
@root_uri = root_uri.dup
|
17
|
-
|
18
|
-
setup options
|
19
|
+
@options = options
|
19
20
|
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
conn.use Middleware::Redirect
|
24
|
-
conn.adapter adapter
|
25
|
-
end
|
21
|
+
setup_default_headers
|
22
|
+
setup_basic_auth
|
23
|
+
setup_client
|
26
24
|
end
|
27
25
|
|
28
26
|
def get_root(**options)
|
29
27
|
get(@root_uri, **options)
|
30
28
|
end
|
31
29
|
|
32
|
-
def get_form(uri, **options)
|
33
|
-
response = request(method: :get, uri: uri, opts: options)
|
34
|
-
Form.new(self, response.body, response.status, response.headers)
|
35
|
-
end
|
36
|
-
|
37
30
|
def get_doc(uri, **options)
|
38
31
|
response = request(method: :get, uri: uri, opts: options)
|
39
32
|
response&.body || ''
|
@@ -51,18 +44,19 @@ class ShafClient
|
|
51
44
|
end
|
52
45
|
end
|
53
46
|
|
47
|
+
def stubs
|
48
|
+
return unless @adapter == :test
|
49
|
+
@stubs ||= Faraday::Adapter::Test::Stubs.new
|
50
|
+
end
|
51
|
+
|
54
52
|
private
|
55
53
|
|
56
|
-
attr_reader :auth_header
|
54
|
+
attr_reader :options, :auth_header
|
57
55
|
|
58
|
-
def
|
59
|
-
setup_default_headers options
|
60
|
-
setup_basic_auth options
|
61
|
-
end
|
62
|
-
|
63
|
-
def setup_default_headers(options)
|
56
|
+
def setup_default_headers
|
64
57
|
@default_headers = {
|
65
|
-
'Content-Type' => options.fetch(:content_type, MIME_TYPE_JSON)
|
58
|
+
'Content-Type' => options.fetch(:content_type, MIME_TYPE_JSON),
|
59
|
+
'Accept' => options.fetch(:accept, MIME_TYPE_HAL)
|
66
60
|
}
|
67
61
|
return unless token = options[:auth_token]
|
68
62
|
|
@@ -70,7 +64,7 @@ class ShafClient
|
|
70
64
|
@default_headers[@auth_header] = token
|
71
65
|
end
|
72
66
|
|
73
|
-
def setup_basic_auth
|
67
|
+
def setup_basic_auth
|
74
68
|
@user, @pass = options.slice(:user, :password).values
|
75
69
|
@auth_header = options.fetch(:auth_header, 'Authorization') if basic_auth?
|
76
70
|
end
|
@@ -79,9 +73,33 @@ class ShafClient
|
|
79
73
|
@user && @pass
|
80
74
|
end
|
81
75
|
|
76
|
+
def setup_client
|
77
|
+
@adapter = options.fetch(:faraday_adapter, DEFAULT_ADAPTER)
|
78
|
+
|
79
|
+
@client = Faraday.new(url: @root_uri) do |conn|
|
80
|
+
conn.basic_auth(@user, @pass) if basic_auth?
|
81
|
+
conn.use Middleware::HttpCache, cache_options
|
82
|
+
conn.use Middleware::Redirect
|
83
|
+
connect_adapter(conn)
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
def cache_options
|
88
|
+
options.merge(
|
89
|
+
accessed_by: self,
|
90
|
+
auth_header: auth_header
|
91
|
+
)
|
92
|
+
end
|
93
|
+
|
94
|
+
def connect_adapter(connection)
|
95
|
+
args = [@adapter]
|
96
|
+
args << stubs if @adapter == :test
|
97
|
+
connection.adapter(*args)
|
98
|
+
end
|
99
|
+
|
82
100
|
def request(method:, uri:, payload: nil, opts: {})
|
83
101
|
payload = JSON.generate(payload) if payload&.is_a?(Hash)
|
84
|
-
headers =
|
102
|
+
headers = default_headers(method).merge(opts.fetch(:headers, {}))
|
85
103
|
headers[:skip_cache] = true if opts[:skip_cache]
|
86
104
|
|
87
105
|
@client.send(method) do |req|
|
@@ -89,5 +107,13 @@ class ShafClient
|
|
89
107
|
req.body = payload if payload
|
90
108
|
req.headers.merge! headers
|
91
109
|
end
|
110
|
+
rescue StandardError => e
|
111
|
+
raise Error, e.message
|
112
|
+
end
|
113
|
+
|
114
|
+
def default_headers(http_method)
|
115
|
+
headers = @default_headers.dup
|
116
|
+
headers.delete('Content-Type') unless %i[put patch post].include? http_method
|
117
|
+
headers
|
92
118
|
end
|
93
119
|
end
|
@@ -18,9 +18,11 @@ class ShafClient
|
|
18
18
|
end
|
19
19
|
|
20
20
|
def to_h
|
21
|
-
attributes
|
22
|
-
|
23
|
-
|
21
|
+
attributes.dup.tap do |hash|
|
22
|
+
hash[:_links] = transform_values_to_s(links)
|
23
|
+
embedded = transform_values_to_s(embedded_resources)
|
24
|
+
hash[:_embedded] = embedded unless embedded.empty?
|
25
|
+
end
|
24
26
|
end
|
25
27
|
|
26
28
|
def to_s
|
@@ -28,19 +30,27 @@ class ShafClient
|
|
28
30
|
end
|
29
31
|
|
30
32
|
def attribute(key)
|
33
|
+
raise Error, "No attribute for key: #{key}" unless attributes.key? key
|
31
34
|
attributes.fetch(key.to_sym)
|
32
35
|
end
|
33
36
|
|
34
37
|
def link(rel)
|
35
|
-
links.
|
38
|
+
rewritten_rel = best_match(links.keys, rel)
|
39
|
+
raise Error, "No link with rel: #{rel}" unless links.key? rewritten_rel
|
40
|
+
links[rewritten_rel]
|
36
41
|
end
|
37
42
|
|
38
43
|
def curie(rel)
|
39
|
-
curies.
|
44
|
+
raise Error, "No curie with rel: #{rel}" unless curies.key? rel.to_sym
|
45
|
+
curies[rel.to_sym]
|
40
46
|
end
|
41
47
|
|
42
48
|
def embedded(rel)
|
43
|
-
embedded_resources.
|
49
|
+
rewritten_rel = best_match(embedded_resources.keys, rel)
|
50
|
+
unless embedded_resources.key? rewritten_rel
|
51
|
+
raise Error, "No embedded resources with rel: #{rel}"
|
52
|
+
end
|
53
|
+
embedded_resources[rewritten_rel]
|
44
54
|
end
|
45
55
|
|
46
56
|
def [](key)
|
@@ -115,6 +125,19 @@ class ShafClient
|
|
115
125
|
super
|
116
126
|
end
|
117
127
|
|
128
|
+
def best_match(rels, rel)
|
129
|
+
rel = rel.to_sym
|
130
|
+
return rel if rels.include? rel
|
131
|
+
|
132
|
+
unless rel.to_s.include? ':'
|
133
|
+
matches = rels.grep(/[^:]*:#{rel}/)
|
134
|
+
return matches.first if matches.size == 1
|
135
|
+
raise Error, "Ambiguous rel: #{rel}. (#{matches})" if matches.size > 1
|
136
|
+
end
|
137
|
+
|
138
|
+
best_match(rels, rel.to_s.tr('_', '-')) if rel.to_s.include? '_'
|
139
|
+
end
|
140
|
+
|
118
141
|
def transform_values_to_s(hash)
|
119
142
|
hash.transform_values do |value|
|
120
143
|
if value.is_a? Array
|
data/lib/shaf_client/form.rb
CHANGED
@@ -30,14 +30,14 @@ class ShafClient
|
|
30
30
|
attribute(:method).downcase.to_sym
|
31
31
|
end
|
32
32
|
|
33
|
-
# def validate; end
|
34
|
-
|
35
33
|
def submit
|
36
34
|
client.send(http_method, target, payload: @values)
|
37
35
|
end
|
38
36
|
|
39
|
-
def
|
40
|
-
|
37
|
+
def valid?
|
38
|
+
attribute(:fields).all? do |field|
|
39
|
+
valid_field? field
|
40
|
+
end
|
41
41
|
end
|
42
42
|
|
43
43
|
protected
|
@@ -49,5 +49,33 @@ class ShafClient
|
|
49
49
|
end
|
50
50
|
super
|
51
51
|
end
|
52
|
+
|
53
|
+
def valid_field?(field)
|
54
|
+
key = field['name'].to_sym
|
55
|
+
return false unless validate_required(field, key)
|
56
|
+
return true if values[key].nil?
|
57
|
+
return false unless validate_number(field, key)
|
58
|
+
return false unless validate_string(field, key)
|
59
|
+
true
|
60
|
+
end
|
61
|
+
|
62
|
+
private
|
63
|
+
|
64
|
+
def validate_required(field, key)
|
65
|
+
return true unless field['required']
|
66
|
+
return false if values[key].nil?
|
67
|
+
return false if values[key].respond_to?(:empty) && values[key].empty?
|
68
|
+
true
|
69
|
+
end
|
70
|
+
|
71
|
+
def validate_string(field, key)
|
72
|
+
return true unless %w[string text].include? field.fetch('type', '').downcase
|
73
|
+
values[key].is_a? String
|
74
|
+
end
|
75
|
+
|
76
|
+
def validate_number(field, key)
|
77
|
+
return true unless %w[int integer number].include? field.fetch('type', '').downcase
|
78
|
+
values.fetch(key, 0).is_a? Numeric
|
79
|
+
end
|
52
80
|
end
|
53
81
|
end
|
@@ -0,0 +1,93 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'shaf_client/middleware/http_cache/in_memory'
|
4
|
+
require 'shaf_client/middleware/http_cache/file_storage'
|
5
|
+
require 'shaf_client/middleware/http_cache/query'
|
6
|
+
require 'shaf_client/middleware/http_cache/accessor'
|
7
|
+
|
8
|
+
class ShafClient
|
9
|
+
module Middleware
|
10
|
+
class HttpCache
|
11
|
+
Response = Struct.new(:status, :body, :headers, keyword_init: true)
|
12
|
+
|
13
|
+
def initialize(app, cache_class: InMemory, accessed_by: nil, **options)
|
14
|
+
@app = app
|
15
|
+
@options = options
|
16
|
+
@cache = cache_class.new(options)
|
17
|
+
add_accessors_to accessed_by
|
18
|
+
end
|
19
|
+
|
20
|
+
def call(env)
|
21
|
+
skip_cache = env[:request_headers].delete :skip_cache
|
22
|
+
cached_entry = nil
|
23
|
+
|
24
|
+
if cacheable?(env)
|
25
|
+
query = Query.from(env)
|
26
|
+
cache.load(query) do |cached|
|
27
|
+
return cached_response(cached) if cached.valid? && !skip_cache
|
28
|
+
add_etag(env, cached.etag)
|
29
|
+
cached_entry = cached
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
@app.call(env).on_complete do
|
34
|
+
handle_not_modified(env, cached_entry)
|
35
|
+
update_cache(env)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
|
41
|
+
attr_reader :cache
|
42
|
+
|
43
|
+
def add_accessors_to(obj)
|
44
|
+
return unless obj
|
45
|
+
obj.extend Accessor.for(cache)
|
46
|
+
end
|
47
|
+
|
48
|
+
def cacheable?(env)
|
49
|
+
%i[get head].include? env[:method]
|
50
|
+
end
|
51
|
+
|
52
|
+
def cached_response(entry)
|
53
|
+
cached_headers = entry.response_headers.transform_keys(&:to_s)
|
54
|
+
Response.new(body: entry.payload, headers: cached_headers)
|
55
|
+
end
|
56
|
+
|
57
|
+
def add_etag(env, etag)
|
58
|
+
env[:request_headers]['If-None-Match'] = etag if etag
|
59
|
+
end
|
60
|
+
|
61
|
+
def handle_not_modified(env, cached_entry)
|
62
|
+
return unless env[:status] == 304
|
63
|
+
|
64
|
+
cached_headers = cached_entry.response_headers.transform_keys(&:to_s)
|
65
|
+
env[:body] = cached_entry.payload
|
66
|
+
env[:response_headers] = cached_headers.merge(env[:response_headers])
|
67
|
+
|
68
|
+
expire_at = Entry.from(env).expire_at
|
69
|
+
cache.update_expiration(cached_entry, expire_at)
|
70
|
+
end
|
71
|
+
|
72
|
+
def update_cache(env)
|
73
|
+
cache.inc_request_count
|
74
|
+
entry = Entry.from(env)
|
75
|
+
return unless storable?(env: env, entry: entry)
|
76
|
+
|
77
|
+
cache.store entry
|
78
|
+
end
|
79
|
+
|
80
|
+
def storable?(env:, entry:)
|
81
|
+
return false unless %i[get put].include? env[:method]
|
82
|
+
return false unless env[:status] != 204
|
83
|
+
return false unless (200..299).cover? env[:status]
|
84
|
+
return false unless entry.etag || entry.expire_at
|
85
|
+
|
86
|
+
request_headers = env.request_headers.transform_keys { |k| k.downcase.to_sym }
|
87
|
+
entry.vary.keys.all? do |key|
|
88
|
+
request_headers.include? key
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class ShafClient
|
4
|
+
module Middleware
|
5
|
+
class HttpCache
|
6
|
+
module Accessor
|
7
|
+
module Accessible
|
8
|
+
extend Forwardable
|
9
|
+
def_delegator :__cache, :size, :cache_size
|
10
|
+
def_delegator :__cache, :clear, :clear_cache
|
11
|
+
def_delegator :__cache, :clear_stale, :clear_stale_cache
|
12
|
+
end
|
13
|
+
|
14
|
+
def self.for(cache)
|
15
|
+
block = proc { cache }
|
16
|
+
|
17
|
+
Module.new do
|
18
|
+
include Accessible
|
19
|
+
define_method(:__cache, &block)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,84 @@
|
|
1
|
+
require 'shaf_client/middleware/http_cache/entry'
|
2
|
+
require 'shaf_client/middleware/http_cache/key'
|
3
|
+
|
4
|
+
class ShafClient
|
5
|
+
module Middleware
|
6
|
+
class HttpCache
|
7
|
+
class Base
|
8
|
+
DEFAULT_PURGE_THRESHOLD = 1000
|
9
|
+
DEFAULT_NO_REQUEST_BETWEEN_PURGE = 500
|
10
|
+
|
11
|
+
attr_writer :request_count, :purge_threshold
|
12
|
+
|
13
|
+
def initialize(**_options); end
|
14
|
+
|
15
|
+
def purge_threshold
|
16
|
+
@purge_threshold ||= DEFAULT_PURGE_THRESHOLD
|
17
|
+
end
|
18
|
+
|
19
|
+
def requests_between_purge
|
20
|
+
DEFAULT_NO_REQUEST_BETWEEN_PURGE
|
21
|
+
end
|
22
|
+
|
23
|
+
def should_purge?
|
24
|
+
(request_count % requests_between_purge).zero?
|
25
|
+
end
|
26
|
+
|
27
|
+
def inc_request_count
|
28
|
+
self.request_count += 1
|
29
|
+
return unless should_purge?
|
30
|
+
|
31
|
+
clear_invalid
|
32
|
+
purge
|
33
|
+
end
|
34
|
+
|
35
|
+
def request_count
|
36
|
+
@request_count ||= 0
|
37
|
+
end
|
38
|
+
|
39
|
+
def purge_target
|
40
|
+
(purge_threshold * 0.8).to_i
|
41
|
+
end
|
42
|
+
|
43
|
+
def purge
|
44
|
+
return unless size > purge_threshold
|
45
|
+
|
46
|
+
count = size - purge_target
|
47
|
+
return unless count.positive?
|
48
|
+
|
49
|
+
delete_if do
|
50
|
+
break if count.zero?
|
51
|
+
count -= 1
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def load(query)
|
56
|
+
entry = get(query)
|
57
|
+
return entry unless block_given?
|
58
|
+
yield entry if entry
|
59
|
+
end
|
60
|
+
|
61
|
+
def store(entry)
|
62
|
+
return unless entry.storable?
|
63
|
+
put(entry)
|
64
|
+
end
|
65
|
+
|
66
|
+
def update_expiration(entry, expire_at)
|
67
|
+
return unless expire_at
|
68
|
+
|
69
|
+
updated_entry = entry.dup
|
70
|
+
updated_entry.expire_at = expire_at
|
71
|
+
store(updated_entry)
|
72
|
+
end
|
73
|
+
|
74
|
+
%i[size get put clear clear_invalid delete_if].each do |name|
|
75
|
+
define_method(name) do
|
76
|
+
raise NotImplementedError, "#{self.class} does not implement required method #{name}"
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
private :delete_if
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
@@ -0,0 +1,104 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'time'
|
4
|
+
require 'shaf_client/middleware/http_cache/key'
|
5
|
+
|
6
|
+
class ShafClient
|
7
|
+
module Middleware
|
8
|
+
class HttpCache
|
9
|
+
class Entry
|
10
|
+
extend Key
|
11
|
+
|
12
|
+
attr_reader :key, :payload, :etag, :vary, :response_headers
|
13
|
+
attr_accessor :expire_at
|
14
|
+
|
15
|
+
class << self
|
16
|
+
def from(env)
|
17
|
+
response_headers = response_headers(env)
|
18
|
+
|
19
|
+
new(
|
20
|
+
key: key(env.fetch(:url)),
|
21
|
+
payload: env[:body],
|
22
|
+
etag: response_headers[:etag],
|
23
|
+
expire_at: expire_at(response_headers),
|
24
|
+
vary: vary(env),
|
25
|
+
response_headers: response_headers
|
26
|
+
)
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def response_headers(env)
|
32
|
+
response_headers = env.response_headers
|
33
|
+
response_headers ||= {}
|
34
|
+
response_headers.transform_keys { |k| k.downcase.to_sym }
|
35
|
+
end
|
36
|
+
|
37
|
+
def request_headers(env)
|
38
|
+
request_headers = env.request_headers
|
39
|
+
request_headers ||= {}
|
40
|
+
request_headers.transform_keys { |k| k.downcase.to_sym }
|
41
|
+
end
|
42
|
+
|
43
|
+
def expire_at(headers)
|
44
|
+
cache_control = headers[:'cache-control']
|
45
|
+
return unless cache_control
|
46
|
+
|
47
|
+
max_age = cache_control[/\bmax-age=(\d+)/, 1]
|
48
|
+
Time.now + max_age.to_i if max_age
|
49
|
+
end
|
50
|
+
|
51
|
+
def vary(env)
|
52
|
+
response_headers = response_headers(env)
|
53
|
+
request_headers = request_headers(env)
|
54
|
+
keys = response_headers.fetch(:vary, '').split(',')
|
55
|
+
keys.each_with_object({}) do |key, vary|
|
56
|
+
key = key.strip.downcase.to_sym
|
57
|
+
# The respose that we see is already decoded (e.g. gunzipped) so
|
58
|
+
# we shouldn't need to care about the Accept-Encoding header
|
59
|
+
next if key == :'accept-encoding'
|
60
|
+
vary[key] = request_headers[key]
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def initialize(key:, payload: nil, etag: nil, expire_at: nil, vary: {}, response_headers: {})
|
66
|
+
expire_at = Time.parse(expire_at) if expire_at.is_a? String
|
67
|
+
@key = key.freeze
|
68
|
+
@payload = payload.freeze
|
69
|
+
@etag = etag.freeze
|
70
|
+
@expire_at = expire_at.freeze
|
71
|
+
@vary = vary.freeze
|
72
|
+
@response_headers = response_headers
|
73
|
+
freeze
|
74
|
+
end
|
75
|
+
|
76
|
+
def expired?
|
77
|
+
not fresh?
|
78
|
+
end
|
79
|
+
|
80
|
+
def fresh?
|
81
|
+
!!(expire_at && expire_at >= Time.now)
|
82
|
+
end
|
83
|
+
|
84
|
+
def valid?
|
85
|
+
return false unless payload?
|
86
|
+
fresh?
|
87
|
+
end
|
88
|
+
|
89
|
+
def invalid?
|
90
|
+
!valid?
|
91
|
+
end
|
92
|
+
|
93
|
+
def payload?
|
94
|
+
!!(payload && !payload.empty?)
|
95
|
+
end
|
96
|
+
|
97
|
+
def storable?
|
98
|
+
return false unless payload?
|
99
|
+
!!(etag || expire_at)
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
@@ -0,0 +1,186 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'tmpdir'
|
4
|
+
require 'fileutils'
|
5
|
+
require 'securerandom'
|
6
|
+
require 'shaf_client/middleware/http_cache/base'
|
7
|
+
|
8
|
+
class ShafClient
|
9
|
+
module Middleware
|
10
|
+
class HttpCache
|
11
|
+
class FileStorage < Base
|
12
|
+
class FileEntry < Entry
|
13
|
+
attr_reader :filepath
|
14
|
+
|
15
|
+
def self.deserialize(content)
|
16
|
+
new(**JSON.parse(content, symbolize_names: true))
|
17
|
+
end
|
18
|
+
|
19
|
+
def self.from_entry(entry, filepath)
|
20
|
+
new(
|
21
|
+
key: entry.key,
|
22
|
+
etag: entry.etag,
|
23
|
+
expire_at: entry.expire_at,
|
24
|
+
vary: entry.vary,
|
25
|
+
payload: entry.payload,
|
26
|
+
filepath: filepath,
|
27
|
+
response_headers: entry.response_headers
|
28
|
+
)
|
29
|
+
end
|
30
|
+
|
31
|
+
def initialize(
|
32
|
+
key:,
|
33
|
+
filepath:,
|
34
|
+
payload: nil,
|
35
|
+
etag: nil,
|
36
|
+
expire_at: nil,
|
37
|
+
vary: {},
|
38
|
+
response_headers: {}
|
39
|
+
)
|
40
|
+
@filepath = filepath
|
41
|
+
super(
|
42
|
+
key: key,
|
43
|
+
payload: payload,
|
44
|
+
etag: etag,
|
45
|
+
expire_at: expire_at,
|
46
|
+
vary: vary,
|
47
|
+
response_headers: response_headers
|
48
|
+
)
|
49
|
+
end
|
50
|
+
|
51
|
+
def serialize
|
52
|
+
JSON.pretty_generate(
|
53
|
+
key: key,
|
54
|
+
etag: etag,
|
55
|
+
expire_at: expire_at,
|
56
|
+
vary: vary,
|
57
|
+
filepath: filepath,
|
58
|
+
response_headers: response_headers,
|
59
|
+
payload: payload
|
60
|
+
)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def initialize(**options)
|
65
|
+
init_dir(options.delete(:directory))
|
66
|
+
end
|
67
|
+
|
68
|
+
def size
|
69
|
+
count = 0
|
70
|
+
each_file { count += 1 }
|
71
|
+
count
|
72
|
+
end
|
73
|
+
|
74
|
+
def clear
|
75
|
+
return unless Dir.exist? cache_dir
|
76
|
+
|
77
|
+
FileUtils.remove_entry_secure cache_dir
|
78
|
+
Dir.mkdir cache_dir
|
79
|
+
end
|
80
|
+
|
81
|
+
def clear_invalid
|
82
|
+
delete_if(&:invalid?)
|
83
|
+
end
|
84
|
+
|
85
|
+
def get(query)
|
86
|
+
find(query)
|
87
|
+
end
|
88
|
+
|
89
|
+
def put(entry)
|
90
|
+
existing = find(Query.from(entry))
|
91
|
+
unlink(existing) if existing
|
92
|
+
write(entry)
|
93
|
+
end
|
94
|
+
|
95
|
+
def each_file
|
96
|
+
return unless Dir.exist? cache_dir
|
97
|
+
|
98
|
+
Dir.each_child(cache_dir) do |dir|
|
99
|
+
Dir.each_child(File.join(cache_dir, dir)) do |file|
|
100
|
+
yield File.join(cache_dir, dir, file)
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
def each
|
106
|
+
each_file do |file|
|
107
|
+
yield parse(file)
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
private
|
112
|
+
|
113
|
+
attr_reader :cache_dir
|
114
|
+
|
115
|
+
def init_dir(dir)
|
116
|
+
@cache_dir = String(dir)
|
117
|
+
return if !@cache_dir.empty? && File.exist?(@cache_dir)
|
118
|
+
|
119
|
+
@cache_dir = File.join(Dir.tmpdir, 'shaf_client_http_cache') if @cache_dir.empty?
|
120
|
+
Dir.mkdir(@cache_dir) unless Dir.exist? @cache_dir
|
121
|
+
end
|
122
|
+
|
123
|
+
def delete_if
|
124
|
+
each do |entry|
|
125
|
+
unlink(entry) if yield entry
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
def find(query)
|
130
|
+
dir = dir(query.key)
|
131
|
+
return unless dir && Dir.exist?(dir)
|
132
|
+
|
133
|
+
Dir.each_child(dir) do |filename|
|
134
|
+
path = File.join(dir, filename)
|
135
|
+
file_entry = parse(path)
|
136
|
+
return file_entry if query.match?(file_entry.vary)
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
def dir(key)
|
141
|
+
File.join(cache_dir, key.to_s.tr('/', '_'))
|
142
|
+
end
|
143
|
+
|
144
|
+
def parse(path)
|
145
|
+
raise Error.new("File not readable: #{path}") unless File.readable? path
|
146
|
+
|
147
|
+
content = File.read(path)
|
148
|
+
FileEntry.deserialize(content)
|
149
|
+
end
|
150
|
+
|
151
|
+
def unlink(entry)
|
152
|
+
File.unlink(entry.filepath) if entry.filepath
|
153
|
+
dir = File.dirname(entry.filepath)
|
154
|
+
Dir.delete(dir) if Dir.empty? dir
|
155
|
+
end
|
156
|
+
|
157
|
+
def write(entry)
|
158
|
+
dir = dir(entry.key)
|
159
|
+
raise Error.new("File not writable: #{dir}") unless File.writable? File.dirname(dir)
|
160
|
+
Dir.mkdir(dir) unless Dir.exist?(dir)
|
161
|
+
|
162
|
+
path = File.join(dir, filename(entry))
|
163
|
+
raise Error.new("File not writable: #{dir}") unless File.writable? dir
|
164
|
+
content = FileEntry.from_entry(entry, path).serialize
|
165
|
+
File.write(path, content)
|
166
|
+
end
|
167
|
+
|
168
|
+
def filename(entry)
|
169
|
+
[entry.expire_at, SecureRandom.hex(4)].join('_')
|
170
|
+
end
|
171
|
+
end
|
172
|
+
end
|
173
|
+
end
|
174
|
+
end
|
175
|
+
# shaf_client_http_cache
|
176
|
+
# .
|
177
|
+
# └── shaf_client_http_cache
|
178
|
+
# ├── host_posts
|
179
|
+
# │ ├── 2019-08-06T12:05:27_j2f
|
180
|
+
# │ ├── 2019-08-06T10:43:10_io1
|
181
|
+
# │ └── 2019-08-07T12:05:27_k13
|
182
|
+
# ├── host_posts_5
|
183
|
+
# │ └── 2019-08-07T22:12:23_kj1
|
184
|
+
# └── host_comments
|
185
|
+
# └── 2019-08-05T10:35:00_22m
|
186
|
+
|
@@ -0,0 +1,67 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'shaf_client/middleware/http_cache/base'
|
4
|
+
|
5
|
+
class ShafClient
|
6
|
+
module Middleware
|
7
|
+
class HttpCache
|
8
|
+
class InMemory < Base
|
9
|
+
def size
|
10
|
+
mutex.synchronize do
|
11
|
+
cache.sum { |_key, entries| entries.size }
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
def clear
|
16
|
+
mutex.synchronize { @cache = new_hash }
|
17
|
+
end
|
18
|
+
|
19
|
+
def clear_invalid
|
20
|
+
mutex.synchronize do
|
21
|
+
cache.each do |_key, entries|
|
22
|
+
entries.keep_if(&:valid?)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def get(query)
|
28
|
+
mutex.synchronize { find(query) }
|
29
|
+
end
|
30
|
+
|
31
|
+
def put(entry)
|
32
|
+
mutex.synchronize do
|
33
|
+
existing = find(Query.from(entry))
|
34
|
+
cache[entry.key].delete(existing) if existing
|
35
|
+
cache[entry.key].unshift entry
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
|
41
|
+
def mutex
|
42
|
+
@mutex ||= Mutex.new
|
43
|
+
end
|
44
|
+
|
45
|
+
def cache
|
46
|
+
@cache ||= new_hash
|
47
|
+
end
|
48
|
+
|
49
|
+
def new_hash
|
50
|
+
Hash.new { |hash, key| hash[key] = [] }
|
51
|
+
end
|
52
|
+
|
53
|
+
def delete_if(&block)
|
54
|
+
mutex.synchronize do
|
55
|
+
cache.each do |_key, entries|
|
56
|
+
entries.delete_if(&block)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def find(query)
|
62
|
+
cache[query.key].find { |e| query.match?(e.vary) }
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class ShafClient
|
4
|
+
module Middleware
|
5
|
+
class HttpCache
|
6
|
+
module Key
|
7
|
+
def key(uri)
|
8
|
+
uri = URI(uri) if uri.is_a? String
|
9
|
+
query = (uri.query || '').split('&').sort.join('&')
|
10
|
+
[uri.host, uri.path, query].join('_').to_sym
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class ShafClient
|
4
|
+
module Middleware
|
5
|
+
class HttpCache
|
6
|
+
class Query
|
7
|
+
extend Key
|
8
|
+
|
9
|
+
attr_reader :key, :headers
|
10
|
+
|
11
|
+
def self.from(env)
|
12
|
+
return from_entry(env) if env.is_a? Entry
|
13
|
+
|
14
|
+
new(
|
15
|
+
key: key(env.fetch(:url)),
|
16
|
+
headers: env.request_headers.transform_keys { |k| k.downcase.to_sym }
|
17
|
+
)
|
18
|
+
end
|
19
|
+
|
20
|
+
def self.from_entry(entry)
|
21
|
+
new(
|
22
|
+
key: entry.key,
|
23
|
+
headers: entry.vary
|
24
|
+
)
|
25
|
+
end
|
26
|
+
|
27
|
+
def initialize(key:, headers: {})
|
28
|
+
@key = key
|
29
|
+
@headers = headers
|
30
|
+
end
|
31
|
+
|
32
|
+
def match?(vary)
|
33
|
+
vary.all? do |key, value|
|
34
|
+
headers[key] == value
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -8,14 +8,14 @@ class ShafClient
|
|
8
8
|
@app = app
|
9
9
|
end
|
10
10
|
|
11
|
-
def call(
|
12
|
-
@app.call(
|
13
|
-
status =
|
14
|
-
location =
|
11
|
+
def call(env)
|
12
|
+
@app.call(env).on_complete do
|
13
|
+
status = env[:status]
|
14
|
+
location = env[:response_headers]['Location']
|
15
15
|
next unless redirect? status
|
16
16
|
next unless location
|
17
|
-
update_env(
|
18
|
-
@app.call(
|
17
|
+
update_env(env, status, location)
|
18
|
+
@app.call(env)
|
19
19
|
end
|
20
20
|
end
|
21
21
|
|
@@ -23,12 +23,12 @@ class ShafClient
|
|
23
23
|
[301, 302, 303, 307, 308].include? status
|
24
24
|
end
|
25
25
|
|
26
|
-
def update_env(
|
26
|
+
def update_env(env, status, location)
|
27
27
|
if status == 303
|
28
|
-
|
29
|
-
|
28
|
+
env[:method] = :get
|
29
|
+
env[:body] = nil
|
30
30
|
end
|
31
|
-
|
31
|
+
env[:url] = URI(location)
|
32
32
|
end
|
33
33
|
end
|
34
34
|
end
|
data/lib/shaf_client/resource.rb
CHANGED
@@ -5,7 +5,7 @@ class ShafClient
|
|
5
5
|
class Resource < BaseResource
|
6
6
|
attr_reader :http_status, :headers
|
7
7
|
|
8
|
-
class
|
8
|
+
class ResourceMapper
|
9
9
|
class << self
|
10
10
|
def all
|
11
11
|
@all ||= Hash.new(Resource)
|
@@ -22,12 +22,12 @@ class ShafClient
|
|
22
22
|
end
|
23
23
|
|
24
24
|
def self.profile(name)
|
25
|
-
|
25
|
+
ResourceMapper.set(name, self)
|
26
26
|
end
|
27
27
|
|
28
28
|
def self.build(client, payload, status = nil, headers = {})
|
29
|
-
profile = headers.fetch('content-type', '')[/profile=([\w-]+)/, 1]
|
30
|
-
|
29
|
+
profile = headers.fetch('content-type', '')[/profile=([\w-]+)\b/, 1]
|
30
|
+
ResourceMapper.for(profile).new(client, payload, status, headers)
|
31
31
|
end
|
32
32
|
|
33
33
|
def initialize(client, payload, status = nil, headers = {})
|
@@ -37,7 +37,7 @@ class ShafClient
|
|
37
37
|
super(payload)
|
38
38
|
end
|
39
39
|
|
40
|
-
%i[get put post delete patch
|
40
|
+
%i[get put post delete patch].each do |method|
|
41
41
|
define_method(method) do |rel, payload = nil, **options|
|
42
42
|
href = link(rel).href
|
43
43
|
client.send(method, href, payload: payload, **options)
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: shaf_client
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.4.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Sammy Henningsson
|
@@ -30,7 +30,7 @@ cert_chain:
|
|
30
30
|
ZMhjYR7sRczGJx+GxGU2EaR0bjRsPVlC4ywtFxoOfRG3WaJcpWGEoAoMJX6Z0bRv
|
31
31
|
M40=
|
32
32
|
-----END CERTIFICATE-----
|
33
|
-
date: 2019-
|
33
|
+
date: 2019-08-15 00:00:00.000000000 Z
|
34
34
|
dependencies:
|
35
35
|
- !ruby/object:Gem::Dependency
|
36
36
|
name: faraday
|
@@ -91,7 +91,14 @@ files:
|
|
91
91
|
- lib/shaf_client/curie.rb
|
92
92
|
- lib/shaf_client/form.rb
|
93
93
|
- lib/shaf_client/link.rb
|
94
|
-
- lib/shaf_client/middleware/
|
94
|
+
- lib/shaf_client/middleware/http_cache.rb
|
95
|
+
- lib/shaf_client/middleware/http_cache/accessor.rb
|
96
|
+
- lib/shaf_client/middleware/http_cache/base.rb
|
97
|
+
- lib/shaf_client/middleware/http_cache/entry.rb
|
98
|
+
- lib/shaf_client/middleware/http_cache/file_storage.rb
|
99
|
+
- lib/shaf_client/middleware/http_cache/in_memory.rb
|
100
|
+
- lib/shaf_client/middleware/http_cache/key.rb
|
101
|
+
- lib/shaf_client/middleware/http_cache/query.rb
|
95
102
|
- lib/shaf_client/middleware/redirect.rb
|
96
103
|
- lib/shaf_client/resource.rb
|
97
104
|
homepage: https://github.com/sammyhenningsson/shaf_client
|
metadata.gz.sig
CHANGED
Binary file
|
@@ -1,186 +0,0 @@
|
|
1
|
-
# FIXME zip payload
|
2
|
-
|
3
|
-
class ShafClient
|
4
|
-
module Middleware
|
5
|
-
class Cache
|
6
|
-
DEFAULT_THRESHOLD = 10_000
|
7
|
-
|
8
|
-
Response = Struct.new(:status, :body, :headers, keyword_init: true)
|
9
|
-
|
10
|
-
module Control
|
11
|
-
def cache_size
|
12
|
-
Cache.size
|
13
|
-
end
|
14
|
-
|
15
|
-
def clear_cache
|
16
|
-
Cache.clear
|
17
|
-
end
|
18
|
-
|
19
|
-
def clear_stale_cache
|
20
|
-
Cache.clear_stale
|
21
|
-
end
|
22
|
-
end
|
23
|
-
|
24
|
-
class << self
|
25
|
-
attr_writer :threshold
|
26
|
-
|
27
|
-
def inc_request_count
|
28
|
-
@request_count ||= 0
|
29
|
-
@request_count += 1
|
30
|
-
return if (@request_count % 500 != 0)
|
31
|
-
clear_stale
|
32
|
-
check_threshold
|
33
|
-
end
|
34
|
-
|
35
|
-
def size
|
36
|
-
cache.size
|
37
|
-
end
|
38
|
-
|
39
|
-
def clear
|
40
|
-
mutex.synchronize do
|
41
|
-
@cache = {}
|
42
|
-
end
|
43
|
-
end
|
44
|
-
|
45
|
-
def clear_stale
|
46
|
-
mutex.synchronize do
|
47
|
-
cache.delete_if { |_key, entry| expired? entry }
|
48
|
-
end
|
49
|
-
end
|
50
|
-
|
51
|
-
def get(key:, check_expiration: true)
|
52
|
-
entry = nil
|
53
|
-
mutex.synchronize do
|
54
|
-
entry = cache[key.to_sym].dup
|
55
|
-
end
|
56
|
-
return entry[:payload] if valid?(entry, check_expiration)
|
57
|
-
yield if block_given?
|
58
|
-
end
|
59
|
-
|
60
|
-
def get_etag(key:)
|
61
|
-
mutex.synchronize do
|
62
|
-
cache.dig(key.to_sym, :etag)
|
63
|
-
end
|
64
|
-
end
|
65
|
-
|
66
|
-
def store(key:, payload:, etag: nil, expire_at: nil)
|
67
|
-
return unless payload && key && (etag || expire_at)
|
68
|
-
|
69
|
-
mutex.synchronize do
|
70
|
-
cache[key.to_sym] = {
|
71
|
-
payload: payload,
|
72
|
-
etag: etag,
|
73
|
-
expire_at: expire_at
|
74
|
-
}
|
75
|
-
end
|
76
|
-
end
|
77
|
-
|
78
|
-
def threshold
|
79
|
-
@threshold ||= DEFAULT_THRESHOLD
|
80
|
-
end
|
81
|
-
|
82
|
-
private
|
83
|
-
|
84
|
-
def mutex
|
85
|
-
@mutex = Mutex.new
|
86
|
-
end
|
87
|
-
|
88
|
-
def cache
|
89
|
-
mutex.synchronize do
|
90
|
-
@cache ||= {}
|
91
|
-
end
|
92
|
-
end
|
93
|
-
|
94
|
-
def valid?(entry, check_expiration = true)
|
95
|
-
return false unless entry
|
96
|
-
return false unless entry[:payload]
|
97
|
-
return true unless check_expiration
|
98
|
-
!expired? entry
|
99
|
-
end
|
100
|
-
|
101
|
-
def expired?(entry)
|
102
|
-
return true unless entry[:expire_at]
|
103
|
-
entry[:expire_at] < Time.now
|
104
|
-
end
|
105
|
-
|
106
|
-
def check_threshold
|
107
|
-
return if size < threshold
|
108
|
-
|
109
|
-
count = 500
|
110
|
-
cache.each do |key, _value|
|
111
|
-
break if count <= 0
|
112
|
-
cache[key] = nil
|
113
|
-
count -= 1
|
114
|
-
end
|
115
|
-
cache.compact!
|
116
|
-
end
|
117
|
-
end
|
118
|
-
|
119
|
-
def initialize(app, **options)
|
120
|
-
@app = app
|
121
|
-
@options = options
|
122
|
-
end
|
123
|
-
|
124
|
-
def call(request_env)
|
125
|
-
key = cache_key(request_env[:url], request_env[:request_headers])
|
126
|
-
|
127
|
-
skip_cache = request_env[:request_headers].delete :skip_cache
|
128
|
-
|
129
|
-
if !skip_cache && request_env[:method] == :get
|
130
|
-
cached = self.class.get(key: key)
|
131
|
-
return Response.new(body: cached, headers: {}) if cached
|
132
|
-
end
|
133
|
-
|
134
|
-
add_etag(request_env, key)
|
135
|
-
|
136
|
-
@app.call(request_env).on_complete do |response_env|
|
137
|
-
# key might have changed in other middleware
|
138
|
-
key = cache_key(response_env[:url], request_env[:request_headers])
|
139
|
-
add_cached_payload(response_env, key)
|
140
|
-
cache_response(response_env, key)
|
141
|
-
self.class.inc_request_count
|
142
|
-
end
|
143
|
-
end
|
144
|
-
|
145
|
-
def add_etag(env, key = nil)
|
146
|
-
return unless %i[get head].include? env[:method]
|
147
|
-
key ||= cache_key(env[:url], env[:request_headers])
|
148
|
-
etag = self.class.get_etag(key: key)
|
149
|
-
env[:request_headers]['If-None-Match'] = etag if etag
|
150
|
-
end
|
151
|
-
|
152
|
-
def add_cached_payload(response_env, key)
|
153
|
-
return if response_env[:status] != 304
|
154
|
-
cached = self.class.get(key: key, check_expiration: false)
|
155
|
-
response_env[:body] = cached if cached
|
156
|
-
end
|
157
|
-
|
158
|
-
def cache_response(response_env, key)
|
159
|
-
etag = response_env[:response_headers]['etag']
|
160
|
-
cache_control = response_env[:response_headers]['cache-control']
|
161
|
-
expire_at = expiration(cache_control)
|
162
|
-
self.class.store(
|
163
|
-
key: key,
|
164
|
-
payload: response_env[:body],
|
165
|
-
etag: etag,
|
166
|
-
expire_at: expire_at
|
167
|
-
)
|
168
|
-
end
|
169
|
-
|
170
|
-
def cache_key(url, request_headers)
|
171
|
-
:"#{url}.#{request_headers&.dig(auth_header)}"
|
172
|
-
end
|
173
|
-
|
174
|
-
def auth_header
|
175
|
-
@options.fetch(:auth_header, 'X-AUTH-TOKEN')
|
176
|
-
end
|
177
|
-
|
178
|
-
def expiration(cache_control)
|
179
|
-
return unless cache_control
|
180
|
-
|
181
|
-
max_age = cache_control[/max-age=\s?(\d+)/, 1]
|
182
|
-
Time.now + max_age.to_i if max_age
|
183
|
-
end
|
184
|
-
end
|
185
|
-
end
|
186
|
-
end
|