reststop 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,126 @@
1
+ require 'net/http'
2
+ require 'net/https'
3
+ require 'uri'
4
+ require 'cgi'
5
+
6
+ begin
7
+ require 'xml_simple'
8
+ rescue LoadError
9
+ require 'rubygems'
10
+ require 'active_support'
11
+ begin
12
+ require 'xml_simple'
13
+ rescue LoadError
14
+ require 'xmlsimple'
15
+ end
16
+ end
17
+
18
+
19
+ # A very simple REST client, best explained by example:
20
+ #
21
+ # # Retrieve a Kitten and print its name and colour
22
+ # kitten = Restr.get('http://example.com/kittens/1.xml')
23
+ # puts kitten['name']
24
+ # puts kitten['colour']
25
+ #
26
+ # # Create a Kitten
27
+ # kitten = Restr.post('http://example.com/kittens.xml',
28
+ # :name => 'batman', :colour => 'black')
29
+ #
30
+ # # Update a Kitten
31
+ # kitten = Restr.put('http://example.com/kittens/1.xml',
32
+ # :age => '6 months')
33
+ #
34
+ # # Delete a Kitten :(
35
+ # kitten = Restr.delete('http://example.com/kittens/1.xml')
36
+ #
37
+ # # Retrieve a list of Kittens
38
+ # kittens = Restr.get('http://example.com/kittens.xml')
39
+ #
40
+ # When the response to a Restr request has content type 'text/xml', the
41
+ # response body will be parsed from XML into a nested Hash (using XmlSimple
42
+ # -- see http://xml-simple.rubyforge.org/). Otherwise the response is
43
+ # returned untouched, as a String.
44
+ #
45
+ # If the remote REST resource requires authentication (Restr only supports
46
+ # HTTP Basic authentication, for now):
47
+ #
48
+ # Restr.get('http://example.com/kittens/1.xml, {},
49
+ # {:username => 'foo', :password => 'bar'})
50
+ #
51
+ class Restr
52
+ @@log = nil
53
+
54
+ def self.logger=(logger)
55
+ @@log = logger.dup
56
+ @@log.progname = self.name
57
+ end
58
+
59
+ def self.method_missing(method, *args)
60
+ self.do(method, args[0], args[1] || {}, args[2])
61
+ end
62
+
63
+ def self.do(method, url, params = {}, auth = nil)
64
+ uri = URI.parse(url)
65
+ params = {} unless params
66
+
67
+ method_mod = method.to_s.downcase.capitalize
68
+ unless Net::HTTP.const_defined?(method_mod)
69
+ raise InvalidRequestMethod,
70
+ "Callback method #{method.inspect} is not a valid HTTP request method."
71
+ end
72
+
73
+ if method_mod == 'Get'
74
+ q = params.collect{|k,v| "#{CGI.escape(k.to_s)}=#{CGI.escape(v.to_s)}"}.join("&")
75
+ if uri.query
76
+ uri.query += "&#{q}"
77
+ else
78
+ uri.query = q
79
+ end
80
+ end
81
+
82
+ req = Net::HTTP.const_get(method_mod).new(uri.request_uri)
83
+
84
+
85
+ if auth
86
+ raise ArgumentError,
87
+ "The `auth` parameter must be a Hash with a :username and :password value." unless
88
+ auth.kind_of? Hash
89
+ req.basic_auth auth[:username] || auth['username'], auth[:password] || auth['password']
90
+ end
91
+
92
+ unless method_mod == 'Get'
93
+ req.set_form_data(params, ';')
94
+ end
95
+
96
+ @@log.debug("Sending #{method.inspect} request to #{url.inspect} with data #{params.inspect}"+
97
+ (auth ? " with authentication" : "")+".") if @@log
98
+
99
+ client = Net::HTTP.new(uri.host, uri.port)
100
+ client.use_ssl = (uri.scheme == 'https')
101
+ res = client.start do |http|
102
+ http.request(req)
103
+ end
104
+
105
+ case res
106
+ when Net::HTTPSuccess
107
+ if res.content_type == 'text/xml'
108
+ @@log.debug("Got XML response.") if @@log
109
+ return XmlSimple.xml_in_string(res.body,
110
+ 'forcearray' => false,
111
+ 'keeproot' => false
112
+ )
113
+ else
114
+ @@log.debug("Got #{res.content_type.inspect} response.") if @@log
115
+ return res.body
116
+ end
117
+ else
118
+ $LAST_ERROR_BODY = res.body # FIXME: this is dumb
119
+ @@log.error("Got error resposne '#{res.message}(#{res.code})': #{$LAST_ERROR_BODY}") if @@log
120
+ res.error!
121
+ end
122
+ end
123
+
124
+ class InvalidRequestMethod < Exception
125
+ end
126
+ end
@@ -1,34 +1,24 @@
1
+ require 'camping'
2
+ require 'reststop/version'
3
+
1
4
  #--
