classy_resources 0.3.1
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/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
|
+
|