web_fetch 0.1.3 → 0.2.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.
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WebFetch
4
+ class Promise
5
+ attr_reader :uid, :request, :result
6
+
7
+ def initialize(client, options = {})
8
+ @client = client
9
+ @uid = options[:uid]
10
+ @request = Request.from_hash(options[:request])
11
+ end
12
+
13
+ def fetch(options = {})
14
+ return @result if complete?
15
+
16
+ block = options.fetch(:wait, true)
17
+ @raw_result = find_or_retrieve(block)
18
+ (@result = build_result)
19
+ end
20
+
21
+ def custom
22
+ request&.custom
23
+ end
24
+
25
+ def complete?
26
+ return false if @result.nil?
27
+ return false if pending?
28
+ return true if @result
29
+
30
+ false
31
+ end
32
+
33
+ def pending?
34
+ return false if @result.nil?
35
+
36
+ @result == :pending
37
+ end
38
+
39
+ def success?
40
+ complete? && @raw_result[:response][:success]
41
+ end
42
+
43
+ def error
44
+ return nil unless complete?
45
+
46
+ @raw_result[:response][:error]
47
+ end
48
+
49
+ private
50
+
51
+ def find_or_retrieve(block)
52
+ block ? @client.retrieve_by_uid(@uid) : @client.find_by_uid(@uid)
53
+ end
54
+
55
+ def build_result
56
+ return nil if @raw_result.nil?
57
+ return :pending if @raw_result[:pending]
58
+ return nil unless @raw_result[:response]
59
+
60
+ response = @raw_result[:response]
61
+ new_result(response)
62
+ end
63
+
64
+ def new_result(response)
65
+ Result.new(
66
+ body: response[:body],
67
+ headers: response[:headers],
68
+ status: response[:status],
69
+ success: @raw_result[:response][:success],
70
+ error: @raw_result[:response][:error],
71
+ uid: @uid
72
+ )
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WebFetch
4
+ class Request
5
+ attr_writer :url, :query, :headers, :body, :custom
6
+ attr_reader :url, :query, :headers, :body, :custom
7
+
8
+ def initialize
9
+ yield self
10
+ end
11
+
12
+ def method=(val)
13
+ @method = val.downcase.to_sym
14
+ end
15
+
16
+ def method
17
+ @method ||= :get
18
+ end
19
+
20
+ def to_h
21
+ {
22
+ url: url,
23
+ query: query,
24
+ headers: headers,
25
+ body: body,
26
+ method: method,
27
+ custom: custom
28
+ }
29
+ end
30
+
31
+ def eql?(other)
32
+ # Makes testing WebFetch a bit easier (based on real world case I hit
33
+ # using WebFetch in a Rails app)
34
+ other.to_h == to_h
35
+ end
36
+
37
+ def ==(other)
38
+ eql?(other)
39
+ end
40
+
41
+ def self.from_hash(hash)
42
+ hash_copy = hash.dup
43
+ request = build_request(hash_copy)
44
+ raise ArgumentError, "Unrecognized keys: #{hash}" unless hash_copy.empty?
45
+
46
+ request
47
+ end
48
+
49
+ class << self
50
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
51
+ def build_request(hash)
52
+ Request.new do |request|
53
+ request.url = hash.delete(:url) if hash.key?(:url)
54
+ request.query = hash.delete(:query) if hash.key?(:query)
55
+ request.headers = hash.delete(:headers) if hash.key?(:headers)
56
+ request.body = hash.delete(:body) if hash.key?(:body)
57
+ request.method = hash.delete(:method) if hash.key?(:method)
58
+ request.custom = hash.delete(:custom) if hash.key?(:custom)
59
+ end
60
+ end
61
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity
62
+ end
63
+ end
64
+ end
@@ -19,8 +19,8 @@ module WebFetch
19
19
  end
20
20
  end
21
21
 
22
- def retrieve(server, params)
23
- retriever = Retriever.new(server, params)
22
+ def retrieve(server, params, options = {})
23
+ retriever = Retriever.new(server, params, options)
24
24
  unless retriever.valid?