2
5
  # This file is part of Reststop.
3
6
  #
4
7
  # Reststop is free software; you can redistribute it and/or modify
5
- # it under the terms of the GNU General Public License as published by
6
- # the Free Software Foundation; either version 3 of the License, or
7
- # (at your option) any later version.
8
+ # it under the terms of the GNU Lesser General Public License as
9
+ # published by the Free Software Foundation; either version 3 of
10
+ # the License, or (at your option) any later version.
8
11
  #
9
12
  # Reststop is distributed in the hope that it will be useful,
10
13
  # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
14
  # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
15
  # GNU General Public License for more details.
13
16
  #
14
- # You should have received a copy of the GNU General Public License
15
- # along with this program. If not, see <http://www.gnu.org/licenses/>.
17
+ # You should have received a copy of the GNU Lesser General Public
18
+ # License along with this program. If not, see
19
+ # <http://www.gnu.org/licenses/>.
16
20
  #--
17
21
 
18
-
19
- module Reststop
20
- unless const_defined? 'VERSION'
21
- module VERSION #:nodoc:
22
- MAJOR = 0
23
- MINOR = 1
24
- TINY = 0
25
-
26
- STRING = [MAJOR, MINOR, TINY].join('.')
27
- end
28
- end
29
- end
30
-
31
-
32
22
  # Extends and overrides Camping for convenient RESTfulness.
33
23
  #
34
24
  # Have a look at:
@@ -38,21 +28,55 @@ end
38
28
  #
39
29
  module Camping
40
30
 
41
- # Override Camping's goes() mechanism so that we can add our stuff.
42
- # ... there must be a saner way to do this >:|
31
+ # Overrides Camping's goes() mechanism so that we can add our stuff.
32
+ # ... there must be a saner way to do this? >:|
33
+ #
34
+ # Also modifies Camping's qsp() method to allow parsing of XML input data.
43
35
  #
44
36
  # FIXME: looks like this breaks auto-reloading when using the camping
45
37
  # server for launching apps :(
46
38
  S2 = IO.read(__FILE__).gsub(/^ S2 = I.+$/,'') # :nodoc:
47
39
  class << self
48
- alias_method :camping_goes, :goes
40
+ # alias_method call is conditional only to make `rake package` happy
41
+ alias_method :camping_goes, :goes
49
42
  def goes(m) # :nodoc:
50
43
  camping_goes m
51
44
  eval S2.gsub('Camping', m.to_s), TOPLEVEL_BINDING
52
45
  end
53
46
  end
