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.
- 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
|