roda-rest_api 1.4.5 → 2.0.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.
- checksums.yaml +4 -4
- data/.document +5 -0
- data/Gemfile +10 -0
- data/README.md +302 -0
- data/Rakefile +22 -0
- data/VERSION +1 -0
- data/lib/roda/plugins/rest_api.rb +302 -250
- data/roda-rest_api.gemspec +61 -0
- data/test/rest_api_api_test.rb +76 -0
- data/test/rest_api_form_input_test.rb +31 -0
- data/test/rest_api_id_pattern_test.rb +56 -0
- data/test/rest_api_nested_test.rb +105 -0
- data/test/rest_api_perf_benchmark.rb +35 -0
- data/test/rest_api_permit_test.rb +117 -0
- data/test/rest_api_resource_test.rb +127 -0
- data/test/rest_api_routes_test.rb +61 -0
- data/test/rest_api_serialize_test.rb +81 -0
- data/test/rest_api_singleton_test.rb +52 -0
- data/test/rest_api_split_route_test.rb +30 -0
- data/test/rest_api_upgrade_test.rb +22 -0
- data/test/rest_api_wrapper_test.rb +92 -0
- data/test/test_helpers.rb +93 -0
- metadata +27 -48
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 3d5c2fcc7e1accb98d420dab826d45d09e562f10
|
4
|
+
data.tar.gz: 3c0d41e7da9e9e2f9079a07298f38600217d8789
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 9f070e65992e1a696917d4f42c04904982d17cb2a2e05491cc9c3dc212d596858d1bbb1c8179415f4480b873983bea9a1dfd986dea1c9e3bb563e9f8188d636c
|
7
|
+
data.tar.gz: bf5acc47f30a35d9eea74137e1c2a3b9e81d5a2fd210c0c9fd8bd6476052bb8a257be370faad8d3c7f7854fe3a6d0a31b3e1a6569a588fff6331cbc2250c48fe
|
data/.document
ADDED
data/Gemfile
ADDED
data/README.md
ADDED
@@ -0,0 +1,302 @@
|
|
1
|
+
Roda plugin for RESTful APIs
|
2
|
+
=============
|
3
|
+
|
4
|
+
### Quick start
|
5
|
+
|
6
|
+
Install gem with
|
7
|
+
|
8
|
+
gem 'roda-rest_api' #Gemfile
|
9
|
+
|
10
|
+
or
|
11
|
+
|
12
|
+
gem install roda-rest_api #Manual
|
13
|
+
|
14
|
+
Create rack app
|
15
|
+
|
16
|
+
```ruby
|
17
|
+
#api.ru
|
18
|
+
|
19
|
+
require 'roda/rest_api'
|
20
|
+
require 'json'
|
21
|
+
|
22
|
+
class App < Roda
|
23
|
+
|
24
|
+
plugin :rest_api
|
25
|
+
|
26
|
+
route do |r|
|
27
|
+
r.api do
|
28
|
+
r.version 3 do
|
29
|
+
r.resource :things do |things|
|
30
|
+
things.list {|param| ['foo', 'bar']}
|
31
|
+
things.routes :index
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
run App
|
39
|
+
```
|
40
|
+
|
41
|
+
And run with
|
42
|
+
|
43
|
+
bundle exec rackup api.ru
|
44
|
+
|
45
|
+
Try it out on:
|
46
|
+
|
47
|
+
curl http://127.0.0.1:9292/api/v3/things
|
48
|
+
|
49
|
+
|
50
|
+
### Usage
|
51
|
+
|
52
|
+
```ruby
|
53
|
+
route do |r|
|
54
|
+
r.api path:'', subdomain:'api' do # 'mount' on api.example.com/v1/...
|
55
|
+
r.version 1 do
|
56
|
+
|
57
|
+
#define all 7 routes:
|
58
|
+
# index - GET /v1/songs
|
59
|
+
# show - GET /v1/songs/:id
|
60
|
+
# create - POST /v1/songs
|
61
|
+
# update - PUT | PATCH /v1/songs/:id
|
62
|
+
# destroy - DELETE /v1/songs/:id
|
63
|
+
# edit - GET /v1/songs/:id/edit
|
64
|
+
# new - GET /v1/songs/new
|
65
|
+
|
66
|
+
# call permit to whitelist allowed parameters for save callback
|
67
|
+
|
68
|
+
r.resource :songs do |songs|
|
69
|
+
songs.list { |params| Song.where(params).all } #index
|
70
|
+
songs.one { |params| Song[params[:id]] } #show, edit, new
|
71
|
+
songs.delete { |params| Song[params[:id]].destroy } #destroy
|
72
|
+
songs.save { |atts| Song.create_or_update(atts) } #create, update
|
73
|
+
songs.permit :title, author: [:name, :address]
|
74
|
+
end
|
75
|
+
|
76
|
+
#define 2 routes and custom serializer, custom primary key:
|
77
|
+
# index - GET /v1/artists
|
78
|
+
# show - GET /v1/artists/:id
|
79
|
+
|
80
|
+
r.resource :artists, content_type: 'application/xml', primary_key: :artist_id do |artists|
|
81
|
+
artists.list { |params| Artist.where(params).all }
|
82
|
+
artists.one { |params| Artist[params[:artist_id]] }
|
83
|
+
artists.serialize { |result| ArtistSerializer.xml(result) }
|
84
|
+
artists.routes :index, :show
|
85
|
+
end
|
86
|
+
|
87
|
+
#define 6 singleton routes:
|
88
|
+
# show - GET /v1/profile
|
89
|
+
# create - POST /v1/profile
|
90
|
+
# update - PUT | PATCH /v1/profile
|
91
|
+
# destroy - DELETE /v1/profile
|
92
|
+
# edit - GET /v1/profile/edit
|
93
|
+
# new - GET /v1/profile/new
|
94
|
+
|
95
|
+
r.resource :profile, singleton: true do |profile|
|
96
|
+
profile.one { |params| current_user.profile } #show, edit, new
|
97
|
+
profile.save { |atts| current_user.profile.create_or_update(atts) } #create, update
|
98
|
+
profile.delete { |params| current_user.profile.destroy } #destroy
|
99
|
+
profile.permit :name, :address
|
100
|
+
end
|
101
|
+
|
102
|
+
#define nested routes
|
103
|
+
# index - GET /v1/albums/:parent_id/songs
|
104
|
+
# show - GET /v1/albums/:parent_id/songs/:id
|
105
|
+
# index - GET /v1/albums/:album_id/artwork
|
106
|
+
# index - GET /v1/albums/favorites
|
107
|
+
# show - GET /v1/albums/favorites/:id
|
108
|
+
|
109
|
+
r.resource :albums do |albums|
|
110
|
+
r.resource :songs do |songs|
|
111
|
+
songs.list { |params| Song.where({ :album_id => params[:parent_id] }) }
|
112
|
+
songs.one { |params| Song[params[:id]] }
|
113
|
+
songs.routes :index, :show
|
114
|
+
end
|
115
|
+
r.resource :artwork, parent_key: :album_id do |artwork|
|
116
|
+
artwork.list { |params| Artwork.where({ :album_id => params[:album_id] }).all }
|
117
|
+
artwork.routes :index
|
118
|
+
end
|
119
|
+
r.resource :favorites, bare: true do |favorites|
|
120
|
+
favorites.list { |params| Favorite.where(params).all }
|
121
|
+
favorites.one { |params| Favorite[params[:id]] ) }
|
122
|
+
favorites.routes :index, :show
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
#call block before route is called
|
127
|
+
|
128
|
+
r.resource :user, singleton: true do |user|
|
129
|
+
user.save {|atts| User.create_or_update(atts) }
|
130
|
+
user.routes :create # public
|
131
|
+
user.routes :update do # private
|
132
|
+
authenticate!
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
#define custom routes
|
137
|
+
|
138
|
+
r.resource :albums do
|
139
|
+
r.index do # GET /v1/albums
|
140
|
+
# list albums
|
141
|
+
end
|
142
|
+
r.create do # POST /v1/albums
|
143
|
+
# create album
|
144
|
+
end
|
145
|
+
r.show do |id| # GET /v1/albums/:id
|
146
|
+
# show album
|
147
|
+
end
|
148
|
+
r.update do |id| # PATCH | PUT /v1/albums/:id
|
149
|
+
# update album
|
150
|
+
end
|
151
|
+
r.destroy do |id| # DELETE /v1/albums/:id
|
152
|
+
# delete album
|
153
|
+
end
|
154
|
+
r.edit do |id| # GET /v1/albums/:id/edit
|
155
|
+
# edit album
|
156
|
+
end
|
157
|
+
r.new do # GET /v1/albums/new
|
158
|
+
# new album
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
end
|
163
|
+
end
|
164
|
+
end
|
165
|
+
```
|
166
|
+
|
167
|
+
###Options
|
168
|
+
|
169
|
+
The plugin supports several options serialize, content_type, wrapper and id_pattern to modify processing of the request. Besides these, any number of custom options can be passed, which can be handy for the wrapper option.
|
170
|
+
Each option can be specified and overridden at the api, version or resource level.
|
171
|
+
|
172
|
+
####Serialization and content type
|
173
|
+
|
174
|
+
A serializer is an object that responds to :serialize and returns a string. Optionally it can provide the content_type, which may also be specified inline. Serialization can also be specified within a block inside the resource.
|
175
|
+
|
176
|
+
```ruby
|
177
|
+
class XMLSerializer
|
178
|
+
|
179
|
+
def serialize(result) #required
|
180
|
+
result.to_xml
|
181
|
+
end
|
182
|
+
|
183
|
+
def content_type #optional
|
184
|
+
'text/xml'
|
185
|
+
end
|
186
|
+
|
187
|
+
end
|
188
|
+
|
189
|
+
class CSVSerializer
|
190
|
+
|
191
|
+
def serialize(result)
|
192
|
+
result.to_csv
|
193
|
+
end
|
194
|
+
|
195
|
+
end
|
196
|
+
|
197
|
+
|
198
|
+
class App < Roda
|
199
|
+
|
200
|
+
plugin :rest_api
|
201
|
+
|
202
|
+
route do |r|
|
203
|
+
r.api serializer: XMLSerializer.new
|
204
|
+
r.resource :things do |things|
|
205
|
+
things.list {|param| ['foo', 'bar']}
|
206
|
+
things.routes :index
|
207
|
+
end
|
208
|
+
r.resource :objects, serializer: CSVSerializer.new, content_type: 'text/csv' do |objects|
|
209
|
+
objects.one {|param| Object.find(param) }
|
210
|
+
objects.routes :show
|
211
|
+
end
|
212
|
+
r.resource :items do |items|
|
213
|
+
items.list {|param| Item.where(param) }
|
214
|
+
items.routes :index
|
215
|
+
items.serialize content_type: 'text/plain' do |result| #inline specification
|
216
|
+
result.to_s
|
217
|
+
end
|
218
|
+
end
|
219
|
+
|
220
|
+
end
|
221
|
+
end
|
222
|
+
end
|
223
|
+
```
|
224
|
+
|
225
|
+
####Wrapper
|
226
|
+
|
227
|
+
A wrapper module can be specified, containing one or more 'around_*' methods. These methods should yield with the passed arguments. Wrappers can be used for cleaning up incoming parameters, database transactions, authorization checking or serialization. A resource can hold a generic :resource option, that can be used for providing extra info to the wrapper. It can be useful to set a custom option like model_class on a resource when using wrappers.
|
228
|
+
|
229
|
+
```ruby
|
230
|
+
module Wrapper
|
231
|
+
|
232
|
+
def around_save(atts)
|
233
|
+
# actions before save
|
234
|
+
result = yield(atts) # call save action
|
235
|
+
#actions after save
|
236
|
+
result
|
237
|
+
end
|
238
|
+
|
239
|
+
# around_one, around_list, around_delete
|
240
|
+
end
|
241
|
+
|
242
|
+
module SpecialWrapper
|
243
|
+
|
244
|
+
def around_delete(atts)
|
245
|
+
model_class = opts[:model_class]
|
246
|
+
if current_user.can_delete(model_class[atts[:id]])
|
247
|
+
yield(atts)
|
248
|
+
else
|
249
|
+
#not allowed
|
250
|
+
end
|
251
|
+
end
|
252
|
+
|
253
|
+
def current_user
|
254
|
+
@request.current_user
|
255
|
+
end
|
256
|
+
|
257
|
+
end
|
258
|
+
|
259
|
+
class App < Roda
|
260
|
+
|
261
|
+
plugin :rest_api
|
262
|
+
|
263
|
+
route do |r|
|
264
|
+
r.api wrapper: Wrapper
|
265
|
+
r.resource :things do |things|
|
266
|
+
things.one {|params| Thing.find(params) } # will be called inside the 'Wrapper#around_one' method
|
267
|
+
things.list {|params| Thing.where(params) } # will be called inside the 'Wrapper#around_list' method
|
268
|
+
things.save {|atts| Thing.create_or_update(atts) } # will be called inside the 'Wrapper#around_save' method
|
269
|
+
things.delete {|params| Thing.destroy(params) } # will be called inside the 'Wrapper#around_delete' method
|
270
|
+
end
|
271
|
+
r.resource :items, wrapper: SpecialWrapper, resource: {model_class: Item} do |items|
|
272
|
+
items.delete {|params| Item.destroy(params) } # will be called inside the 'SpecialWrapper#around_delete' method
|
273
|
+
end
|
274
|
+
|
275
|
+
end
|
276
|
+
end
|
277
|
+
end
|
278
|
+
```
|
279
|
+
|
280
|
+
####ID pattern
|
281
|
+
|
282
|
+
To support various id formats, one of the symbol_matcher symbols or a regex can be specified to match custom id formats.
|
283
|
+
The plugin adds the :uuid symbol for 8-4-4-4-12 formatted UUIDs.
|
284
|
+
|
285
|
+
```ruby
|
286
|
+
uuid_pat = /\w{8}-\w{4}-\w{4}-\w{4}-\w{12}/
|
287
|
+
r.api id_pattern: uuid_pat
|
288
|
+
r.resource :things do |things|
|
289
|
+
things.one {|params| Thing.find(params) }
|
290
|
+
r.resource :parts, id_pattern: /part(\d+)/ do |parts|
|
291
|
+
parts.one {|params| Part.find(params) }
|
292
|
+
end
|
293
|
+
|
294
|
+
end
|
295
|
+
end
|
296
|
+
|
297
|
+
# responds to /things/7e554915-210b-4dxe-a88b-3a09a5e790ge/parts/part123
|
298
|
+
```
|
299
|
+
|
300
|
+
### Caveat
|
301
|
+
|
302
|
+
This plugin catches StandardError when performing the data access methods (list, one, save, delete) and will return a 404 or 422 response code when an error is thrown. When ENV['RACK_ENV'] is set to 'development' the error will be raised, but in all other cases it will fail silently. Be aware that ENV['RACK_ENV'] may be blank, so you won't see any errors even in development. A better approach is to develop and test the data access methods in isolation.
|
data/Rakefile
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
require 'rake/testtask'
|
2
|
+
|
3
|
+
Rake::TestTask.new do |t|
|
4
|
+
t.libs << "test"
|
5
|
+
t.pattern = "test/*_test.rb"
|
6
|
+
end
|
7
|
+
|
8
|
+
require 'jeweler'
|
9
|
+
Jeweler::Tasks.new do |gem|
|
10
|
+
# gem is a Gem::Specification... see http://guides.rubygems.org/specification-reference/ for more options
|
11
|
+
gem.name = "roda-rest_api"
|
12
|
+
gem.homepage = "http://github.com/beno/roda-rest_api"
|
13
|
+
gem.license = "MIT"
|
14
|
+
gem.summary = %Q{Restful resources for Roda}
|
15
|
+
gem.description = %Q{Create restful API easily with the Roda framework}
|
16
|
+
gem.email = "michelbenevento@yahoo.com"
|
17
|
+
gem.authors = ["Michel Benevento"]
|
18
|
+
gem.version = "2.0.1"
|
19
|
+
# dependencies defined in Gemfile
|
20
|
+
end
|
21
|
+
Jeweler::RubygemsDotOrgTasks.new
|
22
|
+
|
data/VERSION
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
2.0.1
|
@@ -1,267 +1,319 @@
|
|
1
1
|
class Roda
|
2
|
-
|
2
|
+
module RodaPlugins
|
3
3
|
|
4
|
-
|
4
|
+
module RestApi
|
5
|
+
|
6
|
+
class DefaultSerializer
|
7
|
+
def serialize(result)
|
8
|
+
result.is_a?(String) ? result : result.to_json
|
9
|
+
end
|
10
|
+
end
|
5
11
|
|
6
|
-
|
7
|
-
|
12
|
+
APPLICATION_JSON = 'application/json'.freeze
|
13
|
+
SINGLETON_ROUTES = %i{ create new update show destroy edit }.freeze
|
14
|
+
OPTS = {}.freeze
|
15
|
+
|
16
|
+
def self.load_dependencies(app, _opts = OPTS)
|
17
|
+
app.plugin :all_verbs
|
18
|
+
app.plugin :symbol_matchers
|
19
|
+
app.plugin :header_matchers
|
20
|
+
app.plugin :drop_body
|
21
|
+
end
|
22
|
+
|
23
|
+
def self.configure(app, opts = OPTS)
|
24
|
+
all_keys = Resource::OPTIONS.keys << :serialize
|
25
|
+
if (opts.keys & all_keys).any?
|
26
|
+
raise "For version 2.0 all options [#{Resource::OPTIONS.keys.join(' ')}] must be set at the api, version or resource level."
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
class Resource
|
31
|
+
|
32
|
+
attr_reader :request, :path, :singleton, :parent, :id_pattern
|
33
|
+
attr_accessor :captures
|
34
|
+
|
35
|
+
OPTIONS = { singleton: false,
|
36
|
+
primary_key: "id",
|
37
|
+
parent_key: "parent_id",
|
38
|
+
id_pattern: /(\d+)/,
|
39
|
+
content_type: APPLICATION_JSON,
|
40
|
+
bare: false,
|
41
|
+
serializer: RestApi::DefaultSerializer.new,
|
42
|
+
wrapper: nil,
|
43
|
+
resource: nil }.freeze
|
44
|
+
|
45
|
+
def initialize(path, request, parent, option_chain=[])
|
46
|
+
@request = request
|
47
|
+
@path = path.to_s
|
48
|
+
traverse_options option_chain
|
49
|
+
if parent
|
50
|
+
@parent = parent
|
51
|
+
@path = ":id/#{@path}" unless @bare
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
[:list, :one, :save, :delete, :serialize].each do |meth|
|
56
|
+
define_method meth do |&block|
|
57
|
+
self.instance_variable_set("@#{meth}", block) if block
|
58
|
+
self.instance_variable_get("@#{meth}") || ->(_){raise NotImplementedError, meth.to_s}
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def content_type
|
63
|
+
if @serializer && @serializer.respond_to?(:content_type)
|
64
|
+
@serializer.content_type
|
65
|
+
else
|
66
|
+
@content_type
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
def opts
|
71
|
+
@resource || {}
|
72
|
+
end
|
73
|
+
|
74
|
+
def routes(*routes)
|
75
|
+
routes! if @routes
|
76
|
+
yield if block_given?
|
77
|
+
@routes = routes
|
78
|
+
end
|
79
|
+
|
80
|
+
def permit(*permitted)
|
81
|
+
@permitted = permitted
|
82
|
+
end
|
83
|
+
|
84
|
+
def routes!
|
85
|
+
unless @routes
|
86
|
+
@routes = SINGLETON_ROUTES.dup
|
87
|
+
@routes << :index unless @singleton
|
88
|
+
end
|
89
|
+
@routes.each { |route| @request.send(route) }
|
90
|
+
end
|
91
|
+
|
92
|
+
POST_BODY = 'rack.input'.freeze
|
93
|
+
FORM_INPUT = 'rack.request.form_input'.freeze
|
94
|
+
FORM_HASH = 'rack.request.form_hash'.freeze
|
8
95
|
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
end
|
44
|
-
|
45
|
-
def save(&block)
|
46
|
-
@save = block if block
|
47
|
-
@save || ->(_){raise NotImplementedError, "save"}
|
48
|
-
end
|
49
|
-
|
50
|
-
def delete(&block)
|
51
|
-
@delete = block if block
|
52
|
-
@content_type = nil
|
53
|
-
@delete || ->(_){raise NotImplementedError, "delete"}
|
54
|
-
end
|
55
|
-
|
56
|
-
def serialize(&block)
|
57
|
-
@serialize = block if block
|
58
|
-
@serialize || ->(obj){obj.is_a?(String) ? obj : obj.send(:to_json)}
|
59
|
-
end
|
60
|
-
|
61
|
-
def routes(*routes)
|
62
|
-
routes! if @routes
|
63
|
-
yield if block_given?
|
64
|
-
@routes = routes
|
65
|
-
end
|
66
|
-
|
67
|
-
def permit(*permitted)
|
68
|
-
@permitted = permitted
|
69
|
-
end
|
70
|
-
|
71
|
-
def routes!
|
72
|
-
unless @routes
|
73
|
-
@routes = SINGLETON_ROUTES.dup
|
74
|
-
@routes << :index unless @singleton
|
75
|
-
end
|
76
|
-
@routes.each { |route| @request.send(route) }
|
77
|
-
end
|
78
|
-
|
79
|
-
POST_BODY = 'rack.input'.freeze
|
80
|
-
FORM_INPUT = 'rack.request.form_input'.freeze
|
81
|
-
FORM_HASH = 'rack.request.form_hash'.freeze
|
96
|
+
def perform_wrapped(method, args, &blk)
|
97
|
+
if respond_to? :"around_#{method}"
|
98
|
+
send :"around_#{method}", args, &blk
|
99
|
+
else
|
100
|
+
blk.call(args)
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
def perform(method, id = nil)
|
105
|
+
begin
|
106
|
+
args = self.arguments(method, id)
|
107
|
+
perform_wrapped(method, args) do |args|
|
108
|
+
r = self.send(method).call(args)
|
109
|
+
end
|
110
|
+
rescue StandardError => e
|
111
|
+
raise if ENV['RACK_ENV'] == 'development'
|
112
|
+
@request.response.status = method === :save ? 422 : 404
|
113
|
+
@request.response.write e
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
protected
|
118
|
+
|
119
|
+
def arguments(method, id)
|
120
|
+
args = if method === :save
|
121
|
+
form = Rack::Request::FORM_DATA_MEDIA_TYPES.include?(@request.media_type)
|
122
|
+
permitted_args(form ? @request.POST : JSON.parse(@request.body.read))
|
123
|
+
else
|
124
|
+
symbolize_keys @request.GET
|
125
|
+
end
|
126
|
+
args.merge!(@primary_key.to_sym => id) if id
|
127
|
+
args.merge!(@parent_key.to_sym => @captures[0]) if @captures
|
128
|
+
args
|
129
|
+
end
|
82
130
|
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
131
|
+
private
|
132
|
+
|
133
|
+
def traverse_options(option_chain)
|
134
|
+
reverse_chain = option_chain.reverse
|
135
|
+
OPTIONS.each_pair do |key, default|
|
136
|
+
ivar = "@#{key}"
|
137
|
+
options = reverse_chain.find do |opts|
|
138
|
+
opts[key]
|
139
|
+
end
|
140
|
+
self.instance_variable_set(ivar, (options && options[key]) || default)
|
141
|
+
end
|
142
|
+
if @wrapper
|
143
|
+
raise ":wrapper should be a module" unless @wrapper.is_a? Module
|
144
|
+
self.extend @wrapper
|
145
|
+
end
|
146
|
+
if @serializer
|
147
|
+
raise ":serializer should respond to :serialize" unless @serializer.respond_to?(:serialize)
|
148
|
+
@serialize = ->(res){@serializer.serialize(res)}
|
149
|
+
end
|
150
|
+
end
|
151
|
+
|
152
|
+
def symbolize_keys(args)
|
153
|
+
_args = {}
|
154
|
+
args.each do |k,v|
|
155
|
+
v = symbolize_keys(v) if v.is_a?(Hash)
|
156
|
+
_args[k.to_sym] = v
|
157
|
+
end
|
158
|
+
_args
|
159
|
+
end
|
160
|
+
|
161
|
+
def permitted_args(args, keypath = [])
|
162
|
+
permitted = nil
|
163
|
+
case args
|
164
|
+
when Hash
|
165
|
+
permitted = Hash.new
|
166
|
+
args.each_pair do |k,v|
|
167
|
+
keypath << k.to_sym
|
168
|
+
if permitted?(keypath)
|
169
|
+
value = permitted_args(v, keypath)
|
170
|
+
permitted[k.to_sym] = value if value
|
171
|
+
end
|
172
|
+
keypath.pop
|
173
|
+
end
|
174
|
+
else
|
175
|
+
permitted = args if permitted?(keypath)
|
176
|
+
end
|
177
|
+
permitted
|
178
|
+
end
|
179
|
+
|
180
|
+
def permitted?(keypath)
|
181
|
+
return false unless @permitted
|
182
|
+
permitted = @permitted
|
183
|
+
find_key = ->(items, key){
|
184
|
+
items.find do |item|
|
185
|
+
case item
|
186
|
+
when Hash
|
187
|
+
!!item.keys.index(key)
|
188
|
+
when Symbol
|
189
|
+
item === key
|
190
|
+
end
|
191
|
+
end
|
192
|
+
}
|
193
|
+
keypath.each do |key|
|
194
|
+
found = find_key.call(permitted, key)
|
195
|
+
permitted = found.is_a?(Hash) ? found.values.flatten : []
|
196
|
+
return false unless found
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
end
|
201
|
+
|
202
|
+
module RequestMethods
|
203
|
+
|
204
|
+
def api(options={}, &block)
|
205
|
+
extract_resource_options options
|
206
|
+
path = options.delete(:path) || 'api'
|
207
|
+
subdomain = options.delete(:subdomain)
|
208
|
+
options.merge!(host: /\A#{Regexp.escape(subdomain)}\./) if subdomain
|
209
|
+
path = true if path.nil? or path.empty?
|
210
|
+
on(path, options, &block)
|
211
|
+
end
|
111
212
|
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
args.each do |k,v|
|
117
|
-
v = symbolize_keys(v) if v.is_a?(Hash)
|
118
|
-
_args[k.to_sym] = v
|
119
|
-
end
|
120
|
-
_args
|
121
|
-
end
|
122
|
-
|
123
|
-
def permitted_args(args, keypath = [])
|
124
|
-
permitted = nil
|
125
|
-
case args
|
126
|
-
when Hash
|
127
|
-
permitted = Hash.new
|
128
|
-
args.each_pair do |k,v|
|
129
|
-
keypath << k.to_sym
|
130
|
-
if permitted?(keypath)
|
131
|
-
value = permitted_args(v, keypath)
|
132
|
-
permitted[k.to_sym] = value if value
|
133
|
-
end
|
134
|
-
keypath.pop
|
135
|
-
end
|
136
|
-
else
|
137
|
-
permitted = args if permitted?(keypath)
|
138
|
-
end
|
139
|
-
permitted
|
140
|
-
end
|
141
|
-
|
142
|
-
def permitted?(keypath)
|
143
|
-
return false unless @permitted
|
144
|
-
permitted = @permitted
|
145
|
-
find_key = ->(items, key){
|
146
|
-
items.find do |item|
|
147
|
-
case item
|
148
|
-
when Hash
|
149
|
-
!!item.keys.index(key)
|
150
|
-
when Symbol
|
151
|
-
item === key
|
152
|
-
end
|
153
|
-
end
|
154
|
-
}
|
155
|
-
keypath.each do |key|
|
156
|
-
found = find_key.call(permitted, key)
|
157
|
-
permitted = found.is_a?(Hash) ? found.values.flatten : []
|
158
|
-
return false unless found
|
159
|
-
end
|
160
|
-
end
|
161
|
-
|
162
|
-
end
|
163
|
-
|
164
|
-
module RequestMethods
|
165
|
-
|
166
|
-
def api(options={}, &block)
|
167
|
-
path = options.delete(:path) || 'api'
|
168
|
-
subdomain = options.delete(:subdomain)
|
169
|
-
options.merge!(host: /\A#{Regexp.escape(subdomain)}\./) if subdomain
|
170
|
-
path = true if path.nil? or path.empty?
|
171
|
-
on(path, options, &block)
|
172
|
-
end
|
213
|
+
def version(version, options={}, &block)
|
214
|
+
extract_resource_options options
|
215
|
+
on("v#{version}", options, &block)
|
216
|
+
end
|
173
217
|
|
174
|
-
|
175
|
-
|
176
|
-
|
218
|
+
def resource(path, options={})
|
219
|
+
extract_resource_options options
|
220
|
+
@resource = Resource.new(path, self, @resource, @resource_options)
|
221
|
+
on(@resource.path, options) do
|
222
|
+
roda_class.symbol_matcher(:id, @resource.id_pattern)
|
223
|
+
@resource.captures = captures.dup unless captures.empty?
|
224
|
+
yield @resource
|
225
|
+
@resource.routes!
|
226
|
+
response.status = 404
|
227
|
+
end
|
228
|
+
@resource_options.pop
|
229
|
+
@resource = @resource.parent
|
230
|
+
end
|
177
231
|
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
|
182
|
-
yield @resource
|
183
|
-
@resource.routes!
|
184
|
-
response.status = 404
|
185
|
-
end
|
186
|
-
@resource = @resource.parent
|
187
|
-
end
|
232
|
+
def index(options={}, &block)
|
233
|
+
block ||= ->{ @resource.perform(:list) }
|
234
|
+
get(['', true], options, &block)
|
235
|
+
end
|
188
236
|
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
237
|
+
def show(options={}, &block)
|
238
|
+
block ||= default_block(:one)
|
239
|
+
get(_path, options, &block)
|
240
|
+
end
|
193
241
|
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
|
242
|
+
def create(options={}, &block)
|
243
|
+
block ||= ->(){@resource.perform(:save)}
|
244
|
+
post(['', true], options) do
|
245
|
+
response.status = 201
|
246
|
+
block.call(*captures) if block
|
247
|
+
end
|
248
|
+
end
|
198
249
|
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
end
|
205
|
-
end
|
250
|
+
def update(options={}, &block)
|
251
|
+
block ||= default_block(:save)
|
252
|
+
options.merge!(method: [:put, :patch])
|
253
|
+
is(_path, options, &block)
|
254
|
+
end
|
206
255
|
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
256
|
+
def destroy(options={}, &block)
|
257
|
+
block ||= default_block(:delete)
|
258
|
+
delete(_path, options) do
|
259
|
+
response.status = 204
|
260
|
+
block.call(*captures) if block
|
261
|
+
end
|
262
|
+
end
|
212
263
|
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
block.call(*captures) if block
|
218
|
-
end
|
219
|
-
end
|
264
|
+
def edit(options={}, &block)
|
265
|
+
block ||= default_block(:one)
|
266
|
+
get(_path('edit'), options, &block)
|
267
|
+
end
|
220
268
|
|
221
|
-
|
222
|
-
|
223
|
-
|
224
|
-
|
269
|
+
def new(options={}, &block)
|
270
|
+
block ||= ->{@resource.perform(:one, "new")}
|
271
|
+
get('new', options, &block)
|
272
|
+
end
|
273
|
+
|
274
|
+
private
|
275
|
+
|
276
|
+
def extract_resource_options(options)
|
277
|
+
@resource_options ||= []
|
278
|
+
opts = {}
|
279
|
+
(Resource::OPTIONS.keys & options.keys).each do |key|
|
280
|
+
opts[key] = options.delete(key)
|
281
|
+
end
|
282
|
+
@resource_options << opts
|
283
|
+
end
|
284
|
+
|
285
|
+
def _path(path=nil)
|
286
|
+
if @resource and @resource.singleton
|
287
|
+
path = ['', true] unless path
|
288
|
+
else
|
289
|
+
path = [':id', path].compact.join("/")
|
290
|
+
end
|
291
|
+
path
|
292
|
+
end
|
293
|
+
|
294
|
+
def default_block(method)
|
295
|
+
if @resource.singleton
|
296
|
+
->(){@resource.perform(method)}
|
297
|
+
else
|
298
|
+
->(id){@resource.perform(method, id)}
|
299
|
+
end
|
300
|
+
end
|
301
|
+
|
302
|
+
CONTENT_TYPE = 'Content-Type'.freeze
|
225
303
|
|
226
|
-
|
227
|
-
|
228
|
-
|
229
|
-
|
230
|
-
|
231
|
-
|
232
|
-
|
233
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
237
|
-
|
238
|
-
|
239
|
-
path
|
240
|
-
end
|
241
|
-
|
242
|
-
def default_block(method)
|
243
|
-
if @resource.singleton
|
244
|
-
->(){@resource.perform(method)}
|
245
|
-
else
|
246
|
-
->(id){@resource.perform(method, id)}
|
247
|
-
end
|
248
|
-
end
|
249
|
-
|
250
|
-
CONTENT_TYPE = 'Content-Type'.freeze
|
304
|
+
def block_result_body(result)
|
305
|
+
if result && @resource
|
306
|
+
response[CONTENT_TYPE] = @resource.content_type
|
307
|
+
@resource.serialize.call(result)
|
308
|
+
else
|
309
|
+
super
|
310
|
+
end
|
311
|
+
end
|
312
|
+
|
313
|
+
end
|
314
|
+
end
|
315
|
+
|
316
|
+
register_plugin(:rest_api, RestApi)
|
251
317
|
|
252
|
-
|
253
|
-
if result && @resource
|
254
|
-
response[CONTENT_TYPE] = @resource.content_type
|
255
|
-
@resource.serialize.call(result)
|
256
|
-
else
|
257
|
-
super
|
258
|
-
end
|
259
|
-
end
|
260
|
-
|
261
|
-
end
|
262
|
-
end
|
263
|
-
|
264
|
-
register_plugin(:rest_api, RestApi)
|
265
|
-
|
266
|
-
end
|
318
|
+
end
|
267
319
|
end
|