25
25
  return { status: status(:unprocessable),
26
26
  payload: { error: retriever.errors } }
@@ -28,6 +28,10 @@ module WebFetch
28
28
  defer_if_found(retriever)
29
29
  end
30
30
 
31
+ def find(server, params)
32
+ retrieve(server, params, block: false)
33
+ end
34
+
31
35
  private
32
36
 
33
37
  def status(name)
@@ -51,7 +55,7 @@ module WebFetch
51
55
  { status: status(:not_found),
52
56
  payload: { error: retriever.not_found_error } }
53
57
  else
54
- { deferred: found }
58
+ { request: found }
55
59
  end
56
60
  end
57
61
  end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WebFetch
4
+ class Result
5
+ attr_reader :body, :headers, :status, :error, :uid
6
+
7
+ def initialize(options = {})
8
+ @pending = options.fetch(:pending, false)
9
+ return if pending?
10
+
11
+ @body = options.fetch(:body)
12
+ @headers = options.fetch(:headers)
13
+ @status = options.fetch(:status)
14
+ @success = options.fetch(:success)
15
+ @error = options.fetch(:error)
16
+ @uid = options.fetch(:uid)
17
+ end
18
+
19
+ def pending?
20
+ @pending
21
+ end
22
+
23
+ def complete?
24
+ !pending?
25
+ end
26
+
27
+ def success?
28
+ @success
29
+ end
30
+ end
31
+ end
@@ -7,17 +7,20 @@ module WebFetch
7
7
 
8
8
  attr_reader :not_found_error
9
9
 
10
- def initialize(server, params)
10
+ def initialize(server, params, options)
11
11
  @uid = params[:uid]
12
12
  @hash = params[:hash]
13
13
  @server = server
14
+ @block = options.fetch(:block, true)
14
15
  end
15
16
 
16
17
  def find
17
- stored = @server.storage.fetch(@uid)
18
- return not_found if stored.nil?
18
+ request = @server.storage.fetch(@uid)
19
+ return not_found if request.nil?
20
+ return not_found if request.nil?
21
+ return request.merge(pending: true) if pending?(request)
19
22
 
20
- stored
23
+ request
21
24
  end
22
25
 
23
26
  private
@@ -35,5 +38,15 @@ module WebFetch
35
38
  end
36
39
  nil
37
40
  end
41
+
42
+ def pending?(request)
43
+ return false if request.nil?
44
+ return false if request[:succeeded]
45
+ # User requested blocking operation so we will wait until item is ready
46
+ # rather than return a `pending` status
47
+ return false if @block
48
+
49
+ true
50
+ end
38
51
  end
39
52
  end
@@ -15,19 +15,21 @@ module WebFetch
15
15
  def route(url, options = {})
16
16
  @server = options.delete(:server)
17
17
  options = { query_string: nil, method: 'GET' }.merge(options)
18
- method = options[:method].downcase.to_sym
18
+
19
19
  Logger.info("#{url}: #{options}")
20
- begin
21
- params = build_params(options)
22
- rescue JSON::ParserError
23
- return { status: 400, payload: I18n.t(:bad_json) }
24
- end
25
- @router.recognize(url, method: method).call(params)
20
+
21
+ json_params = build_params(options)
22
+ return { status: 400, payload: I18n.t(:bad_json) } if json_params.nil?
23
+
24
+ resource = @router.recognize(
25
+ url, method: normalize_http_method(options[:method])
26
+ )
27
+ resource.call(resource.params.merge(json_params))
26
28
  end
27
29
 
28
30
  private
29
31
 
30
- # rubocop:disable Metrics/MethodLength
32
+ # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
31
33
  def setup
32
34
  resource_finder = lambda do |name, env|
33
35
  Resources.public_send(name, @server, env)
@@ -42,12 +44,16 @@ module WebFetch
42
44
  resource_finder.call(:gather, params)
43
45
  }
