elevate 0.5.0 → 0.6.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
@@ -6,3 +6,5 @@ resources/*.momd
6
6
  resources/*.storyboardc
7
7
  .rvmrc
8
8
  Gemfile.lock
9
+ doc
10
+ .yardoc
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
- [![Code Climate](https://codeclimate.com/github/mattgreen/elevate.png)](https://codeclimate.com/github/mattgreen/elevate) [![Travis](https://api.travis-ci.org/mattgreen/elevate.png)](https://travis-ci.org/mattgreen/elevate)
6
+ [![Code Climate](https://codeclimate.com/github/mattgreen/elevate.png)](https://codeclimate.com/github/mattgreen/elevate) [![Travis](https://api.travis-ci.org/mattgreen/elevate.png)](https://travis-ci.org/mattgreen/elevate) [![Gem Version](https://badge.fury.io/rb/elevate.png)](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
- # This block runs on a background thread.
15
- #
16
- # The @username and @password instance variables correspond to the args
17
- # passed into async. API is a thin wrapper class over Elevate::HTTP,
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 iOS 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 when it starts and finishes. 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.
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 iOS development a bit to ensure that the unique value your application provides is as clear as possible. This is most apparent with how Elevate handles network I/O: it provides a blocking HTTP client built from NSURLRequest for use within your tasks. This lets you write your tasks in a simple, blocking manner, while letting Elevate handle concerns relating to cancellation, and errors.
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
- Features
71
+ Documentation
73
72
  --------
73
+ - [Tutorial](https://github.com/mattgreen/elevate/wiki/Tutorial) - start here
74
74
 
75
- * Small, beautiful DSL for describing your tasks
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.5.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
- artist = API.track(@artist)
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
- To cancel a task (like when the view controller is being dismissed), call `cancel` on the task returned by the `async` method. This causes a `CancelledError` to be raised within the task itself, which is handled by the Elevate runtime. This also prevents any callbacks you have defined from running.
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
- **NOTE: Within tasks, do not access the UI or containing view controller! It is extremely dangerous to do so. You must pass data into the `async` method to use it safely.**
128
+ Example:
131
129
 
132
- To Do
133
- -----
134
- * Need ability to set timeout for tasks
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
- require 'motion/project'
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
@@ -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 'guard-motion', '~> 0.1.1'
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
@@ -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
 
@@ -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
@@ -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(:GET, path, nil, query: query, &block)
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
- response = send_request(method, url, options)
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
@@ -1,9 +1,46 @@
1
1
  module Elevate
2
2
  module HTTP
3
- # TODO: redirects
4
- class HTTPRequest
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 = HTTPResponse.new
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 started?
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 started?
49
- start()
111
+ unless sent?
112
+ send
50
113
  end
51
114
 
52
115
  @promise.value
53
116
  end
54
117
 
55
- def start
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
- def started?
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