shaf_client 0.3.0 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7fdd37c57d1bf069216445f7aacbb04e50aa451bd91a0512f21733aa22b030d2
4
- data.tar.gz: 423457d2aa3cd2d79c959488f70ba9a4f454b5a2ce8887fc34377d7613223b0f
3
+ metadata.gz: 7299a5c0ec8bb7cb0922bb5cddee002c4007e40181bf3ded4a8d28c503185858
4
+ data.tar.gz: 576b70f8568906c69127f94ca123c9e154cd32cd0ac08efd99a14213f42aa7c6
5
5
  SHA512:
6
- metadata.gz: 77716b275f965a5f9ae1c5753497bcd5590d5e1250cd0b4241783f8bb874597b42a4ed840ad32d9945313cd64a9366294b015b7825b15aaa208aba8430e8232e
7
- data.tar.gz: a110d1773f71aa7d4d71aa04cc702e26a462df87116bd29339c5fbd60d3af001992a968493bbaeaecd5fc731931cc25ede2e2ae72f7da9bde867d3a60a307a66
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/cache'
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
- extend Middleware::Cache::Control
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
- adapter = options.fetch(:faraday_adapter, :net_http)
18
- setup options
19
+ @options = options
19
20
 
20
- @client = Faraday.new(url: root_uri) do |conn|
21
- conn.basic_auth(@user, @pass) if basic_auth?
22
- conn.use Middleware::Cache, auth_header: auth_header
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 setup(options)
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(options)
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 = @default_headers.merge(opts.fetch(: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
- .merge(_links: transform_values_to_s(links))
23
- .merge(_embedded: transform_values_to_s(embedded_resources))
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.fetch(rel.to_sym)
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.fetch(rel.to_sym)
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.fetch(rel.to_sym)
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
@@ -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 reload!
40
- self << get_form(:self, skip_cache: true)
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(request_env)
12
- @app.call(request_env).on_complete do |response_env|
13
- status = response_env[:status]
14
- location = response_env[:response_headers]['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(request_env, status, location)
18
- @app.call(request_env)
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(request_env, status, location)
26
+ def update_env(env, status, location)
27
27
  if status == 303
28
- request_env[:method] = :get
29
- request_env[:body] = nil
28
+ env[:method] = :get
29
+ env[:body] = nil
30
30
  end
31
- request_env[:url] = URI(location)
31
+ env[:url] = URI(location)
32
32
  end
33
33
  end
34
34
  end
@@ -5,7 +5,7 @@ class ShafClient
5
5
  class Resource < BaseResource
6
6
  attr_reader :http_status, :headers
7
7
 
8
- class ResourceClasses
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
- ResourceClasses.set(name, self)
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
- ResourceClasses.for(profile).new(client, payload, status, headers)
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 get_form].each do |method|
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.3.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-03-25 00:00:00.000000000 Z
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/cache.rb
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