elevate 0.5.0 → 0.6.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 +7 -0
- data/.gitignore +2 -0
- data/README.md +46 -32
- data/Rakefile +11 -1
- data/elevate.gemspec +1 -3
- data/lib/elevate/dsl.rb +15 -0
- data/lib/elevate/{api.rb → elevate.rb} +12 -0
- data/lib/elevate/http.rb +25 -0
- data/lib/elevate/http/errors.rb +2 -0
- data/lib/elevate/http/http_client.rb +2 -61
- data/lib/elevate/http/request.rb +89 -9
- data/lib/elevate/http/response.rb +94 -4
- data/lib/elevate/http/uri.rb +29 -0
- data/lib/elevate/io_coordinator.rb +76 -11
- data/lib/elevate/operation.rb +134 -1
- data/lib/elevate/task_context.rb +6 -0
- data/lib/elevate/version.rb +1 -1
- data/spec/elevate_spec.rb +159 -0
- data/spec/http/{http_request_spec.rb → request_spec.rb} +13 -13
- data/spec/http_spec.rb +195 -0
- data/spec/io_coordinator_spec.rb +8 -0
- metadata +19 -54
- data/spec/api_spec.rb +0 -72
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 960a321bb02005b2ff94c974ae37afbd9905b027
|
4
|
+
data.tar.gz: c5a04645e4e674e4d7285ab4c705a8b52c0561e2
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: f56b43560ddf1be5e5c1cd9cc940bf15cc44719442e5d28fb7ff7022958f67e699245ef4698e6f1ec34210f00ede2e7e57f01f07d9ec144ef8f3eab1d51af700
|
7
|
+
data.tar.gz: 64a8d83ae84ec87a4daf9f78c40f220b1763bc15a6d6370e3ff142b9619acf6e718571e033085a2675c175e031f0ddee2f1d962b40149e2c0c51a11e3410ab6f
|
data/.gitignore
CHANGED
data/README.md
CHANGED
@@ -3,20 +3,19 @@ Elevate
|
|
3
3
|
|
4
4
|
Stop scattering your domain logic across your view controller. Consolidate it to a single conceptual unit with Elevate.
|
5
5
|
|
6
|
-
[](https://codeclimate.com/github/mattgreen/elevate) [](https://travis-ci.org/mattgreen/elevate)
|
6
|
+
[](https://codeclimate.com/github/mattgreen/elevate) [](https://travis-ci.org/mattgreen/elevate) [](http://badge.fury.io/rb/elevate)
|
7
7
|
|
8
8
|
Example
|
9
9
|
-------
|
10
10
|
|
11
11
|
```ruby
|
12
12
|
@login_task = async username: username.text, password: password.text do
|
13
|
+
# This block runs on a background thread.
|
13
14
|
task do
|
14
|
-
#
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
# which blocks until the request returns, yet can be interrupted.
|
19
|
-
credentials = API.login(@username, @password)
|
15
|
+
# @username and @password correspond to the Hash keys provided to async.
|
16
|
+
args = { username: @username, password: @password }
|
17
|
+
|
18
|
+
credentials = Elevate::HTTP.post(LOGIN_URL, args)
|
20
19
|
if credentials
|
21
20
|
UserRegistration.store(credentials.username, credentials.token)
|
22
21
|
end
|
@@ -57,7 +56,7 @@ end
|
|
57
56
|
|
58
57
|
Background
|
59
58
|
-----------
|
60
|
-
Many iOS apps have fairly simple domain logic that is obscured by several programming 'taxes':
|
59
|
+
Many iOS/OS X apps have fairly simple domain logic that is obscured by several programming 'taxes':
|
61
60
|
|
62
61
|
* UI management
|
63
62
|
* asynchronous network requests
|
@@ -65,23 +64,21 @@ Many iOS apps have fairly simple domain logic that is obscured by several progra
|
|
65
64
|
|
66
65
|
These are necessary to ensure a good user experience, but they splinter your domain logic (that is, what your application does) through your view controller. Gross.
|
67
66
|
|
68
|
-
Elevate is a mini task queue for your
|
67
|
+
Elevate is a mini task queue for your app, much like Resque or Sidekiq. Rather than defining part of an operation to run on the UI thread, and a CPU-intensive portion on a background thread, Elevate is designed so you run the *entire* operation in the background, and receive notifications at various times. This has a nice side effect of consolidating all the interaction for a particular task to one place. The UI code is cleanly isolated from the non-UI code. When your tasks become complex, you can elect to extract them out to a service object.
|
69
68
|
|
70
|
-
In a sense, Elevate is almost a control-flow library: it bends the rules of
|
69
|
+
In a sense, Elevate is almost a control-flow library: it bends the rules of app development a bit to ensure that the unique value your application provides is as clear as possible.
|
71
70
|
|
72
|
-
|
71
|
+
Documentation
|
73
72
|
--------
|
73
|
+
- [Tutorial](https://github.com/mattgreen/elevate/wiki/Tutorial) - start here
|
74
74
|
|
75
|
-
|
76
|
-
* Actor-style concurrency
|
77
|
-
* Simplifies asynchronous HTTP requests when used with Elevate::HTTP
|
78
|
-
* Built atop of NSOperationQueue
|
75
|
+
- [Wiki](https://github.com/mattgreen/elevate/wiki)
|
79
76
|
|
80
77
|
Installation
|
81
78
|
------------
|
82
79
|
Update your Gemfile:
|
83
80
|
|
84
|
-
gem "elevate", "~> 0.
|
81
|
+
gem "elevate", "~> 0.6.0"
|
85
82
|
|
86
83
|
Bundle:
|
87
84
|
|
@@ -98,21 +95,15 @@ class ArtistsSearchViewController < UIViewController
|
|
98
95
|
```
|
99
96
|
|
100
97
|
Launch an async task with the `async` method:
|
101
|
-
|
102
|
-
* Pass all the data the task needs to operate (such as credentials or search terms) in to the `async` method.
|
103
|
-
* Define a block that contains a `task` block. The `task` block should contain all of your non-UI code. It will be run on a background thread. Any data passed into the `async` method will be available as instance variables, keyed by the provided hash key.
|
104
|
-
* Optionally:
|
105
|
-
* Define an `on_start` block to be run when the task starts
|
106
|
-
* Define an `on_finish` block to be run when the task finishes
|
107
|
-
* Define an `on_update` block to be called any time the task calls yield (useful for relaying status information back during long operations)
|
108
|
-
|
109
|
-
All of the `on_` blocks are called on the UI thread. `on_start` is guaranteed to precede `on_update` and `on_finish`.
|
110
|
-
|
111
98
|
```ruby
|
112
99
|
@track_task = async artist: searchBar.text do
|
113
100
|
task do
|
114
|
-
|
101
|
+
response = Elevate::HTTP.get("http://example.com/artists", query: { artist: @artist })
|
102
|
+
|
103
|
+
artist = Artist.from_hash(response)
|
115
104
|
ArtistDB.update(artist)
|
105
|
+
|
106
|
+
response["name"]
|
116
107
|
end
|
117
108
|
|
118
109
|
on_start do
|
@@ -125,13 +116,36 @@ All of the `on_` blocks are called on the UI thread. `on_start` is guaranteed to
|
|
125
116
|
end
|
126
117
|
```
|
127
118
|
|
128
|
-
|
119
|
+
If you might need to cancel the task later, call `cancel` on the object returned by `async`:
|
120
|
+
```ruby
|
121
|
+
@track_task.cancel
|
122
|
+
```
|
123
|
+
|
124
|
+
Timeouts
|
125
|
+
--------
|
126
|
+
Elevate 0.6.0 includes support for timeouts. Timeouts are declared using the `timeout` method within the `async` block. They start when an operation is queued, and automatically abort the task when the duration passes. If the task takes longer than the specified duration, the `on_timeout` callback is run.
|
129
127
|
|
130
|
-
|
128
|
+
Example:
|
131
129
|
|
132
|
-
|
133
|
-
|
134
|
-
|
130
|
+
```ruby
|
131
|
+
async do
|
132
|
+
timeout 0.1
|
133
|
+
|
134
|
+
task do
|
135
|
+
Elevate::HTTP.get("http://example.com/")
|
136
|
+
end
|
137
|
+
|
138
|
+
on_timeout do
|
139
|
+
puts 'timed out'
|
140
|
+
end
|
141
|
+
|
142
|
+
on_finish do |result, exception|
|
143
|
+
puts 'completed!'
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
|
148
|
+
```
|
135
149
|
|
136
150
|
Caveats
|
137
151
|
---------
|
data/Rakefile
CHANGED
@@ -1,6 +1,16 @@
|
|
1
1
|
$:.unshift("/Library/RubyMotion/lib")
|
2
2
|
|
3
|
-
|
3
|
+
begin
|
4
|
+
if ENV['osx']
|
5
|
+
require 'motion/project/template/osx'
|
6
|
+
else
|
7
|
+
require 'motion/project/template/ios'
|
8
|
+
end
|
9
|
+
|
10
|
+
rescue LoadError
|
11
|
+
require 'motion/project'
|
12
|
+
end
|
13
|
+
|
4
14
|
require "bundler/gem_tasks"
|
5
15
|
require "bundler/setup"
|
6
16
|
Bundler.require :default
|
data/elevate.gemspec
CHANGED
@@ -15,7 +15,5 @@ Gem::Specification.new do |gem|
|
|
15
15
|
gem.version = Elevate::VERSION
|
16
16
|
|
17
17
|
gem.add_development_dependency 'rake', '>= 0.9.0'
|
18
|
-
gem.add_development_dependency '
|
19
|
-
gem.add_development_dependency 'rb-fsevent', '~> 0.9.1'
|
20
|
-
gem.add_development_dependency 'webstub', '~> 0.3.3'
|
18
|
+
gem.add_development_dependency 'webstub', '~> 0.6.0'
|
21
19
|
end
|
data/lib/elevate/dsl.rb
CHANGED
@@ -9,6 +9,9 @@ module Elevate
|
|
9
9
|
attr_reader :task_callback
|
10
10
|
attr_reader :update_callback
|
11
11
|
|
12
|
+
attr_reader :timeout_callback
|
13
|
+
attr_reader :timeout_interval
|
14
|
+
|
12
15
|
def on_finish(&block)
|
13
16
|
raise "on_finish blocks must accept two parameters" unless block.arity == 2
|
14
17
|
|
@@ -21,6 +24,12 @@ module Elevate
|
|
21
24
|
@start_callback = block
|
22
25
|
end
|
23
26
|
|
27
|
+
def on_timeout(&block)
|
28
|
+
raise "on_timeout blocks must accept zero parameters" unless block.arity == 0
|
29
|
+
|
30
|
+
@timeout_callback = block
|
31
|
+
end
|
32
|
+
|
24
33
|
def on_update(&block)
|
25
34
|
@update_callback = block
|
26
35
|
end
|
@@ -30,5 +39,11 @@ module Elevate
|
|
30
39
|
|
31
40
|
@task_callback = block
|
32
41
|
end
|
42
|
+
|
43
|
+
def timeout(seconds)
|
44
|
+
raise "timeout argument must be a number" unless seconds.is_a?(Numeric)
|
45
|
+
|
46
|
+
@timeout_interval = seconds
|
47
|
+
end
|
33
48
|
end
|
34
49
|
end
|
@@ -1,4 +1,13 @@
|
|
1
1
|
module Elevate
|
2
|
+
# Launches a new asynchronous task.
|
3
|
+
#
|
4
|
+
# @param args [Hash]
|
5
|
+
# input arguments for the task, available to the +task+ block
|
6
|
+
#
|
7
|
+
# @return [NSOperation]
|
8
|
+
# operation representing this task
|
9
|
+
#
|
10
|
+
# @api public
|
2
11
|
def async(args = {}, &block)
|
3
12
|
with_operation(args, block) do |operation|
|
4
13
|
queue.addOperation(operation)
|
@@ -25,6 +34,9 @@ module Elevate
|
|
25
34
|
operation.on_start = Callback.new(self, dsl.start_callback) if dsl.start_callback
|
26
35
|
operation.on_finish = Callback.new(self, dsl.finish_callback) if dsl.finish_callback
|
27
36
|
operation.on_update = Callback.new(self, dsl.update_callback) if dsl.update_callback
|
37
|
+
operation.on_timeout= Callback.new(self, dsl.timeout_callback) if dsl.timeout_callback
|
38
|
+
|
39
|
+
operation.timeout = dsl.timeout_interval if dsl.timeout_interval
|
28
40
|
|
29
41
|
yield operation
|
30
42
|
|
data/lib/elevate/http.rb
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
module Elevate
|
2
|
+
module HTTP
|
3
|
+
Request::METHODS.each do |m|
|
4
|
+
define_singleton_method(m) do |url, options = {}|
|
5
|
+
coordinator = IOCoordinator.for_thread
|
6
|
+
|
7
|
+
request = Request.new(m, url, options)
|
8
|
+
|
9
|
+
coordinator.signal_blocked(request) if coordinator
|
10
|
+
response = request.response
|
11
|
+
coordinator.signal_unblocked(request) if coordinator
|
12
|
+
|
13
|
+
if error = response.error
|
14
|
+
if error.code == NSURLErrorNotConnectedToInternet
|
15
|
+
raise OfflineError, error
|
16
|
+
else
|
17
|
+
raise RequestError, response.error
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
response
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
data/lib/elevate/http/errors.rb
CHANGED
@@ -1,5 +1,6 @@
|
|
1
1
|
module Elevate
|
2
2
|
module HTTP
|
3
|
+
# Raised when a request could not be completed.
|
3
4
|
class RequestError < RuntimeError
|
4
5
|
def initialize(error)
|
5
6
|
super(error.localizedDescription)
|
@@ -10,6 +11,7 @@ module HTTP
|
|
10
11
|
attr_reader :code
|
11
12
|
end
|
12
13
|
|
14
|
+
# Raised when the internet connection is offline.
|
13
15
|
class OfflineError < RequestError
|
14
16
|
end
|
15
17
|
end
|
@@ -7,7 +7,7 @@ module HTTP
|
|
7
7
|
end
|
8
8
|
|
9
9
|
def get(path, query={}, &block)
|
10
|
-
issue(:
|
10
|
+
issue(:get, path, nil, query: query, &block)
|
11
11
|
end
|
12
12
|
|
13
13
|
def post(path, body, &block)
|
@@ -43,37 +43,7 @@ module HTTP
|
|
43
43
|
options[:headers]["Content-Type"] = "application/json"
|
44
44
|
end
|
45
45
|
|
46
|
-
|
47
|
-
raise_error(response.error) if response.error
|
48
|
-
|
49
|
-
response = JSONHTTPResponse.new(response)
|
50
|
-
if response.error == nil && block_given?
|
51
|
-
result = yield response.body
|
52
|
-
|
53
|
-
result
|
54
|
-
else
|
55
|
-
response
|
56
|
-
end
|
57
|
-
end
|
58
|
-
|
59
|
-
def raise_error(error)
|
60
|
-
if error.code == -1009
|
61
|
-
raise OfflineError, error
|
62
|
-
else
|
63
|
-
raise RequestError, response.error
|
64
|
-
end
|
65
|
-
end
|
66
|
-
|
67
|
-
def send_request(method, url, options)
|
68
|
-
request = HTTPRequest.new(method, url, options)
|
69
|
-
|
70
|
-
coordinator = IOCoordinator.for_thread
|
71
|
-
|
72
|
-
coordinator.signal_blocked(request) if coordinator
|
73
|
-
response = request.response
|
74
|
-
coordinator.signal_unblocked(request) if coordinator
|
75
|
-
|
76
|
-
response
|
46
|
+
Elevate::HTTP.send(method, url, options)
|
77
47
|
end
|
78
48
|
|
79
49
|
def url_for(path)
|
@@ -82,34 +52,5 @@ module HTTP
|
|
82
52
|
NSURL.URLWithString(path, relativeToURL:@base_url).absoluteString
|
83
53
|
end
|
84
54
|
end
|
85
|
-
|
86
|
-
class JSONHTTPResponse
|
87
|
-
def initialize(response)
|
88
|
-
@response = response
|
89
|
-
@body = decode(response.body)
|
90
|
-
end
|
91
|
-
|
92
|
-
def decode(data)
|
93
|
-
return nil if data.nil?
|
94
|
-
|
95
|
-
NSJSONSerialization.JSONObjectWithData(data, options:0, error:nil)
|
96
|
-
end
|
97
|
-
|
98
|
-
attr_reader :body
|
99
|
-
|
100
|
-
# TODO: delegate
|
101
|
-
def error
|
102
|
-
@response.error
|
103
|
-
end
|
104
|
-
|
105
|
-
def headers
|
106
|
-
@response.headers
|
107
|
-
end
|
108
|
-
|
109
|
-
def status_code
|
110
|
-
@response.status_code
|
111
|
-
end
|
112
|
-
|
113
|
-
end
|
114
55
|
end
|
115
56
|
end
|
data/lib/elevate/http/request.rb
CHANGED
@@ -1,9 +1,46 @@
|
|
1
1
|
module Elevate
|
2
2
|
module HTTP
|
3
|
-
#
|
4
|
-
|
3
|
+
# Encapsulates a HTTP request.
|
4
|
+
#
|
5
|
+
# +NSURLConnection+ is responsible for fulfilling the request. The response
|
6
|
+
# is buffered in memory as it is received, and made available through the
|
7
|
+
# +response+ method.
|
8
|
+
#
|
9
|
+
# @api public
|
10
|
+
class Request
|
5
11
|
METHODS = [:get, :post, :put, :delete, :patch, :head, :options].freeze
|
6
12
|
|
13
|
+
# Initializes a HTTP request with the specified parameters.
|
14
|
+
#
|
15
|
+
# @param [String] method
|
16
|
+
# HTTP method to use
|
17
|
+
# @param [String] url
|
18
|
+
# URL to load
|
19
|
+
# @param [Hash] options
|
20
|
+
# Options to use
|
21
|
+
#
|
22
|
+
# @option options [Hash] :query
|
23
|
+
# Hash to construct the query string from.
|
24
|
+
# @option options [Hash] :headers
|
25
|
+
# Headers to append to the request.
|
26
|
+
# @option options [Hash] :credentials
|
27
|
+
# Credentials to be used with HTTP Basic Authentication. Must have a
|
28
|
+
# +:username+ and/or +:password+ key.
|
29
|
+
# @option options [NSData] :body
|
30
|
+
# Raw bytes to use as the body.
|
31
|
+
# @option options [Hash,Array] :json
|
32
|
+
# Hash/Array to be JSON-encoded as the request body. Sets the
|
33
|
+
# +Content-Type+ header to +application/json+.
|
34
|
+
# @option options [Hash] :form
|
35
|
+
# Hash to be form encoded as the request body. Sets the +Content-Type+
|
36
|
+
# header to +application/x-www-form-urlencoded+.
|
37
|
+
#
|
38
|
+
# @raise [ArgumentError]
|
39
|
+
# if an illegal HTTP method is used
|
40
|
+
# @raise [ArgumentError]
|
41
|
+
# if the URL does not start with 'http'
|
42
|
+
# @raise [ArgumentError]
|
43
|
+
# if the +:body+ option is not an instance of +NSData+
|
7
44
|
def initialize(method, url, options={})
|
8
45
|
raise ArgumentError, "invalid HTTP method" unless METHODS.include? method.downcase
|
9
46
|
raise ArgumentError, "invalid URL" unless url.start_with? "http"
|
@@ -13,6 +50,16 @@ module HTTP
|
|
13
50
|
url += "?" + URI.encode_query(options[:query])
|
14
51
|
end
|
15
52
|
|
53
|
+
options[:headers] ||= {}
|
54
|
+
|
55
|
+
if root = options.delete(:json)
|
56
|
+
options[:body] = NSJSONSerialization.dataWithJSONObject(root, options: 0, error: nil)
|
57
|
+
options[:headers]["Content-Type"] = "application/json"
|
58
|
+
elsif root = options.delete(:form)
|
59
|
+
options[:body] = URI.encode_www_form(root).dataUsingEncoding(NSASCIIStringEncoding)
|
60
|
+
options[:headers]["Content-Type"] ||= "application/x-www-form-urlencoded"
|
61
|
+
end
|
62
|
+
|
16
63
|
@request = NSMutableURLRequest.alloc.init
|
17
64
|
@request.CachePolicy = NSURLRequestReloadIgnoringLocalCacheData
|
18
65
|
@request.HTTPBody = options[:body]
|
@@ -29,14 +76,22 @@ module HTTP
|
|
29
76
|
@request.setValue(value.to_s, forHTTPHeaderField:key.to_s)
|
30
77
|
end
|
31
78
|
|
32
|
-
@response =
|
79
|
+
@response = Response.new
|
80
|
+
@response.url = url
|
33
81
|
|
34
82
|
@connection = nil
|
35
83
|
@promise = Promise.new
|
36
84
|
end
|
37
85
|
|
86
|
+
# Cancels an in-flight request.
|
87
|
+
#
|
88
|
+
# This method is safe to call from any thread.
|
89
|
+
#
|
90
|
+
# @return [void]
|
91
|
+
#
|
92
|
+
# @api public
|
38
93
|
def cancel
|
39
|
-
return unless
|
94
|
+
return unless sent?
|
40
95
|
|
41
96
|
NetworkThread.cancel(@connection)
|
42
97
|
ActivityIndicator.instance.hide
|
@@ -44,22 +99,41 @@ module HTTP
|
|
44
99
|
@promise.fulfill(nil)
|
45
100
|
end
|
46
101
|
|
102
|
+
# Returns a response to this request, sending it if necessary
|
103
|
+
#
|
104
|
+
# This method blocks the calling thread, unless interrupted.
|
105
|
+
#
|
106
|
+
# @return [Elevate::HTTP::Response, nil]
|
107
|
+
# response to this request, or nil, if this request was canceled
|
108
|
+
#
|
109
|
+
# @api public
|
47
110
|
def response
|
48
|
-
unless
|
49
|
-
|
111
|
+
unless sent?
|
112
|
+
send
|
50
113
|
end
|
51
114
|
|
52
115
|
@promise.value
|
53
116
|
end
|
54
117
|
|
55
|
-
|
118
|
+
# Sends this request. The caller is not blocked.
|
119
|
+
#
|
120
|
+
# @return [void]
|
121
|
+
#
|
122
|
+
# @api public
|
123
|
+
def send
|
56
124
|
@connection = NSURLConnection.alloc.initWithRequest(@request, delegate:self, startImmediately:false)
|
57
125
|
|
58
126
|
NetworkThread.start(@connection)
|
59
127
|
ActivityIndicator.instance.show
|
60
128
|
end
|
61
129
|
|
62
|
-
|
130
|
+
# Returns true if this request is in-flight
|
131
|
+
#
|
132
|
+
# @return [Boolean]
|
133
|
+
# true if this request is in-flight
|
134
|
+
#
|
135
|
+
# @api public
|
136
|
+
def sent?
|
63
137
|
@connection != nil
|
64
138
|
end
|
65
139
|
|
@@ -75,7 +149,7 @@ module HTTP
|
|
75
149
|
end
|
76
150
|
|
77
151
|
def connection(connection, didFailWithError: error)
|
78
|
-
puts "ERROR: #{error.localizedDescription} (code: #{error.code})"
|
152
|
+
puts "ERROR: #{error.localizedDescription} (code: #{error.code})" unless RUBYMOTION_ENV == "test"
|
79
153
|
|
80
154
|
@response.error = error
|
81
155
|
@response.freeze
|
@@ -93,6 +167,12 @@ module HTTP
|
|
93
167
|
@promise.fulfill(@response)
|
94
168
|
end
|
95
169
|
|
170
|
+
def connection(connection, willSendRequest: request, redirectResponse: response)
|
171
|
+
@response.url = request.URL.absoluteString
|
172
|
+
|
173
|
+
request
|
174
|
+
end
|
175
|
+
|
96
176
|
def get_authorization_header(credentials)
|
97
177
|
"Basic " + Base64.encode("#{credentials[:username]}:#{credentials[:password]}")
|
98
178
|
end
|