halcyon 0.3.7

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