halcyon 0.3.7

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,53 @@
1
+ #--
2
+ # Created by Matt Todd on 2007-12-14.
3
+ # Copyright (c) 2007. All rights reserved.
4
+ #++
5
+
6
+ #--
7
+ # module
8
+ #++
9
+
10
+ module Halcyon
11
+ class Client
12
+ class Base
13
+ module Exceptions #:nodoc:
14
+
15
+ #--
16
+ # Base Halcyon Exception
17
+ #++
18
+
19
+ class Base < Exception #:nodoc:
20
+ attr_accessor :status, :error
21
+ def initialize(status, error)
22
+ @status = status
23
+ @error = error
24
+ end
25
+ end
26
+
27
+ #--
28
+ # Exception classes
29
+ #++
30
+
31
+ Halcyon::Exceptions::HTTP_ERROR_CODES.to_a.each do |http_error|
32
+ status, body = http_error
33
+ class_eval(
34
+ "class #{body.gsub(/ /,'')} < Base\n"+
35
+ " def initialize(s=#{status}, e='#{body}')\n"+
36
+ " super s, e\n"+
37
+ " end\n"+
38
+ "end"
39
+ );
40
+ end
41
+
42
+ #--
43
+ # Exception Lookup
44
+ #++
45
+
46
+ def self.lookup(status)
47
+ self.const_get(Halcyon::Exceptions::HTTP_ERROR_CODES[status].gsub(/ /,'').to_sym)
48
+ end
49
+
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,106 @@
1
+ #--
2
+ # Created by Matt Todd on 2007-12-14.
3
+ # Copyright (c) 2007. All rights reserved.
4
+ #++
5
+
6
+ #--
7
+ # module
8
+ #++
9
+
10
+ module Halcyon
11
+ class Client
12
+
13
+ # = Reverse Routing
14
+ #
15
+ # Handles URL generation from route params and action names to complement
16
+ # the routing ability in the Server.
17
+ #
18
+ # == Usage
19
+ #
20
+ # class Simple < Halcyon::Client::Base
21
+ # route do |r|
22
+ # r.match('/path/to/match').to(:action => 'do_stuff')
23
+ # {:action => 'not_found'} # the default route
24
+ # end
25
+ # def greet(name)
26
+ # get(url_for(__method__, :name => name))
27
+ # end
28
+ # end
29
+ #
30
+ # == Default Routes
31
+ #
32
+ # The default route is selected if and only if no other routes matched the
33
+ # action and params supplied as a fallback query to supply. This should
34
+ # generate an error in most cases, unless you plan to handle this exception
35
+ # specifically.
36
+ class Router
37
+
38
+ # Retrieves the last value from the +route+ call in Halcyon::Client::Base
39
+ # and, if it's a Hash, sets it to +@@default_route+ to designate the
40
+ # failover route. If +route+ is not a Hash, though, the internal default
41
+ # should be used instead (as the last returned value is probably a Route
42
+ # object returned by the +r.match().to()+ call).
43
+ #
44
+ # Used exclusively internally.
45
+ def self.default_to route
46
+ @@default_route = route.is_a?(Hash) ? route : {:action => 'not_found'}
47
+ end
48
+
49
+ # This method performs the param matching and URL generation based on the
50
+ # inputs from the +url_for+ method. (Caution: not for the feint hearted.)
51
+ def self.route(action, params)
52
+ r = nil
53
+ @@routes.each do |r|
54
+ path, pars = r
55
+ if pars[:action] == action
56
+ # if the actions match up (a pretty good sign of success), make sure the params match up
57
+ if (!pars.empty? && !params.empty? && (/(:#{params.keys.first})/ =~ path).nil?) ||
58
+ ((pars.empty? && !params.empty?) || (!pars.empty? && params.empty?))
59
+ r = nil
60
+ next
61
+ else
62
+ break
63
+ end
64
+ end
65
+ end
66
+
67
+ # make sure a route is returned even if no match is found
68
+ if r.nil?
69
+ #return default route
70
+ @@default_route
71
+ else
72
+ # params (including action and module if set) for the matching route
73
+ path = r[0].dup
74
+ # replace all params with the proper placeholder in the path
75
+ params.each{|p| path.gsub!(/:#{p[0]}/, p[1]) }
76
+ path
77
+ end
78
+ end
79
+
80
+ #--
81
+ # Route building methods
82
+ #++
83
+
84
+ # Sets up the +@@routes+ hash and begins the processing by yielding to the block.
85
+ def self.prepare
86
+ @@path = nil
87
+ @@routes = {}
88
+ yield self if block_given?
89
+ end
90
+
91
+ # Stores the path temporarily in order to put it in the hash table.
92
+ def self.match(path)
93
+ @@path = path
94
+ self
95
+ end
96
+
97
+ # Adds the final route to the hash table and clears the temporary value.
98
+ def self.to(params={})
99
+ @@routes[@@path] = params
100
+ @@path = nil
101
+ self
102
+ end
103
+
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,19 @@
1
+ #--
2
+ # Created by Matt Todd on 2007-12-14.
3
+ # Copyright (c) 2007. All rights reserved.
4
+ #++
5
+
6
+ #--
7
+ # module
8
+ #++
9
+
10
+ module Halcyon
11
+ module Exceptions #:nodoc:
12
+ HTTP_ERROR_CODES = {
13
+ 403 => "Forbidden",
14
+ 404 => "Not Found",
15
+ 406 => "Not Acceptable",
16
+ 415 => "Unsupported Media Type"
17
+ }
18
+ end
19
+ end
@@ -0,0 +1,55 @@
1
+ #--
2
+ # Created by Matt Todd on 2007-12-14.
3
+ # Copyright (c) 2007. All rights reserved.
4
+ #++
5
+
6
+ $:.unshift File.dirname(File.join('..', __FILE__))
7
+ $:.unshift File.dirname(__FILE__)
8
+
9
+ #--
10
+ # dependencies
11
+ #++
12
+
13
+ %w(halcyon rubygems rack json).each {|dep|require dep}
14
+
15
+ #--
16
+ # module
17
+ #++
18
+
19
+ module Halcyon
20
+
21
+ # = Server Communication and Protocol
22
+ #
23
+ # Server tries to comply with appropriate HTTP response codes, as found at
24
+ # <http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html>. However, all
25
+ # responses are JSON encoded as the server expects a JSON parser on the
26
+ # client side since the server should not be processing requests directly
27
+ # through the browser. The server expects the User-Agent to be one of:
28
+ # +"User-Agent" => "JSON/1.1.1 Compatible (en-US) Halcyon/0.0.12 Client/0.0.1"+
29
+ # +"User-Agent" => "JSON/1.1.1 Compatible"+
30
+ # The server also expects to accept application/json and be originated
31
+ # from the local host (though this can be overridden).
32
+ #
33
+ # = Usage
34
+ #
35
+ # For documentation on using Halcyon, check out the Halcyon::Server::Base and
36
+ # Halcyon::Client::Base classes which contain much more usage documentation.
37
+ class Server
38
+ VERSION.replace [0,3,7]
39
+ def self.version
40
+ VERSION.join('.')
41
+ end
42
+
43
+ #--
44
+ # module dependencies
45
+ #++
46
+
47
+ autoload :Base, 'halcyon/server/base'
48
+ autoload :Exceptions, 'halcyon/server/exceptions'
49
+ autoload :Router, 'halcyon/server/router'
50
+
51
+ end
52
+
53
+ end
54
+
55
+ %w(server/exceptions).each {|dep|require dep}
@@ -0,0 +1,392 @@
1
+ #--
2
+ # Created by Matt Todd on 2007-12-14.
3
+ # Copyright (c) 2007. All rights reserved.
4
+ #++
5
+
6
+ #--
7
+ # dependencies
8
+ #++
9
+
10
+ %w(logger json).each {|dep|require dep}
11
+
12
+ #--
13
+ # module
14
+ #++
15
+
16
+ module Halcyon
17
+ class Server
18
+
19
+ DEFAULT_OPTIONS = {}
20
+ ACCEPTABLE_REQUESTS = [
21
+ ["HTTP_USER_AGENT", /JSON\/1\.1\.\d+ Compatible( \(en-US\) Halcyon\/(\d+\.\d+\.\d+) Client\/(\d+\.\d+\.\d+))?/, 406, 'Not Acceptable'],
22
+ ["CONTENT_TYPE", /application\/json/, 415, 'Unsupported Media Type']
23
+ ]
24
+ ACCEPTABLE_REMOTES = ['localhost', '127.0.0.1']
25
+
26
+ # = Building Halcyon Server Apps
27
+ #
28
+ # Halcyon apps are actually little servers running on top of Rack instances
29
+ # which affords a great deal of simplicity and quickness to both design and
30
+ # performance.
31
+ #
32
+ # Building a Halcyon app consists of defining routes to map all requests
33
+ # against in order to designate what functionality handles what specific
34
+ # requests, the actual actions (and modules) to actually perform these
35
+ # requests, and any extensions or configurations you may need or want for
36
+ # your individual needs.
37
+ #
38
+ # == Inheriting from Halcyon::Server::Base
39
+ #
40
+ # To begin with, an application would be started by simply defining a class
41
+ # that inherits from Halcyon::Server::Base.
42
+ #
43
+ # class Greeter < Halcyon::Server::Base
44
+ # end
45
+ #
46
+ # Once this task has been completed, routes can be defined.
47
+ #
48
+ # class Greeter < Halcyon::Server::Base
49
+ # route do |r|
50
+ # r.match('/hello/:name').to(:action => 'greet')
51
+ # {:action => 'not_found'} # default route
52
+ # end
53
+ # end
54
+ #
55
+ # Two routes are (effectively) defined here, the first being to watch for
56
+ # all requests in the format +/hello/:name+ where the word pattern is
57
+ # stored and transmitted as the appropriate keys in the params hash.
58
+ #
59
+ # Once we've got our inputs specified, we can start to handle requests:
60
+ #
61
+ # class Greeter < Halcyon::Server::Base
62
+ # route do |r|
63
+ # r.match('/hello/:name').to(:action => 'greet')
64
+ # {:action => 'not_found'} # default route
65
+ # end
66
+ # def greet(p); {:status=>200, :body=>"Hi #{p[:name]}"}; end
67
+ # end
68
+ #
69
+ # You will notice that we only define the method +greet+ and that it
70
+ # returns a Hash object containing a +status+ code and +body+ content.
71
+ # This is the most basic way to send data, but if all you're doing is
72
+ # replying that the request was successful and you have data to return,
73
+ # the method +ok+ (an alias of +standard_response+) with the +body+ param
74
+ # as its sole parameter is sufficient.
75
+ #
76
+ #
77
+ # def greet(p); ok("Hi #{p[:name]}"); end
78
+ #
79
+ # You'll also notice that there's no method called +not_found+; this is
80
+ # because it is already defined and behaves almost exactly like the +ok+
81
+ # method. We could certainly overwrite +not_found+, but at this point it
82
+ # is not necessary.
83
+ #
84
+ # You should also realize that the second route is not defined. This is
85
+ # classified as the default route, the route to follow in the event that no
86
+ # route actually matches, so it doesn't need any of the extra path to match
87
+ # against.
88
+ #
89
+ # ==
90
+ class Base
91
+
92
+ #--
93
+ # Request Handling
94
+ #++
95
+
96
+ # = Handling Calls
97
+ #
98
+ # Receives the request, handles the route matching, runs the approriate
99
+ # action based on the route determined (or defaulted to) and finishes by
100
+ # responding to the client with the content returned.
101
+ #
102
+ # == Response and Output
103
+ #
104
+ # Halcyon responds in purely JSON format (except perhaps on sever server
105
+ # malfunctions that aren't caught or intended; read: bugs).
106
+ #
107
+ # The standard response is simply a JSON-encoded hash following this
108
+ # format:
109
+ #
110
+ # {:status => http_status_code, :body => response_body}
111
+ #
112
+ # Response body can be any object desired (as long as there is a
113
+ # +to_json+ method for it, which includes most core classes), usually
114
+ # containing a nested hash with appropriate data.
115
+ #
116
+ # DO NOT try to call +to_json+ on the +body+ contents as this will cause
117
+ # errors when trying to parse JSON.
118
+ def call(env)
119
+ @start_time = Time.now if $debug
120
+
121
+ # collect env information, create request and response objects, prep for dispatch
122
+ # puts env.inspect if $debug # request information (huge)
123
+ @env = env
124
+ @res = Rack::Response.new
125
+ @req = Rack::Request.new(env)
126
+
127
+ ACCEPTABLE_REMOTES.replace([@env["REMOTE_ADDR"]]) if $debug
128
+
129
+ # pre run hook
130
+ before_run(Time.now - @start_time) if respond_to? :before_run
131
+
132
+ # dispatch
133
+ @res.write(run(Router.route(env)).to_json)
134
+
135
+ # post run hook
136
+ after_run(Time.now - @start_time) if respond_to? :after_run
137
+
138
+ puts "Served #{env['REQUEST_URI']} in #{(Time.now - @start_time)}" if $debug
139
+
140
+ # finish request
141
+ @res.finish
142
+ end
143
+
144
+ # = Dispatching Requests
145
+ #
146
+ # Dispatches the routed request, handling module resolution and pulling
147
+ # all of the param values together for the action. This action is called
148
+ # by +call+ and should be transparent to your server app.
149
+ #
150
+ # One of the design elements of this method is that it rescues all
151
+ # Halcon-specific exceptions (defined innside of ::Base::Exceptions) so
152
+ # that a proper JSON response may be rendered by +call+.
153
+ #
154
+ # With this in mind, it is preferred that, for any errors that should
155
+ # result in a given HTTP Response code other than 200, an appropriate
156
+ # exception should be thrown which is then handled by this method's
157
+ # rescue clause.
158
+ #
159
+ # Refer to the Exceptions module to see a list of available Exceptions.
160
+ #
161
+ # == Acceptable Requests
162
+ #
163
+ # Halcyon is a very picky server when dealing with requests, requiring
164
+ # that clients match a given remote location, accepting JSON responses,
165
+ # and matching a certain User-Agent profile. Unless running in debug
166
+ # mode, Halcyon will reject all requests with a 403 Forbidden response
167
+ # if these requirements are not met.
168
+ #
169
+ # This means, while in development and testing, the debug flag must be
170
+ # enabled if you intend to perform initial tests through the browser.
171
+ #
172
+ # These restrictions may appear to be arbitrary, but it is simply a
173
+ # measure to prevent a live server running in production mode from being
174
+ # assaulted by unacceptable clients which keeps the server performing
175
+ # actual functions without concerning itself with non-acceptable clients.
176
+ #
177
+ # The requirements are defined by the Halcyon::Server constants:
178
+ # * +ACCEPTABLE_REQUESTS+: defines the necessary User-Agent and Accept
179
+ # headers the client must provide.
180
+ # * ACCEPTABLE_REMOTES: defines the acceptable remote origins of
181
+ # any request. This is primarily limited to
182
+ # only local requests, but can be changed.
183
+ #
184
+ # Halcyon servers are intended to be run behind other applications and
185
+ # primarily only speaking with other apps on the same machine, though
186
+ # your specific requirements may differ and change that.
187
+ #
188
+ # == Hooks, Callbacks, and Authentication
189
+ #
190
+ # There is no Authentication mechanism built in to Halcyon (for the time
191
+ # being), but there are hooks and callbacks for you to be able to ensure
192
+ # that requests are authenticated, etc.
193
+ #
194
+ # In order to set up a callback, simply define one of the following
195
+ # methods in your app's base class:
196
+ # * before_run
197
+ # * before_action
198
+ # * after_action
199
+ # * after_run
200
+ #
201
+ # This is the exact order in which the callbacks are performed if
202
+ # defined. Make use of these methods to monitor incoming and outgoing
203
+ # requests.
204
+ #
205
+ # It is preferred for these methods to throw Exceptions::Base exceptions
206
+ # (or one of its inheriters) instead of handling them manually.
207
+ def run(route)
208
+ # make sure the request meets our expectations
209
+ ACCEPTABLE_REQUESTS.each do |req|
210
+ raise Exceptions::Base.new(req[2], req[3]) unless @env[req[0]] =~ req[1]
211
+ end
212
+ raise Exceptions::Forbidden.new unless ACCEPTABLE_REMOTES.member? @env["REMOTE_ADDR"]
213
+
214
+ # pull params
215
+ params = route.reject{|key, val| [:action, :module].include? key}
216
+ params.merge!(query_params)
217
+
218
+ # pre call hook
219
+ before_call(route, params) if respond_to? :before_call
220
+
221
+ # handle module actions differently than non-module actions
222
+ if route[:module].nil?
223
+ # call action
224
+ res = send(route[:action], params)
225
+ else
226
+ # call module action
227
+ mod = self.dup
228
+ mod.instance_eval(&(@@modules[route[:module].to_sym]))
229
+ res = mod.send(route[:action], params)
230
+ end
231
+
232
+ # after call hook
233
+ after_call if respond_to? :after_call
234
+
235
+ res
236
+ rescue Exceptions::Base => e
237
+ # puts @env.inspect if $debug
238
+ # handles all content error exceptions
239
+ @res.status = e.status
240
+ {:status => e.status, :body => e.error}
241
+ end
242
+
243
+ #--
244
+ # Initialization and setup
245
+ #++
246
+
247
+ # Called when the Handler gets started and stores the configuration
248
+ # options used to start the server.
249
+ def initialize(options = {})
250
+ # debug mode handling
251
+ if $debug
252
+ puts "Entering debugging mode..."
253
+ @logger = Logger.new(STDOUT)
254
+ ACCEPTABLE_REQUESTS.replace([
255
+ ["HTTP_USER_AGENT", /.*/, 406, 'Not Acceptable'],
256
+ ["HTTP_USER_AGENT", /.*/, 415, 'Unsupported Media Type'] # content type isn't set when navigating via browser
257
+ ])
258
+ end
259
+
260
+ # save configuration options
261
+ @config = DEFAULT_OPTIONS.merge(options)
262
+
263
+ # setup logging
264
+ @logger ||= Logger.new(@config[:log_file])
265
+
266
+ puts "Started. Awaiting input. Listening on #{@config[:port]}..." if $debug
267
+ end
268
+
269
+ # = Routing
270
+ #
271
+ # Halcyon expects its apps to have routes set up inside of the base class
272
+ # (the class that inherits from Halcyon::Server::Base). Routes are
273
+ # defined identically to Merb's routes (since Halcyon Router inherits all
274
+ # its functionality directly from the Merb Router).
275
+ #
276
+ # == Usage
277
+ #
278
+ # A sample Halcyon application defining and handling Routes follows:
279
+ #
280
+ # class Simple < Halcyon::Server::Base
281
+ # route do |r|
282
+ # r.match('/user/show/:id').to(:module => 'user', :action => 'show')
283
+ # r.match('/hello/:name').to(:action => 'greet')
284
+ # r.match('/').to(:action => 'index')
285
+ # {:action => 'not_found'} # default route
286
+ # end
287
+ # user do
288
+ # def show(p); ok(p[:id]); end
289
+ # end
290
+ # def greet(p); ok("Hi #{p[:name]}"); end
291
+ # def index(p); ok("..."); end
292
+ # def not_found(p); super; end
293
+ # end
294
+ #
295
+ # In this example we define numerous routes for actions and even an
296
+ # action in the 'user' module as well as handling the event that no route
297
+ # was matched (thereby passing to not_found).
298
+ #
299
+ # == Modules
300
+ #
301
+ # A module is simply a named block that whose methods get executed as if
302
+ # they were in Base but without conflicting any methods with them, very
303
+ # similar to module in Ruby. All that is required to define a module is
304
+ # something like this:
305
+ #
306
+ # admin do
307
+ # def users; ok(...); end
308
+ # end
309
+ #
310
+ # This just needs to add one directive when defining what a given route
311
+ # maps to, such as:
312
+ #
313
+ # route do |r|
314
+ # r.map('/admin/users').to(:module => 'admin', :action => 'users')
315
+ # end
316
+ #
317
+ # or, alternatively, you can just map to:
318
+ #
319
+ # r.map('/:module/:action').to()
320
+ #
321
+ # though it may be better to just explicitly state the module (for
322
+ # resolving cleanly when someone starts entering garbage that matches
323
+ # incorrectly).
324
+ #
325
+ # == More Help
326
+ #
327
+ # In addition to this, you may also find some of the documentation for
328
+ # the Router class helpful. However, since the Router is pulled directly
329
+ # from Merb, you really should look at the documentation for Merb. You
330
+ # can find the documentation on Merb's website at: http://merbivore.com/
331
+ def self.route
332
+ if block_given?
333
+ Router.prepare do |router|
334
+ Router.default_to yield(router) || {:action => 'not_found'}
335
+ end
336
+ else
337
+ abort "Halcyon::Server::Base.route expects a block to define routes."
338
+ end
339
+ end
340
+
341
+ # Registers modules internally. (This is designed in a way to prevent
342
+ # method naming collisions inside and outside of modules.)
343
+ def self.method_missing(name, *params, &proc)
344
+ @@modules ||= {}
345
+ @@modules[name] = proc
346
+ end
347
+
348
+ #--
349
+ # Properties and shortcuts
350
+ #++
351
+
352
+ # Takes +msg+ as parameter and formats it into the standard response type
353
+ # expected by an action's caller. This format is as follows:
354
+ #
355
+ # {:status => http_status_code, :body => json_encoded_body}
356
+ #
357
+ # The methods +standard_response+, +success+, and +ok+ all handle any
358
+ # textual message and puts it in the body field, defaulting to the 200
359
+ # response class status code.
360
+ def standard_response(body = 'OK')
361
+ {:status => 200, :body => body}
362
+ end
363
+ alias_method :success, :standard_response
364
+ alias_method :ok, :standard_response
365
+
366
+ # Similar to the +standard_response+ method, takes input and responds
367
+ # accordingly, which is by raising an exception (which handles formatting
368
+ # the response in the normal response hash).
369
+ def not_found(body = 'Not Found')
370
+ body = 'Not Found' if body.is_a?(Hash) && body.empty?
371
+ raise Exceptions::NotFound.new(404, body)
372
+ end
373
+
374
+ # Returns the params following the ? in a given URL as a hash
375
+ def query_params
376
+ @env['QUERY_STRING'].split(/&/).inject({}){|h,kp| k,v = kp.split(/=/); h[k] = v; h}
377
+ end
378
+
379
+ # Returns the URI requested
380
+ def uri
381
+ @env['REQUEST_URI']
382
+ end
383
+
384
+ # Returns the Request Method as a lowercase symbol
385
+ def method
386
+ @env['REQUEST_METHOD'].downcase.to_sym
387
+ end
388
+
389
+ end
390
+
391
+ end
392
+ end