reststop 0.1.0 → 0.2.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,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