54
-
55
- # This override is stolen and slightly modified from the Camping mailing list;
47
+
48
+ # Overrides Camping's query parsing method so that XML input is parsed
49
+ # into @input as an object usable more or less in the same manner as
50
+ # a standard Hash input.
51
+ #
52
+ # This is necessary for dealing with ActiveResource calls, since ActiveResource
53
+ # submits its POST and PUT data as XML instead of the standard CGI query
54
+ # string.
55
+ #
56
+ # The method automatically determines whether input is XML or standard
57
+ # CGI query and parses it accordingly.
58
+ def self.qsp(qs, d='&;', y=nil, z=H[])
59
+ if qs.kind_of?(String) && !qs.nil? && !qs.empty? && qs =~ /^<\?xml/
60
+ qxp(qs)
61
+ else
62
+ m = proc {|_,o,n|o.u(n,&m)rescue([*o]<<n)}
63
+ (qs||'').
64
+ split(/[#{d}] */n).
65
+ inject((b,z=z,H[])[0]) { |h,p| k, v=un(p).split('=',2)
66
+ h.u(k.split(/[\]\[]+/).reverse.
67
+ inject(y||v) { |x,i| H[i,x] },&m)
68
+ }
69
+ end
70
+ end
71
+
72
+ # Parse an XML query (input) into a Hash usable more or less
73
+ # the same way as a Camping's standard Hash input.
74
+ def self.qxp(qxml)
75
+ #xml = XmlSimple.xml_in_string(qxml, 'forcearray' => false)
76
+ H.new Hash.from_xml(qxml)
77
+ end
78
+
79
+ # This override is taken and slightly modified from the Camping mailing list;
56
80
  # it fakes PUT/DELETE HTTP methods, since many browsers don't support them.
57
81
  #
58
82
  # In your forms you will have to add:
@@ -73,12 +97,15 @@ module Camping
73
97
  # Overrides Camping's render method to add the ability to specify a format
74
98
  # module when rendering a view.
75
99
  #
76
- # Format is specified in one of three ways (in this order of precedence):
100
+ # The format can also be specified in other ways (shown in this order
101
+ # of precedence):
77
102
  #
78
103
  # # By providing a second parameter to render()
79
104
  # (eg: <tt>render(:foo, :HTML)</tt>)
80
105
  # # By setting the @format variable
81
106
  # # By providing a 'format' parameter in the request (i.e. @input[:format])
107
+ # # By adding a file-format extension to the url (e.g. /items.xml or
108
+ # /items/2.html).
82
109
  #
83
110
  # For example, you could have:
84
111
  #
@@ -116,6 +143,11 @@ module Camping
116
143
  # # url is /foobar/1?format=RSS
117
144
  # render(:foo) # render the RSS version of foo
118
145
  #
146
+ # or
147
+ #
148
+ # # url is /foobar/1.rss
149
+ # render(:foo) # render the RSS version of foo
150
+ #
119
151
  # If no format is specified, render() will behave like it normally does in
120
152
  # Camping, by looking for a matching view method directly
121
153
  # in the Views module.
@@ -128,18 +160,33 @@ module Camping
128
160
  # module HTML
129
161
  # # ... etc.
130
162
  # end
131
- # default_format :XML
163
+ # default_format :HTML
132
164
  # end
133
165
  #
134
166
  def render(action, format = nil)
135
- format ||= @format || @input[:format]
167
+ format ||= @format
136
168
 
137
169
  if format.nil?
170
+ begin
171
+ ct = CONTENT_TYPE
172
+ rescue NameError
173
+ ct = 'text/html'
174
+ end
175
+ @headers['Content-Type'] ||= ct
176
+
138
177
  super(action)
139
178
  else
140
179
  m = Mab.new({}, self)
141
180
  mod = "Camping::Views::#{format.to_s}".constantize
142
181
  m.extend mod
182
+
183
+ begin
184
+ ct = mod::CONTENT_TYPE
185
+ rescue NameError
186
+ ct = "text/#{format.to_s.downcase}"
187
+ end
188
+ @headers['Content-Type'] = ct
189
+
143
190
  s = m.capture{m.send(action)}
144
191
  s = m.capture{send(:layout){s}} if /^_/!~a[0].to_s and m.respond_to?(:layout)
145
192
  s
@@ -166,8 +213,16 @@ module Camping
166
213
  end
167
214
  end
168
215
 
169
- module Controllers
216
+ module Controllers
170
217
  class << self
218
+ def read_format(input, env) #:nodoc:
219
+ if input[:format] && !input[:format].empty?
220
+ input[:format].upcase.intern
221
+ elsif env['PATH_INFO'] =~ /\.([a-z]+)$/
222
+ $~[1].upcase.intern
223
+ end
224
+ end
225
+
171
226
  # Calling <tt>REST "<resource name>"</tt> creates a controller with the
172
227
  # appropriate routes and maps your REST methods to standard
173
228
  # Camping controller mehods. This is meant to be used in your Controllers
@@ -223,35 +278,167 @@ module Camping
223
278
  # # ...
224
279
  # end
225
280
  # end
281
+ #
282
+ # Additionally, format-based routing is possible. For example to get
283
+ # a list of kittens in XML format, place a GET call to /kittens.xml.
284
+ # See the documentation for the render() method for more info.
285
+ #
226
286
  def REST(r, options = {})
227
- crud = R "#{options[:prefix]}/#{r}/(.+)", "#{options[:prefix]}/#{r}"
287
+ crud = R "#{options[:prefix]}/#{r}/([0-9a-zA-Z]+)/([a-z_]+)(?:\.[a-z]+)?",
288
+ "#{options[:prefix]}/#{r}/([0-9a-zA-Z]+)(?:\.[a-z]+)?",
289
+ "#{options[:prefix]}/#{r}/([a-z_]+)(?:\.[a-z]+)?",
290
+ "#{options[:prefix]}/#{r}(?:\.[a-z]+)?"
291
+
228
292
  crud.module_eval do
293
+ meta_def(:restful?){true}
229
294
 
230
- def get(id = nil) # :nodoc:
231
- if id.nil? && @input[:id].nil?
232
- list
295
+ $LOG.debug("Creating RESTful controller for #{r.inspect} using Reststop #{::Reststop::VERSION::STRING}") if $LOG
296
+
297
+ def get(id_or_custom_action = nil, custom_action = nil) # :nodoc:
298
+ id = @input[:id] if @input[:id]
299
+
300
+ custom_action = @input[:action] if @input[:action]
301
+
302
+ if self.methods.include? id_or_custom_action
303
+ custom_action ||= id_or_custom_action
304
+ id ||= nil
233
305
  else
234
- read(id || @input[:id])
306
+ id ||= id_or_custom_action
307
+ end
308
+
309
+ id = id.to_i if id && id =~ /^[0-9]+$/
310
+
311
+ @format = Controllers.read_format(@input, @env)
312
+
313
+ begin
314
+ if id.nil? && @input[:id].nil?
315
+ custom_action ? send(custom_action) : list
316
+ else
317
+ custom_action ? send(custom_action, id || @input[:id]) : read(id || @input[:id])
318
+ end
319
+ rescue NoMethodError => e
320
+ # FIXME: this is probably not a good way to do this, but we need to somehow differentiate
321
+ # between 'no such route' vs. other NoMethodErrors
322
+ if e.message =~ /no such method/
323
+ return no_method(e)
324
+ else
325
+ raise e
326
+ end
327
+ rescue ActiveRecord::RecordNotFound => e
328
+ return not_found(e)
235
329
  end
236
330
  end
237
331
 
238
332
 
239
- def post # :nodoc:
240
- create
333
+ def post(custom_action = nil) # :nodoc:
334
+ @format = Controllers.read_format(@input, @env)
335
+ custom_action ? send(custom_action) : create
336
+ end
337
+
338
+
339
+ def put(id, custom_action = nil) # :nodoc:
340
+ id = id.to_i if id =~ /^[0-9]+$/
341
+ @format = Controllers.read_format(@input, @env)
342
+ custom_action ? send(custom_action, id || @input[:id]) : update(id || @input[:id])
241
343
  end
242
344
 
243
345
 
244
- def put(id = nil) # :nodoc:
245
- update(id || @input[:id])
346
+ def delete(id, custom_action = nil) # :nodoc:
347
+ id = id.to_i if id =~ /^[0-9]+$/
348
+ @format = Controllers.read_format(@input, @env)
349
+ custom_action ? send(custom_action, id || @input[:id]) : destroy(id || @input[:id])
350
+ end
351
+
352
+ private
353
+ def _error(message, status_code = 500, e = nil)
354
+ @status = status_code
355
+ @message = message
356
+ begin
357
+ render "error_#{status_code}".intern
358
+ rescue NoMethodError
359
+ if @format.to_s == 'XML'
360
+ "<error code='#{status_code}'>#{@message}</error>"
361
+ else
362
+ out = "<strong>#{@message}</strong>"
363
+ out += "<pre style='color: #bbb'><strong>#{e.class}: #{e}</strong>\n#{e.backtrace.join("\n")}</pre>" if e
364
+ out
365
+ end
366
+ end
246
367
  end
247
368
 
369
+ def no_method(e)
370
+ _error("No controller method responds to this route!", 501, e)
371
+ end
248
372
 
249
- def delete(id = nil) # :nodoc:
250
- destroy(id || @input[:id])
373
+ def not_found(e)
374
+ _error("Record not found!", 404, e)
251
375
  end
252
376
  end
253
377
  crud
254
378
  end
255
379
  end
256
380
  end
381
+
382
+ module Helpers
383
+ alias_method :_R, :R
384
+
385
+ # Overrides Camping's routing helper to make it possible to route RESTful resources.
386
+ #
387
+ # Some usage examples:
388
+ #
389
+ # R(Kittens) # /kittens
390
+ # R(Kittens, 'new') # /kittens/new
391
+ # R(Kittens, 1, 'meow') # /kittens/1/meow
392
+ # R(@kitten) # /kittens/1
393
+ # R(@kitten, 'meow') # /kittens/1/meow
394
+ #
395
+ # The current output format is retained, so if the current <tt>@format</tt> is <tt>:XML</tt>,
396
+ # the URL will be /kittens/1.xml rather than /kittens/1.
397
+ #
398
+ # Note that your controller names might not be loaded if you're calling <tt>R</tt> inside a
399
+ # view module. In that case you should use the fully qualified name (i.e. Myapp::Controllers::Kittens)
400
+ # or include the Controllers module into your view module.
401
+ def R(c, *g)
402
+ if Controllers.constants.include?(cl = c.class.name.split("::").last.pluralize)
403
+ path = "/#{cl.underscore}/#{c.id}"
404
+ path << ".#{@format.to_s.downcase}" if @format
405
+ path << "?#{g}" unless g.empty? # FIXME: undefined behaviour if there are multiple arguments left
406
+ self / path
407
+ elsif c.respond_to?(:restful?) && c.restful?
408
+ base = c.name.split("::").last.underscore
409
+ id = g.shift
410
+ action = g.shift
411
+ path = "/#{base}"
412
+ path << "/#{id}" if id
413
+ path << "/#{action}" if action
414
+ path << ".#{@format.to_s.downcase}" if @format
415
+ path << "?#{g}" unless g.empty? # FIXME: undefined behaviour if there are multiple arguments left
416
+ self / path
417
+ else
418
+ _R(c, *g)
419
+ end
420
+ end
421
+ end
422
+
423
+ end
424
+
425
+ module Markaby
426
+ class Builder
427
+ # Modifies Markaby's 'form' generator so that if a 'method' parameter
428
+ # is supplied, a hidden '_method' input is automatically added.
429
+ def form(*args, &block)
430
+ options = args[0] if args && args[0] && args[0].kind_of?(Hash)
431
+ inside = capture &block
432
+
433
+ if options && options.has_key?(:method)
434
+ inside = input(:type => 'hidden', :name => '_method', :value => options[:method]) +
435
+ inside
436
+ if options[:method].to_s === 'put' || options[:method].to_s == 'delete'
437
+ options[:method] = 'post'
438
+ end
439
+ end
440
+
441
+ tag!(:form, options || args[0]) {inside}
442
+ end
443
+ end
257
444
  end