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.
@@ -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