44
46
 
45
- get '/retrieve', to: lambda { |params|
47
+ get '/retrieve/:uid', to: lambda { |params|
46
48
  resource_finder.call(:retrieve, params)
47
49
  }
50
+
51
+ get '/find/:uid', to: lambda { |params|
52
+ resource_finder.call(:find, params)
53
+ }
48
54
  end
49
55
  end
50
- # rubocop:enable Metrics/MethodLength
56
+ # rubocop:enable Metrics/MethodLength, Metrics/AbcSize
51
57
 
52
58
  def build_params(options)
53
59
  params = Rack::Utils.parse_nested_query(options[:query_string])
@@ -55,6 +61,8 @@ module WebFetch
55
61
  params = symbolize(params)
56
62
  params.merge!(options[:post_data] || {})
57
63
  params
64
+ rescue JSON::ParserError
65
+ nil
58
66
  end
59
67
 
60
68
  def merge_json(params)
@@ -67,5 +75,9 @@ module WebFetch
67
75
  def merge_json!(params)
68
76
  params.merge!(merge_json(params))
69
77
  end
78
+
79
+ def normalize_http_method(method)
80
+ method.downcase.to_sym
81
+ end
70
82
  end
71
83
  end
@@ -20,14 +20,8 @@ module WebFetch
20
20
  def process_http_request
21
21
  result = @router.route(@http_request_uri, request_params)
22
22
  response = EM::DelegatedHttpResponse.new(self)
23
-
24
23
  default_headers(response)
25
-
26
- if result[:deferred].nil?
27
- respond_immediately(result, response)
28
- else
29
- wait_for_response(result[:deferred], response)
30
- end
24
+ outcome(result, response)
31
25
  end
32
26
 
33
27
  # Note that #gather is called by WebFetch itself to asynchronously gather
@@ -35,15 +29,28 @@ module WebFetch
35
29
  # #process_http_request and subsequently WebFetch::Router#route
36
30
  def gather(targets)
37
31
  targets.each do |target|
38
- request = target[:request]
39
- async_request = EM::HttpRequest.new(request[:url])
40
- method = request.fetch(:method, 'GET').downcase.to_sym
41
- http = async_request.public_send(method,
42
- head: request[:headers],
43
- query: request.fetch(:query, {}),
44
- body: request.fetch(:body, nil))
45
- @storage.store(target[:uid], uid: target[:uid], http: http)
32
+ http = request_async(target[:request])
33
+ request = { uid: target[:uid], deferred: http }
34
+ apply_callbacks(request)
35
+ @storage.store(target[:uid], request)
46
36
  end
47
37
  end
38
+
39
+ private
40
+
41
+ def outcome(result, response)
42
+ # User requested an unrecognised ID
43
+ return respond_immediately(result, response) if result[:request].nil?
44
+
45
+ # Fetch has already completed
46
+ return succeed(result, response) if result[:succeeded]
47
+ return fail_(result, response) if result[:failed]
48
+
49
+ # User requested non-blocking call
50
+ return pending(result, response) if result[:request][:pending]
51
+
52
+ # User requested blocking call
53
+ wait_for_response(result[:request], response)
54
+ end
48
55
  end
49
56
  end
@@ -1,16 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module WebFetch
4
- # Rudimentary global storage for responses
4
+ # Rudimentary global storage for responses. The intention is that this will
5
+ # one day prescribe an interface to e.g. memcached
5
6
  class Storage
6
- @_storage = {}
7
+ @storage = {}
7
8
 
8
9
  def self.store(key, obj)
9
- @_storage[key] = obj
10
+ @storage[key] = obj
10
11
  end
11
12
 
12
13
  def self.fetch(key)
13
- @_storage.delete(key)
14
+ @storage.fetch(key, nil)
15
+ end
16
+
17
+ def self.delete(key)
18
+ @storage.delete(key)
14
19
  end
15
20
  end
16
21
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module WebFetch
4
- VERSION = '0.1.3'
4
+ VERSION = '0.2.0'
5
5
  end
data/lib/web_fetch.rb CHANGED
@@ -30,6 +30,7 @@ require 'web_fetch/event_machine_helpers'
30
30
  require 'web_fetch/http_helpers'
31
31
  require 'web_fetch/concerns/validatable'
32
32
  require 'web_fetch/concerns/http_helpers'
33
+ require 'web_fetch/concerns/client_http'
33
34
  require 'web_fetch/storage'
34
35
  require 'web_fetch/server'
35
36
  require 'web_fetch/router'
@@ -37,4 +38,8 @@ require 'web_fetch/resources'
37
38
  require 'web_fetch/gatherer'
38
39
  require 'web_fetch/retriever'
39
40
  require 'web_fetch/client'
41
+ require 'web_fetch/request'
42
+ require 'web_fetch/promise'
43
+ require 'web_fetch/result'
44
+ require 'web_fetch/errors'
40
45
  require 'web_fetch/version'
data/spec/client_spec.rb CHANGED
@@ -6,6 +6,12 @@ describe WebFetch::Client do
6
6
  before(:each) do
7
7
  stub_request(:any, 'http://blah.blah/success')
8
8
  .to_return(body: 'hello, everybody')
9
+
10
+ # XXX: This does not do what we would hope in EventMachine context as it
11
+ # locks the entire reactor. I really don't know how to make webmock hook
12
+ # into EM and delay the response so we can test #find_by_uid :(
13
+ stub_request(:any, 'http://blah.blah/slow_success')
14
+ .to_return(body: ->(_req) { sleep(0.1) && 'hi' })
9
15
  end
10
16
 
11
17
  it 'can be instantiated with host and port params' do
@@ -20,15 +26,40 @@ describe WebFetch::Client do
20
26
 
21
27
  describe '#gather' do
22
28
  it 'makes `gather` requests to a running server' do
23
- result = client.gather([{ url: 'http://blah.blah/success' }])
24
- expect(result.first[:uid]).to_not be_nil
29
+ web_request = WebFetch::Request.new do |request|
30
+ request.url = 'http://blah.blah/success'
31
+ request.custom = { my_key: 'my_value' }
32
+ end
33
+ result = client.gather([web_request])
34
+ expect(result.first).to be_a WebFetch::Promise
35
+ expect(result.first.uid).to_not be_nil
36
+ expect(result.first.custom).to eql(my_key: 'my_value')
37
+ end
38
+
39
+ it 'passes any WebFetch server errors back to the user' do
40
+ expect { client.gather([]) }.to raise_error WebFetch::ClientError
25
41
  end
26
42
  end
27
43
 
44
+ describe '#fetch' do
45
+ let(:responses) { client.gather([{ url: 'http://blah.blah/success' }]) }
46
+
47
+ subject { client.fetch(responses.first.uid) }
48
+
49
+ it { is_expected.to be_a WebFetch::Result }
50
+ context 'no matching request found' do
51
+ subject { proc { client.fetch('not-found') } }
52
+ it { is_expected.to raise_error WebFetch::RequestNotFoundError }
53
+ end
54
+
55
+ # Tested more extensively in supporting methods #retrieve_by_uid and
56
+ # #find_by_uid below
57
+ end
58
+
28
59
  describe '#retrieve_by_uid' do
29
60
  it 'retrieves a gathered item' do
30
61
  result = client.gather([{ url: 'http://blah.blah/success' }])
31
- uid = result.first[:uid]
62
+ uid = result.first.uid
32
63
 
33
64
  retrieved = client.retrieve_by_uid(uid)
34
65
  expect(retrieved[:response][:status]).to eql 200
@@ -44,6 +75,24 @@ describe WebFetch::Client do
44
75
  end
45
76
  end
46
77
 
78
+ describe '#find_by_uid' do
79
+ it 'returns a ready status when has been fetched' do
80
+ pending 'Find a good way to create a slow response without locking EM'
81
+ result = client.gather([{ url: 'http://blah.blah/slow_success' }])
82
+ uid = result.first[:uid]
83
+
84
+ found = client.find_by_uid(uid)
85
+ expect(found[:pending]).to be true
86
+ end
87
+
88
+ it 'returns nil for non-requested items' do
89
+ client.gather([{ url: 'http://blah.blah/success' }])
90
+
91
+ retrieved = client.find_by_uid('lalalala')
92
+ expect(retrieved).to be_nil
93
+ end
94
+ end
95
+
47
96
  describe '#create' do
48
97
  it 'spawns a server and returns a client able to connect' do
49
98
  client = described_class.create('localhost', 8077, log: File::NULL)
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe WebFetch::Promise do
4
+ let(:uid) { 'abc123' }
5
+ let(:request) { { url: 'http://blah', custom: { my_key: 'my_value' } } }
6
+ let(:options) { { uid: uid, request: request } }
7
+ let(:client) { WebFetch::Client.new('test-host', 8080) }
8
+ let(:response) { described_class.new(client, options) }
9
+ let(:retrieve_url) { "http://#{client.host}:#{client.port}/retrieve/#{uid}" }
10
+ let(:find_url) { "http://#{client.host}:#{client.port}/find/#{uid}" }
11
+
12
+ let(:client_success) do
13
+ double(retrieve_by_uid: { response: { success: true } })
14
+ end
15
+
16
+ let(:client_failure) do
17
+ double(retrieve_by_uid: { response: { success: false, error: 'foo' } })
18
+ end
19
+
20
+ let(:client_pending) do
21
+ double(retrieve_by_uid: { pending: true })
22
+ end
23
+
24
+ let(:client_not_started) do
25
+ double(retrieve_by_uid: nil)
26
+ end
27
+
28
+ subject { response }
29
+
30
+ it { is_expected.to be_a described_class }
31
+
32
+ describe '#fetch' do
33
+ before do
34
+ stub_request(:get, retrieve_url).to_return(body: {}.to_json)
35
+ stub_request(:get, find_url).to_return(body: {}.to_json)
36
+ end
37
+
38
+ subject { response.fetch(fetch_options) }
39
+
40
+ context 'blocking call' do
41
+ let(:fetch_options) { { wait: true } }
42
+ it { is_expected.to have_requested(:get, retrieve_url) }
43
+ end
44
+
45
+ context 'non-blocking call' do
46
+ let(:fetch_options) { { wait: false } }
47
+ it { is_expected.to have_requested(:get, find_url) }
48
+ end
49
+
50
+ context 'default (blocking)' do
51
+ subject { response.fetch }
52
+ it { is_expected.to have_requested(:get, retrieve_url) }
53
+ end
54
+ end
55
+
56
+ describe '#result' do
57
+ before do
58
+ stub_request(:get, retrieve_url)
59
+ .to_return(
60
+ body: { response: { success: true, body: 'abc123' } }.to_json
61
+ )
62
+ response.fetch
63
+ end
64
+
65
+ subject { response.result }
66
+ it { is_expected.to be_a WebFetch::Result }
67
+ its(:body) { is_expected.to eql 'abc123' }
68
+ end
69
+
70
+ describe '#complete?, #success?, #pending?, #error' do
71
+ before { response.fetch }
72
+
73
+ subject { response }
74
+
75
+ context 'request succeeded' do
76
+ let(:client) { client_success }
77
+ its(:complete?) { is_expected.to be true }
78
+ its(:success?) { is_expected.to be true }
79
+ its(:pending?) { is_expected.to be false }
80
+ its(:error) { is_expected.to be_nil }
81
+ end
82
+
83
+ context 'request failed' do
84
+ let(:client) { client_failure }
85
+ its(:complete?) { is_expected.to be true }
86
+ its(:success?) { is_expected.to be false }
87
+ its(:pending?) { is_expected.to be false }
88
+ its(:error) { is_expected.to eql 'foo' }
89
+ end
90
+
91
+ context 'request pending' do
92
+ let(:client) { client_pending }
93
+ its(:complete?) { is_expected.to be false }
94
+ its(:success?) { is_expected.to be false }
95
+ its(:pending?) { is_expected.to be true }
96
+ its(:error) { is_expected.to be_nil }
97
+ end
98
+
99
+ context 'request not started' do
100
+ let(:client) { client_not_started }
101
+ its(:complete?) { is_expected.to be false }
102
+ its(:success?) { is_expected.to be false }
103
+ its(:pending?) { is_expected.to be false }
104
+ its(:error) { is_expected.to be_nil }
105
+ end
106
+ end
107
+
108
+ describe '#request' do
109
+ subject { response.request }
110
+ it { is_expected.to be_a WebFetch::Request }
111
+ its(:custom) { is_expected.to eql(my_key: 'my_value') }
112
+ end
113
+
114
+ describe '#custom' do
115
+ # I think it's good to expose this directly on the response even though its
116
+ # accessible on as `response.request.custom`
117
+ its(:custom) { is_expected.to eql(my_key: 'my_value') }
118
+ end
119
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe WebFetch::Request do
4
+ let(:request) do
5
+ described_class.new do |request|
6
+ request.url = 'http://blah'
7
+ request.method = 'GET'
8
+ request.query = { foo: 'bar' }
9
+ request.headers = { 'Content-Type' => 'application/baz' }
10
+ request.body = 'abc123'
11
+ request.custom = { my_custom_key: 'my_custom_value' }
12
+ end
13
+ end
14
+
15
+ let(:hash) do
16
+ {
17
+ url: 'http://blah',
18
+ method: :get,
19
+ query: { foo: 'bar' },
20
+ headers: { 'Content-Type' => 'application/baz' },
21
+ body: 'abc123',
22
+ custom: { my_custom_key: 'my_custom_value' }
23
+ }
24
+ end
25
+
26
+ subject { request }
27
+ it { is_expected.to be_a described_class }
28
+ its(:url) { is_expected.to eql 'http://blah' }
29
+ its(:method) { is_expected.to eql :get } # Normalised to lower-case symbols
30
+ its(:query) { is_expected.to eql(foo: 'bar') }
31
+ its(:headers) { are_expected.to eql('Content-Type' => 'application/baz') }
32
+ its(:body) { is_expected.to eql 'abc123' }
33
+ its(:custom) { is_expected.to eql(my_custom_key: 'my_custom_value') }
34
+ its(:to_h) { is_expected.to eql hash }
35
+
36
+ describe '#get' do
37
+ let(:request) { described_class.new { |request| } }
38
+ subject { request.method }
39
+ it { is_expected.to eql :get }
40
+ end
41
+
42
+ describe '#from_hash' do
43
+ subject do
44
+ described_class.from_hash(
45
+ url: 'a', method: 'GET', query: {},
46
+ headers: {}, body: 'abc', custom: {}
47
+ )
48
+ end
49
+
50
+ it { is_expected.to be_a described_class }
51
+ its(:url) { is_expected.to eql 'a' }
52
+ its(:method) { is_expected.to eql :get }
53
+ its(:query) { is_expected.to eql({}) }
54
+ its(:headers) { are_expected.to eql({}) }
55
+ its(:body) { is_expected.to eql 'abc' }
56
+ its(:custom) { is_expected.to eql({}) }
57
+
58
+ context 'unrecognised keys' do
59
+ subject { proc { described_class.from_hash(unkown_key: 'foo') } }
60
+ it { is_expected.to raise_error(ArgumentError) }
61
+ end
62
+ end
63
+
64
+ describe '#==' do
65
+ let(:request_copy) { described_class.from_hash(request.to_h) }
66
+ it { is_expected.to eq(request_copy) }
67
+ end
68
+
69
+ describe '#eql' do
70
+ let(:request_copy) { described_class.from_hash(request.to_h) }
71
+ it { is_expected.to eql(request_copy) }
72
+ end
73
+ end