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 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