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
         |