reststop 0.1.0 → 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- data/CHANGELOG.txt +21 -1
- data/LICENSE.txt +159 -668
- data/Manifest.txt +7 -4
- data/README.txt +52 -8
- data/Rakefile +3 -2
- data/examples/blog.rb +345 -0
- data/lib/restr.rb +126 -0
- data/lib/reststop.rb +226 -39
- data/lib/reststop/version.rb +9 -0
- metadata +17 -10
data/lib/restr.rb
ADDED
@@ -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
|
data/lib/reststop.rb
CHANGED
@@ -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
|
6
|
-
# the Free Software Foundation; either version 3 of
|
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
|
15
|
-
# along with this program. If not, see
|
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
|
-
#
|
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
|
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
|
-
#
|
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
|
-
#
|
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 :
|
163
|
+
# default_format :HTML
|
132
164
|
# end
|
133
165
|
#
|
134
166
|
def render(action, format = nil)
|
135
|
-
format ||= @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}/(
|
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
|
-
|
231
|
-
|
232
|
-
|
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
|
-
|
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
|
-
|
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
|
245
|
-
|
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
|
250
|
-
|
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
|