web_fetch 0.1.3 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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