classy_resources 0.3.1
Sign up to get free protection for your applications and to get access to all the features.
- data/README.rdoc +58 -0
- data/VERSION.yml +4 -0
- data/lib/classy_resources.rb +122 -0
- data/lib/classy_resources/active_record.rb +30 -0
- data/lib/classy_resources/mime_type.rb +216 -0
- data/lib/classy_resources/post_body_param_parsing.rb +38 -0
- data/lib/classy_resources/post_body_params.rb +41 -0
- data/lib/classy_resources/sequel.rb +35 -0
- data/lib/classy_resources/sequel_errors_to_xml.rb +19 -0
- data/test/active_record_test.rb +166 -0
- data/test/fixtures/active_record_test_app.rb +43 -0
- data/test/fixtures/sequel_test_app.rb +36 -0
- data/test/sequel_errors_to_xml_test.rb +25 -0
- data/test/sequel_test.rb +137 -0
- data/test/test_helper.rb +37 -0
- metadata +89 -0
data/README.rdoc
ADDED
@@ -0,0 +1,58 @@
|
|
1
|
+
= Classy Resources
|
2
|
+
|
3
|
+
With a simple, declarative syntax, you can create active_resource compatible REST APIs incredibly quickly.
|
4
|
+
|
5
|
+
Think resource_controller, except for Sinatra.
|
6
|
+
|
7
|
+
= Installation
|
8
|
+
|
9
|
+
sudo gem install giraffesoft-classy_resources
|
10
|
+
|
11
|
+
= Usage
|
12
|
+
|
13
|
+
require 'rubygems'
|
14
|
+
require 'sinatra'
|
15
|
+
require 'classy_resources'
|
16
|
+
require 'classy_resources/active_record'
|
17
|
+
# ... or require 'classy_resources/sequel'
|
18
|
+
# more ORMs coming (it's also easy to implement your own)...
|
19
|
+
|
20
|
+
define_resource :posts, :member => [:get, :put, :delete],
|
21
|
+
:collection => [:get, :post],
|
22
|
+
:formats => [:xml, :json, :yaml]
|
23
|
+
|
24
|
+
The above declaration will create the five actions specified, each responding to all of the formats listed.
|
25
|
+
|
26
|
+
- GET /resources.format # => index
|
27
|
+
- POST /resources.format # => create
|
28
|
+
- GET /resources/1.format # => show
|
29
|
+
- PUT /resources/1.format # => update
|
30
|
+
- DELETE /resources/1.format # => destroy
|
31
|
+
|
32
|
+
Since ClassyResources was designed to be active resource compatible, the params formats and return values are what AR expects.
|
33
|
+
|
34
|
+
= Overrides
|
35
|
+
|
36
|
+
In the above example, :posts would map to a Post class. If your class is named differently, you just override class_for. For example, if your Post class was stored in a module:
|
37
|
+
|
38
|
+
def class_for(resource)
|
39
|
+
MyModule.const_get(resource.to_s.singularize.classify.constantize)
|
40
|
+
end
|
41
|
+
|
42
|
+
Or, if you wanted to change how objects were being serialized:
|
43
|
+
|
44
|
+
def serialize(object, format)
|
45
|
+
MySerializer.new(object, format).to_s
|
46
|
+
end
|
47
|
+
|
48
|
+
Other method signatures you might want to override:
|
49
|
+
|
50
|
+
- def load_collection(resource)
|
51
|
+
- def build_object(resource, params)
|
52
|
+
- def load_object(resource, id)
|
53
|
+
- def update_object(object, params) # Note that this doesn't save. It just changes the attributes.
|
54
|
+
- def destroy_object(object)
|
55
|
+
|
56
|
+
== Copyright
|
57
|
+
|
58
|
+
Copyright (c) 2008 James Golick, Daniel Haran, GiraffeSoft Inc.. See LICENSE for details.
|
data/VERSION.yml
ADDED
@@ -0,0 +1,122 @@
|
|
1
|
+
dir = File.expand_path(File.dirname(__FILE__))
|
2
|
+
$LOAD_PATH << dir unless $LOAD_PATH.include?(dir)
|
3
|
+
require 'active_support'
|
4
|
+
require 'classy_resources/mime_type'
|
5
|
+
require 'classy_resources/post_body_params'
|
6
|
+
|
7
|
+
module ClassyResources
|
8
|
+
def class_for(resource)
|
9
|
+
resource.to_s.singularize.classify.constantize
|
10
|
+
end
|
11
|
+
|
12
|
+
def define_resource(*options)
|
13
|
+
ResourceBuilder.new(self, *options)
|
14
|
+
end
|
15
|
+
|
16
|
+
def collection_url_for(resource, format)
|
17
|
+
"/#{resource}.#{format}"
|
18
|
+
end
|
19
|
+
|
20
|
+
def object_route_url(resource, format)
|
21
|
+
"/#{resource}/:id.#{format}"
|
22
|
+
end
|
23
|
+
|
24
|
+
def object_url_for(resource, format, object)
|
25
|
+
"/#{resource}/#{object.id}.#{format}"
|
26
|
+
end
|
27
|
+
|
28
|
+
def set_content_type(format)
|
29
|
+
content_type Mime.const_get(format.to_s.upcase).to_s
|
30
|
+
end
|
31
|
+
|
32
|
+
def serialize(object, format)
|
33
|
+
object.send(:"to_#{format}")
|
34
|
+
end
|
35
|
+
|
36
|
+
class ResourceBuilder
|
37
|
+
attr_reader :resources, :options, :main, :formats
|
38
|
+
|
39
|
+
def initialize(main, *args)
|
40
|
+
@main = main
|
41
|
+
@options = args.pop if args.last.is_a?(Hash)
|
42
|
+
@resources = args
|
43
|
+
@formats = options[:formats] || :xml
|
44
|
+
|
45
|
+
build!
|
46
|
+
end
|
47
|
+
|
48
|
+
def build!
|
49
|
+
resources.each do |r|
|
50
|
+
[*formats].each do |f|
|
51
|
+
[:member, :collection].each do |t|
|
52
|
+
[*options[t]].each do |v|
|
53
|
+
send(:"define_#{t}_#{v}", r, f) unless v.nil?
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
protected
|
61
|
+
def define_collection_get(resource, format)
|
62
|
+
get collection_url_for(resource, format) do
|
63
|
+
set_content_type(format)
|
64
|
+
serialize(load_collection(resource), format)
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
def define_collection_post(resource, format)
|
69
|
+
post collection_url_for(resource, format) do
|
70
|
+
set_content_type(format)
|
71
|
+
object = build_object(resource, params[resource.to_s.singularize] || {})
|
72
|
+
|
73
|
+
if object.valid?
|
74
|
+
object.save
|
75
|
+
|
76
|
+
response['location'] = object_url_for(resource, format, object)
|
77
|
+
response.status = 201
|
78
|
+
serialize(object, format)
|
79
|
+
else
|
80
|
+
response.status = 422
|
81
|
+
serialize(object.errors, format)
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
def define_member_get(resource, format)
|
87
|
+
get object_route_url(resource, format) do
|
88
|
+
set_content_type(format)
|
89
|
+
object = load_object(resource, params[:id])
|
90
|
+
serialize(object, format)
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
def define_member_put(resource, format)
|
95
|
+
put object_route_url(resource, format) do
|
96
|
+
set_content_type(format)
|
97
|
+
object = load_object(resource, params[:id])
|
98
|
+
update_object(object, params[resource.to_s.singularize])
|
99
|
+
|
100
|
+
if object.valid?
|
101
|
+
object.save
|
102
|
+
serialize(object, format)
|
103
|
+
else
|
104
|
+
response.status = 422
|
105
|
+
serialize(object.errors, format)
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
def define_member_delete(resource, format)
|
111
|
+
delete object_route_url(resource, format) do
|
112
|
+
set_content_type(format)
|
113
|
+
object = load_object(resource, params[:id])
|
114
|
+
destroy_object(object)
|
115
|
+
""
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
include ClassyResources
|
122
|
+
|
@@ -0,0 +1,30 @@
|
|
1
|
+
module ClassyResources
|
2
|
+
module ActiveRecord
|
3
|
+
def load_collection(resource)
|
4
|
+
class_for(resource).all
|
5
|
+
end
|
6
|
+
|
7
|
+
def build_object(resource, object_params)
|
8
|
+
class_for(resource).new(object_params)
|
9
|
+
end
|
10
|
+
|
11
|
+
def load_object(resource, id)
|
12
|
+
class_for(resource).find(id)
|
13
|
+
end
|
14
|
+
|
15
|
+
def update_object(object, params)
|
16
|
+
object.attributes = params
|
17
|
+
end
|
18
|
+
|
19
|
+
def destroy_object(object)
|
20
|
+
object.destroy
|
21
|
+
end
|
22
|
+
|
23
|
+
error ::ActiveRecord::RecordNotFound do
|
24
|
+
response.status = 404
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
include ClassyResources::ActiveRecord
|
30
|
+
|
@@ -0,0 +1,216 @@
|
|
1
|
+
# borrowed from activesupport
|
2
|
+
require 'set'
|
3
|
+
|
4
|
+
module Mime
|
5
|
+
SET = []
|
6
|
+
EXTENSION_LOOKUP = Hash.new { |h, k| h[k] = Type.new(k) unless k.blank? }
|
7
|
+
LOOKUP = Hash.new { |h, k| h[k] = Type.new(k) unless k.blank? }
|
8
|
+
|
9
|
+
# Encapsulates the notion of a mime type. Can be used at render time, for example, with:
|
10
|
+
#
|
11
|
+
# class PostsController < ActionController::Base
|
12
|
+
# def show
|
13
|
+
# @post = Post.find(params[:id])
|
14
|
+
#
|
15
|
+
# respond_to do |format|
|
16
|
+
# format.html
|
17
|
+
# format.ics { render :text => post.to_ics, :mime_type => Mime::Type["text/calendar"] }
|
18
|
+
# format.xml { render :xml => @people.to_xml }
|
19
|
+
# end
|
20
|
+
# end
|
21
|
+
# end
|
22
|
+
class Type
|
23
|
+
def self.html_types; @@html_types; end
|
24
|
+
def self.browser_generated_types; @@browser_generated_types; end
|
25
|
+
|
26
|
+
@@html_types = Set.new [:html, :all]
|
27
|
+
|
28
|
+
@@browser_generated_types = Set.new [:html, :url_encoded_form, :multipart_form, :text]
|
29
|
+
|
30
|
+
# A simple helper class used in parsing the accept header
|
31
|
+
class AcceptItem #:nodoc:
|
32
|
+
attr_accessor :order, :name, :q
|
33
|
+
|
34
|
+
def initialize(order, name, q=nil)
|
35
|
+
@order = order
|
36
|
+
@name = name.strip
|
37
|
+
q ||= 0.0 if @name == Mime::ALL # default wilcard match to end of list
|
38
|
+
@q = ((q || 1.0).to_f * 100).to_i
|
39
|
+
end
|
40
|
+
|
41
|
+
def to_s
|
42
|
+
@name
|
43
|
+
end
|
44
|
+
|
45
|
+
def <=>(item)
|
46
|
+
result = item.q <=> q
|
47
|
+
result = order <=> item.order if result == 0
|
48
|
+
result
|
49
|
+
end
|
50
|
+
|
51
|
+
def ==(item)
|
52
|
+
name == (item.respond_to?(:name) ? item.name : item)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
class << self
|
57
|
+
def lookup(string)
|
58
|
+
LOOKUP[string]
|
59
|
+
end
|
60
|
+
|
61
|
+
def lookup_by_extension(extension)
|
62
|
+
EXTENSION_LOOKUP[extension]
|
63
|
+
end
|
64
|
+
|
65
|
+
# Registers an alias that's not used on mime type lookup, but can be referenced directly. Especially useful for
|
66
|
+
# rendering different HTML versions depending on the user agent, like an iPhone.
|
67
|
+
def register_alias(string, symbol, extension_synonyms = [])
|
68
|
+
register(string, symbol, [], extension_synonyms, true)
|
69
|
+
end
|
70
|
+
|
71
|
+
def register(string, symbol, mime_type_synonyms = [], extension_synonyms = [], skip_lookup = false)
|
72
|
+
Mime.instance_eval { const_set symbol.to_s.upcase, Type.new(string, symbol, mime_type_synonyms) }
|
73
|
+
|
74
|
+
SET << Mime.const_get(symbol.to_s.upcase)
|
75
|
+
|
76
|
+
([string] + mime_type_synonyms).each { |string| LOOKUP[string] = SET.last } unless skip_lookup
|
77
|
+
([symbol.to_s] + extension_synonyms).each { |ext| EXTENSION_LOOKUP[ext] = SET.last }
|
78
|
+
end
|
79
|
+
|
80
|
+
def parse(accept_header)
|
81
|
+
if accept_header !~ /,/
|
82
|
+
[Mime::Type.lookup(accept_header)]
|
83
|
+
else
|
84
|
+
# keep track of creation order to keep the subsequent sort stable
|
85
|
+
list = []
|
86
|
+
accept_header.split(/,/).each_with_index do |header, index|
|
87
|
+
params, q = header.split(/;\s*q=/)
|
88
|
+
if params
|
89
|
+
params.strip!
|
90
|
+
list << AcceptItem.new(index, params, q) unless params.empty?
|
91
|
+
end
|
92
|
+
end
|
93
|
+
list.sort!
|
94
|
+
|
95
|
+
# Take care of the broken text/xml entry by renaming or deleting it
|
96
|
+
text_xml = list.index("text/xml")
|
97
|
+
app_xml = list.index(Mime::XML.to_s)
|
98
|
+
|
99
|
+
if text_xml && app_xml
|
100
|
+
# set the q value to the max of the two
|
101
|
+
list[app_xml].q = [list[text_xml].q, list[app_xml].q].max
|
102
|
+
|
103
|
+
# make sure app_xml is ahead of text_xml in the list
|
104
|
+
if app_xml > text_xml
|
105
|
+
list[app_xml], list[text_xml] = list[text_xml], list[app_xml]
|
106
|
+
app_xml, text_xml = text_xml, app_xml
|
107
|
+
end
|
108
|
+
|
109
|
+
# delete text_xml from the list
|
110
|
+
list.delete_at(text_xml)
|
111
|
+
|
112
|
+
elsif text_xml
|
113
|
+
list[text_xml].name = Mime::XML.to_s
|
114
|
+
end
|
115
|
+
|
116
|
+
# Look for more specific XML-based types and sort them ahead of app/xml
|
117
|
+
|
118
|
+
if app_xml
|
119
|
+
idx = app_xml
|
120
|
+
app_xml_type = list[app_xml]
|
121
|
+
|
122
|
+
while(idx < list.length)
|
123
|
+
type = list[idx]
|
124
|
+
break if type.q < app_xml_type.q
|
125
|
+
if type.name =~ /\+xml$/
|
126
|
+
list[app_xml], list[idx] = list[idx], list[app_xml]
|
127
|
+
app_xml = idx
|
128
|
+
end
|
129
|
+
idx += 1
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
list.map! { |i| Mime::Type.lookup(i.name) }.uniq!
|
134
|
+
list
|
135
|
+
end
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
def initialize(string, symbol = nil, synonyms = [])
|
140
|
+
@symbol, @synonyms = symbol, synonyms
|
141
|
+
@string = string
|
142
|
+
end
|
143
|
+
|
144
|
+
def to_s
|
145
|
+
@string
|
146
|
+
end
|
147
|
+
|
148
|
+
def to_str
|
149
|
+
to_s
|
150
|
+
end
|
151
|
+
|
152
|
+
def to_sym
|
153
|
+
@symbol || @string.to_sym
|
154
|
+
end
|
155
|
+
|
156
|
+
def ===(list)
|
157
|
+
if list.is_a?(Array)
|
158
|
+
(@synonyms + [ self ]).any? { |synonym| list.include?(synonym) }
|
159
|
+
else
|
160
|
+
super
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
def ==(mime_type)
|
165
|
+
return false if mime_type.blank?
|
166
|
+
(@synonyms + [ self ]).any? do |synonym|
|
167
|
+
synonym.to_s == mime_type.to_s || synonym.to_sym == mime_type.to_sym
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
# Returns true if Action Pack should check requests using this Mime Type for possible request forgery. See
|
172
|
+
# ActionController::RequestForgeryProtection.
|
173
|
+
def verify_request?
|
174
|
+
browser_generated?
|
175
|
+
end
|
176
|
+
|
177
|
+
def html?
|
178
|
+
@@html_types.include?(to_sym) || @string =~ /html/
|
179
|
+
end
|
180
|
+
|
181
|
+
def browser_generated?
|
182
|
+
@@browser_generated_types.include?(to_sym)
|
183
|
+
end
|
184
|
+
|
185
|
+
private
|
186
|
+
def method_missing(method, *args)
|
187
|
+
if method.to_s =~ /(\w+)\?$/
|
188
|
+
$1.downcase.to_sym == to_sym
|
189
|
+
else
|
190
|
+
super
|
191
|
+
end
|
192
|
+
end
|
193
|
+
end
|
194
|
+
end
|
195
|
+
|
196
|
+
# Build list of Mime types for HTTP responses
|
197
|
+
# http://www.iana.org/assignments/media-types/
|
198
|
+
|
199
|
+
Mime::Type.register "*/*", :all
|
200
|
+
Mime::Type.register "text/plain", :text, [], %w(txt)
|
201
|
+
Mime::Type.register "text/html", :html, %w( application/xhtml+xml ), %w( xhtml )
|
202
|
+
Mime::Type.register "text/javascript", :js, %w( application/javascript application/x-javascript )
|
203
|
+
Mime::Type.register "text/css", :css
|
204
|
+
Mime::Type.register "text/calendar", :ics
|
205
|
+
Mime::Type.register "text/csv", :csv
|
206
|
+
Mime::Type.register "application/xml", :xml, %w( text/xml application/x-xml )
|
207
|
+
Mime::Type.register "application/rss+xml", :rss
|
208
|
+
Mime::Type.register "application/atom+xml", :atom
|
209
|
+
Mime::Type.register "application/x-yaml", :yaml, %w( text/yaml )
|
210
|
+
|
211
|
+
Mime::Type.register "multipart/form-data", :multipart_form
|
212
|
+
Mime::Type.register "application/x-www-form-urlencoded", :url_encoded_form
|
213
|
+
|
214
|
+
# http://www.ietf.org/rfc/rfc4627.txt
|
215
|
+
# http://www.json.org/JSONRequest.html
|
216
|
+
Mime::Type.register "application/json", :json, %w( text/x-json application/jsonrequest )
|
@@ -0,0 +1,38 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'json'
|
3
|
+
|
4
|
+
module ClassyResources
|
5
|
+
|
6
|
+
# A Rack middleware for parsing POST/PUT body data when Content-Type is
|
7
|
+
# not one of the standard supported types, like <tt>application/json</tt>.
|
8
|
+
#
|
9
|
+
# TODO: Find a better name.
|
10
|
+
#
|
11
|
+
class PostBodyParams
|
12
|
+
|
13
|
+
# Constants
|
14
|
+
#
|
15
|
+
CONTENT_TYPE = 'CONTENT_TYPE'.freeze
|
16
|
+
POST_BODY = 'rack.input'.freeze
|
17
|
+
FORM_INPUT = 'rack.request.form_input'.freeze
|
18
|
+
FORM_HASH = 'rack.request.form_hash'.freeze
|
19
|
+
|
20
|
+
# Supported Content-Types
|
21
|
+
#
|
22
|
+
APPLICATION_JSON = 'application/json'.freeze
|
23
|
+
|
24
|
+
def initialize(app)
|
25
|
+
@app = app
|
26
|
+
end
|
27
|
+
|
28
|
+
def call(env)
|
29
|
+
case env[CONTENT_TYPE]
|
30
|
+
when APPLICATION_JSON
|
31
|
+
env.update(FORM_HASH => JSON.parse(env[POST_BODY].read), FORM_INPUT => env[POST_BODY])
|
32
|
+
end
|
33
|
+
@app.call(env)
|
34
|
+
end
|
35
|
+
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
@@ -0,0 +1,41 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'json'
|
3
|
+
|
4
|
+
module ClassyResources
|
5
|
+
|
6
|
+
# A Rack middleware for parsing POST/PUT body data when Content-Type is
|
7
|
+
# not one of the standard supported types, like <tt>application/json</tt>.
|
8
|
+
#
|
9
|
+
# TODO: Find a better name.
|
10
|
+
#
|
11
|
+
class PostBodyParams
|
12
|
+
|
13
|
+
# Constants
|
14
|
+
#
|
15
|
+
CONTENT_TYPE = 'CONTENT_TYPE'.freeze
|
16
|
+
POST_BODY = 'rack.input'.freeze
|
17
|
+
FORM_INPUT = 'rack.request.form_input'.freeze
|
18
|
+
FORM_HASH = 'rack.request.form_hash'.freeze
|
19
|
+
|
20
|
+
# Supported Content-Types
|
21
|
+
#
|
22
|
+
APPLICATION_JSON = 'application/json'.freeze
|
23
|
+
APPLICATION_XML = 'application/xml'.freeze
|
24
|
+
|
25
|
+
def initialize(app)
|
26
|
+
@app = app
|
27
|
+
end
|
28
|
+
|
29
|
+
def call(env)
|
30
|
+
case env[CONTENT_TYPE]
|
31
|
+
when APPLICATION_JSON
|
32
|
+
env.update(FORM_HASH => JSON.parse(env[POST_BODY].read), FORM_INPUT => env[POST_BODY])
|
33
|
+
when APPLICATION_XML
|
34
|
+
env.update(FORM_HASH => Hash.from_xml(env[POST_BODY].read), FORM_INPUT => env[POST_BODY])
|
35
|
+
end
|
36
|
+
@app.call(env)
|
37
|
+
end
|
38
|
+
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
@@ -0,0 +1,35 @@
|
|
1
|
+
require 'classy_resources/sequel_errors_to_xml'
|
2
|
+
|
3
|
+
module ClassyResources
|
4
|
+
module Sequel
|
5
|
+
class ResourceNotFound < RuntimeError; end
|
6
|
+
|
7
|
+
def load_collection(resource)
|
8
|
+
class_for(resource).all
|
9
|
+
end
|
10
|
+
|
11
|
+
def build_object(resource, object_params)
|
12
|
+
class_for(resource).new(object_params)
|
13
|
+
end
|
14
|
+
|
15
|
+
def load_object(resource, id)
|
16
|
+
r = class_for(resource).find(:id => id)
|
17
|
+
raise ResourceNotFound if r.nil?
|
18
|
+
r
|
19
|
+
end
|
20
|
+
|
21
|
+
def update_object(object, params)
|
22
|
+
object.set params
|
23
|
+
end
|
24
|
+
|
25
|
+
def destroy_object(object)
|
26
|
+
object.destroy
|
27
|
+
end
|
28
|
+
|
29
|
+
error ResourceNotFound do
|
30
|
+
response.status = 404
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
include ClassyResources::Sequel
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# stolen from active record
|
2
|
+
#
|
3
|
+
module ClassyResources
|
4
|
+
module SequelErrorsToXml
|
5
|
+
def to_xml(options={})
|
6
|
+
options[:root] ||= "errors"
|
7
|
+
options[:indent] ||= 2
|
8
|
+
options[:builder] ||= Builder::XmlMarkup.new(:indent => options[:indent])
|
9
|
+
|
10
|
+
options[:builder].instruct! unless options.delete(:skip_instruct)
|
11
|
+
options[:builder].errors do |e|
|
12
|
+
full_messages.each { |msg| e.error(msg) }
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
::Sequel::Model::Validation::Errors.send(:include, SequelErrorsToXml)
|
18
|
+
end
|
19
|
+
|
@@ -0,0 +1,166 @@
|
|
1
|
+
require File.dirname(__FILE__) + '/test_helper'
|
2
|
+
require 'sinatra'
|
3
|
+
require 'sinatra/test/unit'
|
4
|
+
require File.dirname(__FILE__) + '/fixtures/active_record_test_app'
|
5
|
+
|
6
|
+
class ActiveRecordTest < Test::Unit::TestCase
|
7
|
+
context "on GET to /posts with xml" do
|
8
|
+
setup do
|
9
|
+
2.times { create_post }
|
10
|
+
get '/posts.xml'
|
11
|
+
end
|
12
|
+
|
13
|
+
expect { assert_equal 200, @response.status }
|
14
|
+
expect { assert_equal Post.all.to_xml, @response.body }
|
15
|
+
expect { assert_equal "application/xml", @response.content_type }
|
16
|
+
end
|
17
|
+
|
18
|
+
context "on GET to /posts with json" do
|
19
|
+
setup do
|
20
|
+
2.times { create_post }
|
21
|
+
get '/posts.json'
|
22
|
+
end
|
23
|
+
|
24
|
+
expect { assert_equal 200, @response.status }
|
25
|
+
expect { assert_equal Post.all.to_json, @response.body }
|
26
|
+
expect { assert_equal "application/json", @response.content_type }
|
27
|
+
end
|
28
|
+
|
29
|
+
context "on POST to /posts" do
|
30
|
+
setup do
|
31
|
+
Post.destroy_all
|
32
|
+
post '/posts.xml', :post => {:title => "whatever"}
|
33
|
+
end
|
34
|
+
|
35
|
+
expect { assert_equal 201, @response.status }
|
36
|
+
expect { assert_equal "/posts/#{Post.first.id}.xml", @response.location }
|
37
|
+
expect { assert_equal "whatever", Post.first.title }
|
38
|
+
expect { assert_equal "application/xml", @response.content_type }
|
39
|
+
expect { assert_equal Post.first.to_xml, @response.body }
|
40
|
+
end
|
41
|
+
|
42
|
+
context "on POST to /posts with invalid params" do
|
43
|
+
setup do
|
44
|
+
Post.destroy_all
|
45
|
+
post '/posts.xml', :post => {}
|
46
|
+
end
|
47
|
+
|
48
|
+
expect { assert_equal 422, @response.status }
|
49
|
+
expect { assert_equal "application/xml", @response.content_type }
|
50
|
+
expect { assert_equal Post.create.errors.to_xml, @response.body }
|
51
|
+
expect { assert_equal 0, Post.count }
|
52
|
+
end
|
53
|
+
|
54
|
+
context "on GET to /posts/id" do
|
55
|
+
setup do
|
56
|
+
@post = create_post
|
57
|
+
get "/posts/#{@post.id}.xml"
|
58
|
+
end
|
59
|
+
|
60
|
+
expect { assert_equal 200, @response.status }
|
61
|
+
expect { assert_equal @post.to_xml, @response.body }
|
62
|
+
expect { assert_equal "application/xml", @response.content_type }
|
63
|
+
end
|
64
|
+
|
65
|
+
context "on GET to /posts/id with a missing post" do
|
66
|
+
setup do
|
67
|
+
get "/posts/doesntexist.xml"
|
68
|
+
end
|
69
|
+
|
70
|
+
expect { assert_equal 404, @response.status }
|
71
|
+
expect { assert @response.body.empty? }
|
72
|
+
expect { assert_equal "application/xml", @response.content_type }
|
73
|
+
end
|
74
|
+
|
75
|
+
context "on PUT to /posts/id" do
|
76
|
+
setup do
|
77
|
+
@post = create_post
|
78
|
+
put "/posts/#{@post.id}.xml", :post => {:title => "Changed!"}
|
79
|
+
end
|
80
|
+
|
81
|
+
expect { assert_equal 200, @response.status }
|
82
|
+
expect { assert_equal @post.reload.to_xml, @response.body }
|
83
|
+
expect { assert_equal "application/xml", @response.content_type }
|
84
|
+
|
85
|
+
should "update the post" do
|
86
|
+
assert_equal "Changed!", @post.reload.title
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
context "on PUT to /posts/id with invalid params" do
|
91
|
+
setup do
|
92
|
+
@post = create_post
|
93
|
+
put "/posts/#{@post.id}.xml", :post => {:title => ""}
|
94
|
+
end
|
95
|
+
|
96
|
+
expect { assert_equal 422, @response.status }
|
97
|
+
expect { assert_equal "application/xml", @response.content_type }
|
98
|
+
expect { assert_equal Post.create.errors.to_xml, @response.body }
|
99
|
+
|
100
|
+
should "not update the post" do
|
101
|
+
assert_not_equal "", @post.reload.title
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
context "on PUT to /posts/id with a missing post" do
|
106
|
+
setup do
|
107
|
+
put "/posts/missing.xml", :post => {:title => "Changed!"}
|
108
|
+
end
|
109
|
+
|
110
|
+
expect { assert_equal 404, @response.status }
|
111
|
+
expect { assert @response.body.empty? }
|
112
|
+
expect { assert_equal "application/xml", @response.content_type }
|
113
|
+
end
|
114
|
+
|
115
|
+
context "on DELETE to /posts/id" do
|
116
|
+
setup do
|
117
|
+
@post = create_post
|
118
|
+
delete "/posts/#{@post.id}.xml"
|
119
|
+
end
|
120
|
+
|
121
|
+
expect { assert_equal 200, @response.status }
|
122
|
+
expect { assert_equal "application/xml", @response.content_type }
|
123
|
+
|
124
|
+
should "destroy the post" do
|
125
|
+
assert_nil Post.find_by_id(@post)
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
context "on DELETE to /posts/id with a missing post" do
|
130
|
+
setup do
|
131
|
+
delete "/posts/missing.xml"
|
132
|
+
end
|
133
|
+
|
134
|
+
expect { assert_equal 404, @response.status }
|
135
|
+
expect { assert_equal "application/xml", @response.content_type }
|
136
|
+
expect { assert @response.body.empty? }
|
137
|
+
end
|
138
|
+
|
139
|
+
context "on POST to /comments with a JSON post body" do
|
140
|
+
setup do
|
141
|
+
Comment.destroy_all
|
142
|
+
post "/comments.xml", {:comment => hash_for_comment(:author => 'james')}.to_json,
|
143
|
+
:content_type => 'application/json'
|
144
|
+
end
|
145
|
+
|
146
|
+
expect { assert_equal 201, @response.status }
|
147
|
+
expect { assert_equal "application/xml", @response.content_type }
|
148
|
+
expect { assert_equal "/comments/#{Comment.first.id}.xml", @response.location }
|
149
|
+
expect { assert_equal 1, Comment.count }
|
150
|
+
expect { assert_equal 'james', Comment.first.author }
|
151
|
+
end
|
152
|
+
|
153
|
+
context "on POST to /posts/id/comments with a XML post body" do
|
154
|
+
setup do
|
155
|
+
Comment.destroy_all
|
156
|
+
post "/comments.xml", Comment.new(:author => 'james').to_xml,
|
157
|
+
:content_type => 'application/xml'
|
158
|
+
end
|
159
|
+
|
160
|
+
expect { assert_equal 201, @response.status }
|
161
|
+
expect { assert_equal "application/xml", @response.content_type }
|
162
|
+
expect { assert_equal "/comments/#{Comment.first.id}.xml", @response.location }
|
163
|
+
expect { assert_equal 1, Comment.count }
|
164
|
+
expect { assert_equal 'james', Comment.first.author }
|
165
|
+
end
|
166
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
gem 'activerecord', '2.2.2'
|
3
|
+
require 'activerecord'
|
4
|
+
require 'sinatra'
|
5
|
+
require 'classy_resources/active_record'
|
6
|
+
|
7
|
+
ActiveRecord::Base.configurations = {'sqlite3' => {:adapter => 'sqlite3',
|
8
|
+
:database => ':memory:'}}
|
9
|
+
ActiveRecord::Base.establish_connection('sqlite3')
|
10
|
+
|
11
|
+
ActiveRecord::Base.logger = Logger.new(STDERR)
|
12
|
+
ActiveRecord::Base.logger.level = Logger::WARN
|
13
|
+
|
14
|
+
ActiveRecord::Schema.define(:version => 0) do
|
15
|
+
create_table :posts do |t|
|
16
|
+
t.string :title
|
17
|
+
end
|
18
|
+
|
19
|
+
create_table :comments do |t|
|
20
|
+
t.integer :post_id
|
21
|
+
t.string :author
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
class Post < ActiveRecord::Base
|
26
|
+
has_many :comments
|
27
|
+
validates_presence_of :title
|
28
|
+
end
|
29
|
+
|
30
|
+
class Comment < ActiveRecord::Base
|
31
|
+
belongs_to :post
|
32
|
+
end
|
33
|
+
|
34
|
+
set :raise_errors, false
|
35
|
+
|
36
|
+
define_resource :posts, :collection => [:get, :post],
|
37
|
+
:member => [:get, :put, :delete],
|
38
|
+
:formats => [:xml, :json]
|
39
|
+
|
40
|
+
define_resource :comments, :collection => [:get, :post]
|
41
|
+
|
42
|
+
use ClassyResources::PostBodyParams
|
43
|
+
|
@@ -0,0 +1,36 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'sinatra'
|
3
|
+
require 'sequel'
|
4
|
+
require 'classy_resources/sequel'
|
5
|
+
|
6
|
+
Sequel::Model.db = Sequel.sqlite
|
7
|
+
|
8
|
+
Sequel::Model.db.instance_eval do
|
9
|
+
create_table! :users do
|
10
|
+
primary_key :id
|
11
|
+
varchar :name
|
12
|
+
end
|
13
|
+
|
14
|
+
create_table! :subscriptions do
|
15
|
+
primary_key :id
|
16
|
+
int :user_id
|
17
|
+
varchar :name
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
class User < Sequel::Model(:users)
|
22
|
+
one_to_many :subscriptions
|
23
|
+
validates_presence_of :name
|
24
|
+
end
|
25
|
+
|
26
|
+
class Subscription < Sequel::Model(:subscriptions)
|
27
|
+
many_to_one :users
|
28
|
+
validates_presence_of :user_id
|
29
|
+
end
|
30
|
+
|
31
|
+
set :raise_errors, false
|
32
|
+
|
33
|
+
define_resource :users, :collection => [:get, :post],
|
34
|
+
:member => [:put, :delete, :get]
|
35
|
+
|
36
|
+
define_resource :subscriptions, :collection => [:get, :post]
|
@@ -0,0 +1,25 @@
|
|
1
|
+
require File.dirname(__FILE__) + '/test_helper'
|
2
|
+
require 'sinatra'
|
3
|
+
require 'sinatra/test/unit'
|
4
|
+
require File.dirname(__FILE__) + '/fixtures/sequel_test_app'
|
5
|
+
require 'activesupport'
|
6
|
+
|
7
|
+
class SequelErrorsToXmlTest < Test::Unit::TestCase
|
8
|
+
context "serializing a sequel errors object" do
|
9
|
+
before do
|
10
|
+
@subscription = Subscription.new
|
11
|
+
@subscription.valid?
|
12
|
+
end
|
13
|
+
|
14
|
+
should "serialize in a format that is active resource compatible" do
|
15
|
+
str =<<-__END__
|
16
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
17
|
+
<errors>
|
18
|
+
<error>user_id is not present</error>
|
19
|
+
</errors>
|
20
|
+
__END__
|
21
|
+
assert_equal str, @subscription.errors.to_xml
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
data/test/sequel_test.rb
ADDED
@@ -0,0 +1,137 @@
|
|
1
|
+
require File.dirname(__FILE__) + '/test_helper'
|
2
|
+
require 'sinatra'
|
3
|
+
require 'sinatra/test/unit'
|
4
|
+
require File.dirname(__FILE__) + '/fixtures/sequel_test_app'
|
5
|
+
require 'activesupport'
|
6
|
+
|
7
|
+
class Sequel::Model
|
8
|
+
def to_xml(opts={})
|
9
|
+
values.to_xml(opts)
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
class SequelTest < Test::Unit::TestCase
|
14
|
+
context "on GET to /users with xml" do
|
15
|
+
setup do
|
16
|
+
2.times { create_user }
|
17
|
+
get '/users.xml'
|
18
|
+
end
|
19
|
+
|
20
|
+
expect { assert_equal 200, @response.status }
|
21
|
+
expect { assert_equal User.all.to_xml, @response.body }
|
22
|
+
expect { assert_equal "application/xml", @response.content_type }
|
23
|
+
end
|
24
|
+
|
25
|
+
context "on POST to /users" do
|
26
|
+
setup do
|
27
|
+
User.destroy_all
|
28
|
+
post '/users.xml', :user => {:name => "whatever"}
|
29
|
+
end
|
30
|
+
|
31
|
+
expect { assert_equal 201, @response.status }
|
32
|
+
expect { assert_equal "/users/#{User.first.id}.xml", @response.location }
|
33
|
+
expect { assert_equal "whatever", User.first.name }
|
34
|
+
expect { assert_equal "application/xml", @response.content_type }
|
35
|
+
end
|
36
|
+
|
37
|
+
context "on GET to /users/id" do
|
38
|
+
setup do
|
39
|
+
@user = create_user
|
40
|
+
get "/users/#{@user.id}.xml"
|
41
|
+
end
|
42
|
+
|
43
|
+
expect { assert_equal 200, @response.status }
|
44
|
+
expect { assert_equal @user.to_xml, @response.body }
|
45
|
+
expect { assert_equal "application/xml", @response.content_type }
|
46
|
+
end
|
47
|
+
|
48
|
+
context "on GET to /users/id with a missing user" do
|
49
|
+
setup do
|
50
|
+
get "/users/missing.xml"
|
51
|
+
end
|
52
|
+
|
53
|
+
expect { assert_equal 404, @response.status }
|
54
|
+
expect { assert @response.body.empty? }
|
55
|
+
expect { assert_equal "application/xml", @response.content_type }
|
56
|
+
end
|
57
|
+
|
58
|
+
context "on PUT to /users/id" do
|
59
|
+
setup do
|
60
|
+
@user = create_user
|
61
|
+
put "/users/#{@user.id}.xml", :user => {:name => "Changed!"}
|
62
|
+
end
|
63
|
+
|
64
|
+
expect { assert_equal 200, @response.status }
|
65
|
+
expect { assert_equal @user.reload.to_xml, @response.body }
|
66
|
+
expect { assert_equal "application/xml", @response.content_type }
|
67
|
+
|
68
|
+
should "update the user" do
|
69
|
+
assert_equal "Changed!", @user.reload.name
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
context "on PUT to /users/id with invalid params" do
|
74
|
+
setup do
|
75
|
+
@user = create_user
|
76
|
+
put "/users/#{@user.id}.xml", :user => {:name => ""}
|
77
|
+
@invalid_user = User.new
|
78
|
+
@invalid_user.valid?
|
79
|
+
end
|
80
|
+
|
81
|
+
expect { assert_equal 422, @response.status }
|
82
|
+
expect { assert_equal @invalid_user.errors.to_xml, @response.body }
|
83
|
+
expect { assert_equal "application/xml", @response.content_type }
|
84
|
+
|
85
|
+
should "not update the user" do
|
86
|
+
assert_not_equal "Changed!", @user.reload.name
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
context "on PUT to /users/id with a missing user" do
|
91
|
+
setup do
|
92
|
+
put "/users/missing.xml", :user => {:name => "Changed!"}
|
93
|
+
end
|
94
|
+
|
95
|
+
expect { assert_equal 404, @response.status }
|
96
|
+
expect { assert @response.body.empty? }
|
97
|
+
expect { assert_equal "application/xml", @response.content_type }
|
98
|
+
end
|
99
|
+
|
100
|
+
context "on DELETE to /users/id" do
|
101
|
+
setup do
|
102
|
+
@user = create_user
|
103
|
+
delete "/users/#{@user.id}.xml"
|
104
|
+
end
|
105
|
+
|
106
|
+
expect { assert_equal 200, @response.status }
|
107
|
+
expect { assert_equal "application/xml", @response.content_type }
|
108
|
+
|
109
|
+
should "destroy the user" do
|
110
|
+
assert_nil User.find(:id => @user.id)
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
context "on DELETE to /users/id with a missing user" do
|
115
|
+
setup do
|
116
|
+
delete "/users/missing.xml"
|
117
|
+
end
|
118
|
+
|
119
|
+
expect { assert_equal 404, @response.status }
|
120
|
+
expect { assert_equal "application/xml", @response.content_type }
|
121
|
+
expect { assert @response.body.empty? }
|
122
|
+
end
|
123
|
+
|
124
|
+
context "on POST to /subscriptions with invalid params" do
|
125
|
+
setup do
|
126
|
+
Subscription.destroy_all
|
127
|
+
@subscription = Subscription.new
|
128
|
+
@subscription.valid?
|
129
|
+
post "/subscriptions.xml", :subscription => {}
|
130
|
+
end
|
131
|
+
|
132
|
+
expect { assert_equal 422, @response.status }
|
133
|
+
expect { assert_equal "application/xml", @response.content_type }
|
134
|
+
expect { assert_equal 0, Subscription.count }
|
135
|
+
expect { assert_equal @subscription.errors.to_xml, @response.body }
|
136
|
+
end
|
137
|
+
end
|
data/test/test_helper.rb
ADDED
@@ -0,0 +1,37 @@
|
|
1
|
+
require File.dirname(__FILE__) + '/../lib/classy_resources'
|
2
|
+
require 'rubygems'
|
3
|
+
require 'test/unit'
|
4
|
+
require 'context'
|
5
|
+
require 'zebra'
|
6
|
+
require 'mocha'
|
7
|
+
|
8
|
+
class Test::Unit::TestCase
|
9
|
+
protected
|
10
|
+
def create_post(opts = {})
|
11
|
+
Post.create!({:title => 'awesome'}.merge(opts))
|
12
|
+
end
|
13
|
+
|
14
|
+
def hash_for_comment(opts = {})
|
15
|
+
{}.merge(opts)
|
16
|
+
end
|
17
|
+
|
18
|
+
def create_comment(opts = {})
|
19
|
+
Comment.create!(hash_for_comment(opts))
|
20
|
+
end
|
21
|
+
|
22
|
+
def create_user(opts = {})
|
23
|
+
u = User.new({:name => 'james'}.merge(opts))
|
24
|
+
u.save
|
25
|
+
u
|
26
|
+
end
|
27
|
+
|
28
|
+
def hash_for_subscription(opts = {})
|
29
|
+
{:name => "emptiness is depressing"}.merge(opts)
|
30
|
+
end
|
31
|
+
|
32
|
+
def create_subscription(opts = {})
|
33
|
+
s = Subscription.new(hash_for_subscription(opts))
|
34
|
+
s.save
|
35
|
+
s
|
36
|
+
end
|
37
|
+
end
|
metadata
ADDED
@@ -0,0 +1,89 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: classy_resources
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.3.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- James Golick
|
8
|
+
- Justin Lynn
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
|
13
|
+
date: 2009-11-16 00:00:00 -08:00
|
14
|
+
default_executable:
|
15
|
+
dependencies:
|
16
|
+
- !ruby/object:Gem::Dependency
|
17
|
+
name: activesupport
|
18
|
+
type: :runtime
|
19
|
+
version_requirement:
|
20
|
+
version_requirements: !ruby/object:Gem::Requirement
|
21
|
+
requirements:
|
22
|
+
- - "="
|
23
|
+
- !ruby/object:Gem::Version
|
24
|
+
version: 2.2.2
|
25
|
+
version:
|
26
|
+
- !ruby/object:Gem::Dependency
|
27
|
+
name: sinatra
|
28
|
+
type: :runtime
|
29
|
+
version_requirement:
|
30
|
+
version_requirements: !ruby/object:Gem::Requirement
|
31
|
+
requirements:
|
32
|
+
- - ~>
|
33
|
+
- !ruby/object:Gem::Version
|
34
|
+
version: 0.9.0.4
|
35
|
+
version:
|
36
|
+
description: Instant ActiveResource compatible resources for sinatra.
|
37
|
+
email: justinlynn@gmail.com
|
38
|
+
executables: []
|
39
|
+
|
40
|
+
extensions: []
|
41
|
+
|
42
|
+
extra_rdoc_files: []
|
43
|
+
|
44
|
+
files:
|
45
|
+
- README.rdoc
|
46
|
+
- VERSION.yml
|
47
|
+
- lib/classy_resources/active_record.rb
|
48
|
+
- lib/classy_resources/mime_type.rb
|
49
|
+
- lib/classy_resources/post_body_param_parsing.rb
|
50
|
+
- lib/classy_resources/post_body_params.rb
|
51
|
+
- lib/classy_resources/sequel.rb
|
52
|
+
- lib/classy_resources/sequel_errors_to_xml.rb
|
53
|
+
- lib/classy_resources.rb
|
54
|
+
- test/active_record_test.rb
|
55
|
+
- test/fixtures/active_record_test_app.rb
|
56
|
+
- test/fixtures/sequel_test_app.rb
|
57
|
+
- test/sequel_errors_to_xml_test.rb
|
58
|
+
- test/sequel_test.rb
|
59
|
+
- test/test_helper.rb
|
60
|
+
has_rdoc: true
|
61
|
+
homepage: http://github.com/justinlynn/classy_resources
|
62
|
+
licenses: []
|
63
|
+
|
64
|
+
post_install_message:
|
65
|
+
rdoc_options: []
|
66
|
+
|
67
|
+
require_paths:
|
68
|
+
- lib
|
69
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
70
|
+
requirements:
|
71
|
+
- - ">="
|
72
|
+
- !ruby/object:Gem::Version
|
73
|
+
version: "0"
|
74
|
+
version:
|
75
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
76
|
+
requirements:
|
77
|
+
- - ">="
|
78
|
+
- !ruby/object:Gem::Version
|
79
|
+
version: "0"
|
80
|
+
version:
|
81
|
+
requirements: []
|
82
|
+
|
83
|
+
rubyforge_project:
|
84
|
+
rubygems_version: 1.3.5
|
85
|
+
signing_key:
|
86
|
+
specification_version: 2
|
87
|
+
summary: Instant ActiveResource compatible resources. Think resource_controller, for sinatra. Now modified for gemcutter.org awesomeness.
|
88
|
+
test_files: []
|
89
|
+
|