toast 0.9.5 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 1a702e1c3fefb8b0f86ae4169cf2bf0d5c06c59e
4
+ data.tar.gz: 7909991e0e45f40ab0f9889656a5fa0f64244bb2
5
+ SHA512:
6
+ metadata.gz: ae832ad5e6df4d2b1fe56e52125fc2721029bd084c8c8ef7f1c1271d64bffbdea500f3fe9b3fbc3cdac7c47f9762cc3010cee3b8335c406aed1f084a8a2b20e7
7
+ data.tar.gz: b7c9bd99a118e16f6d722e5bc4323335e85f151be89e59457684426cee8b4c3bf8bd3afaea460620f3013da83d4d27f9e18fc9281505f3d55bef98e0bdd090d6
data/README.md CHANGED
@@ -3,46 +3,58 @@
3
3
  Summary
4
4
  =======
5
5
 
6
- Toast is an extension to Ruby on Rails to build web services with low
7
- programming effort in a coherent way. Toast extends ActiveRecord such
8
- that each model can be declared to be a web resource, exposing defined
9
- attributes for reading and writing using HTTP.
6
+ Toast is a Rack application that hooks into Ruby on Rails. It exposes ActiveRecord models as a web service (REST API). The main difference from doing that with Ruby on Rails itself is it's DSL that covers all aspects of an API in one single configuration. For each model and API endpoint you define:
10
7
 
11
- Its main features are:
8
+ * what models and attributes are to be exposed
9
+ * what methods are supported (GET, PATCH, DELETE, POST,...)
10
+ * hooks to handle authorization
11
+ * customized handlers
12
12
 
13
- * declaration of web resources based on ActiveRecord models
14
- * generic controller handles all actions
15
- * automated routing
16
- * exposing data values with JSON maps
17
- * exposing associations by links (URLs)
13
+ When using Toast there's no Rails controller involved. Model classes and the API configuration is sufficient.
18
14
 
19
- Toast works with
15
+ Toast uses a REST/hypermedia style API, which is an own interpretation of the REST idea, not compatible with others like JSON API, Siren etc. It's design is much simpler and based on the idea of traversing opaque URIs.
20
16
 
21
- * Ruby on Rails >= 3.1.0 (currently tested up to 3.2.16)
22
- * Ruby 1.8.7, 1.9.3, 2.0.0
17
+ Other features are:
23
18
 
24
- See the [User Manual](https://github.com/robokopp/toast/wiki/User-Manual) for a detailed description.
19
+ * windowing of collections via _Range/Content-Range_ headers (paging)
20
+ * attribute selection per request
21
+ * processing of URI parameters
22
+
23
+ Toast v1 is build for Rails 5. The predecesssor v0.9 supports 3 and 4, but has a much different and smaller DSL.
24
+
25
+ See the User Manual (to be published soon) for a detailed description.
25
26
 
26
27
  Status
27
- =====
28
+ ======
28
29
 
29
- Toast is ready for production and is being used in productive
30
- applications since 2012. However, misconfigurations can have undesired
31
- effects.
30
+ Toast v1 for Rails 5 is a complete rewrite of v0.9, which was first published and used in production since 2012.
31
+ It comes now with secure defaults: Nothing is exposed unless declared, all endpoints have a default authorization hook responding with 401.
32
32
 
33
- WARNING
34
- =======
33
+ From my point of view it is production ready. I am in the process of porting a large API from v0.9 to v1 that uses all features and it looks very good so far. Of course minor issues will appear, please help to report and fix them.
34
+
35
+ Installation
36
+ ============
35
37
 
36
- A soon the gem is loaded a controller with ready routing is enabled
37
- serving the annotated model's data records at least for reading for
38
- everybody.
38
+ with Bundler (Gemfile) from Rubygems:
39
+
40
+ source 'http://rubygems.org'
41
+ gem "toast"
42
+
43
+ from Github:
44
+
45
+ gem "toast", :git => "https://github.com/robokopp/toast.git"
39
46
 
40
- You need to implement authorization yourself.
47
+ then run
48
+
49
+ bundle
50
+ rails generate toast init
51
+ create config/toast-api.rb
52
+ create config/toast-api
41
53
 
42
54
  Example
43
55
  =======
44
56
 
45
- Let the table `bananas` have the following schema:
57
+ Let the table _bananas_ have the following schema:
46
58
 
47
59
  create_table "bananas", :force => true do |t|
48
60
  t.string "name"
@@ -51,104 +63,177 @@ Let the table `bananas` have the following schema:
51
63
  t.integer "apple_id"
52
64
  end
53
65
 
54
- and let a corresponding model class have a *acts_as_resource* annotation:
66
+ and let a corresponding model class have this code:
55
67
 
56
68
  class Banana < ActiveRecord::Base
57
69
  belongs_to :apple
58
70
  has_many :coconuts
59
71
 
60
- scope :less_than_100, where("number < 100")
72
+ scope :less_than_100, where("number < 100")
73
+ end
74
+
75
+ Then we can define the API like this (in `config/toast-api/banana.rb`):
61
76
 
62
- acts_as_resource do
63
- # exposed attributes or association names
64
- readables :coconuts, :apple
77
+ expose(Banana) {
78
+
79
+ readables :color
65
80
  writables :name, :number
66
81
 
67
- # exposed class methods of Banana returning an Array of Banana records
68
- collections :less_than_100, :all
69
- end
70
- end
82
+ via_get {
83
+ allow do |user, model, uri_params|
84
+ true
85
+ end
86
+ }
87
+
88
+ via_patch {
89
+ allow do |user, model, uri_params|
90
+ true
91
+ end
92
+ }
93
+
94
+ via_delete {
95
+ allow do |user, model, uri_params|
96
+ true
97
+ end
98
+ }
99
+
100
+ collection(:less_than_100) {
101
+ via_get {
102
+ allow do |user, model, uri_params|
103
+ true
104
+ end
105
+ }
106
+ }
107
+
108
+ collection(:all) {
109
+ max_window 16
110
+
111
+ via_get {
112
+ allow do |user, model, uri_params|
113
+ true
114
+ end
115
+ }
116
+
117
+ via_post {
118
+ allow do |user, model, uri_params|
119
+ true
120
+ end
121
+ }
122
+ }
123
+
124
+ association(:coconuts) {
125
+ via_get {
126
+ allow do |user, model, uri_params|
127
+ true
128
+ end
129
+
130
+ handler do |banana, uri_params|
131
+ if uri_params[:max_weight] =~ /\A\d+\z/
132
+ banana.coconuts.where("weight <= #{uri_params[:max_weight]}")
133
+ else
134
+ banana.coconuts
135
+ end.order(:weight)
136
+ end
137
+ }
138
+
139
+ via_post {
140
+ allow do |user, model, uri_params|
141
+ true
142
+ end
143
+ }
144
+
145
+ via_link {
146
+ allow do |user, model, uri_params|
147
+ true
148
+ end
149
+ }
150
+ }
151
+
152
+ association(:apple) {
153
+ via_get {
154
+ allow do |user, model, uri_params|
155
+ true
156
+ end
157
+ }
158
+ }
159
+ }
160
+
161
+ Note, that all `allow`-blocks returning _true_. In practice authorization logic should be applied. An `allow`-block must be defined for each endpoint because it defaults to return `false`, which causes a 401 response.
162
+
163
+ The above definition exposes the model Banana as such:
164
+
165
+ ### Get a single resource representation:
166
+ GET http://www.example.com/bananas/23
167
+ --> 200, '{"self": "http://www.example.com/bananas/23"
168
+ "name": "Fred",
169
+ "number": 33,
170
+ "color": "yellow",
171
+ "coconuts": "http://www.example.com/bananas/23/coconuts",
172
+ "apple": "http://www.example.com/bananas/23/apple" }'
71
173
 
72
- The above definition inside the `acts_as_resource` block exposes the
73
- records of the model Banana automatically via a generic controller to
74
- the outside world, accepting and delivering JSON representations of
75
- the records. Let the associated models Apple and Coconut be
76
- exposed as a resource, too:
174
+ The representation of a record is a flat JSON map: _name_ → _value_, in case of associations _name_ → _URI_. The special key _self_ contains the URI from which this record can be fetch alone. _self_ can be treated as a unique ID of the record (globally unique, if under a FQDN).
77
175
 
78
- ### Get a collection
79
- GET /bananas
176
+ ### Get a collection (the :all collection)
177
+ GET http://www.example.com/bananas
80
178
  --> 200, '[{"self": "http://www.example.com/bananas/23",
81
179
  "name": "Fred",
82
180
  "number": 33,
181
+ "color": "yellow",
83
182
  "coconuts": "http://www.example.com/bananas/23/coconuts",
84
183
  "apple": "http://www.example.com/bananas/23/apple,
85
184
  {"self": "http://www.example.com/bananas/24",
86
- ... }, ... ]
87
- ### Get a customized collection (filtered, paging, etc.)
88
- GET /bananas/less_than_100
185
+ ... }, ... ]'
186
+
187
+ The default length of collections is limited to 42, this can be adjusted globally or for each endpoint separately. In this case no more than 16 will be delivered due to the `max_window 16` directive.
188
+
189
+ ### Get a customized collection
190
+ GET http://www.example.com/bananas/less_than_100
89
191
  --> 200, '[{BANANA}, {BANANA}, ...]'
90
192
 
91
- ### Get a single resource representation:
92
- GET /bananas/23
93
- --> 200, '{"self": "http://www.example.com/bananas/23"
94
- "name": "Fred",
95
- "number": 33,
96
- "coconuts": "http://www.example.com/bananas/23/coconuts",
97
- "apple": "http://www.example.com/bananas/23/apple" }'
193
+ Any scope can be published this way as well as any model class method returning a relation.
194
+
195
+ ### Get an associated collection + filter
196
+ GET http://www.example.com/bananas/23/coconuts?max_weight=3
197
+ --> 200, '[{COCONUT},{COCONUT},...]',
98
198
 
99
- ### Get an associated collection
100
- "GET" /bananas/23/coconuts
101
- --> 200, '[{COCNUT},{COCONUT},...]',
199
+ The COCONUT model must be exposed too. URI parameters can be processed in custom handlers for sorting and filtering.
102
200
 
103
201
  ### Update a single resource:
104
- PUT /bananas/23, '{"self": "http://www.example.com/bananas/23"
105
- "name": "Barney",
106
- "number": 44}'
202
+ PATCH http://www.example.com/bananas/23, '{"name": "Barney", "number": 44, "foo" => "bar"}'
107
203
  --> 200, '{"self": "http://www.example.com/bananas/23"
108
204
  "name": "Barney",
109
205
  "number": 44,
206
+ "color": "yellow",
110
207
  "coconuts": "http://www.example.com/bananas/23/coconuts",
111
208
  "apple": "http://www.example.com/bananas/23/apple"}'
112
209
 
210
+ Toast ingores unknown attributes, but prints warnings in it's log file. Only attributes from the 'writables' list will be updated.
211
+
113
212
  ### Create a new record
114
- "POST" /bananas, '{"name": "Johnny",
115
- "number": 888}'
116
- --> 201, {"self": "http://www.example.com/bananas/102"
213
+ POST http://www.example.com/bananas, '{"name": "Johnny", "number": 888}'
214
+ --> 201, '{"self": "http://www.example.com/bananas/102"
117
215
  "name": "Johnny",
118
- "number": 888,
119
- "coconuts": "http://www.example.com/bananas/102/coconuts" ,
120
- "apple": "http://www.example.com/bananas/102/apple }
216
+ "number": 888,
217
+ "color": null,
218
+ "coconuts": "http://www.example.com/bananas/102/coconuts",
219
+ "apple": "http://www.example.com/bananas/102/apple }'
121
220
 
122
221
  ### Create an associated record
123
- "POST" /bananas/23/coconuts, '{COCONUT}'
124
- --> 201, {"self":"http://www.example.com/coconuts/432,
125
- ...}
222
+ POST http://www.example.com/bananas/23/coconuts, '{COCONUT}'
223
+ --> 201, {"self":"http://www.example.com/coconuts/432, ...}
126
224
 
225
+
127
226
  ### Delete records
128
- DELETE /bananas/23
227
+ DELETE http://www.example.com/bananas/23
129
228
  --> 200
130
229
 
131
- More details and configuration options are documented in the manual.
132
-
133
- Installation
134
- ============
135
-
136
- With bundler from (rubygems.org)
230
+ ### Linking records
137
231
 
138
- gem "toast"
232
+ LINK "http://www.example.com/bananas/23/coconuts",
233
+ Link: "http://www.example.com/coconuts/31"
234
+ --> 200
139
235
 
140
- the latest Git:
236
+ Toast uses the (unusual) methods LINK and UNLINK in order to express the action of linking or unlinking existing resources. The above request will add _Coconut#31_ to the association _Banana#coconuts_.
141
237
 
142
- gem "toast", :git => "https://github.com/robokopp/toast.git"
143
-
144
- Remarks
145
- =======
146
238
 
147
- REST is more than some pretty URIs, the use of the HTTP verbs and
148
- response codes. It's on the Toast user to invent meaningful media
149
- types that control the application's state and introduce
150
- semantics. With toast you can build REST services or tightly coupled
151
- server-client applications, which ever suits the task best. That's why
152
- TOAST stands for:
153
239
 
154
- > **TOast Ain't reST**
data/config/routes.rb CHANGED
@@ -1,29 +1,3 @@
1
1
  Rails.application.routes.draw do
2
-
3
- ActiveRecord::Base.descendants.each do |model|
4
- next unless model.is_resourceful_model?
5
-
6
- resource_name = model.to_s.pluralize.underscore
7
-
8
- namespaces = []
9
-
10
- # routes must be defined for all defined namespaces of a model
11
- model.toast_configs.each do |tc|
12
- # once per namespace
13
- next if namespaces.include? tc.namespace
14
-
15
- namespaces << tc.namespace
16
-
17
- match("#{tc.namespace}/#{resource_name}(/:id(/:subresource))" => 'toast#catch_all',
18
- :constraints => { :id => /\d+/ },
19
- :resource => resource_name,
20
- :as => resource_name,
21
- :defaults => { :format => 'json' })
22
-
23
- match("#{tc.namespace}/#{resource_name}/:subresource" => 'toast#catch_all',
24
- :resource => resource_name,
25
- :defaults => { :format => 'json' })
26
- end
27
- end
28
-
2
+ mount Toast::RackApp.new, at: '/*toast_path'
29
3
  end
@@ -0,0 +1 @@
1
+ Use this generator to initialize Toast's main configuration file and the API config directory.
@@ -0,0 +1,10 @@
1
+ toast_settings {
2
+ max_window 42
3
+ link_unlink_via_post false
4
+
5
+ authenticate do |request|
6
+ # authenticate the request here (ActionDispatch::Request)
7
+ # returned object (except false) is passed as first argument to every allow-block for authorization
8
+ false
9
+ end
10
+ }
@@ -0,0 +1,9 @@
1
+ class ToastGenerator < Rails::Generators::NamedBase
2
+
3
+ source_root File.expand_path("../templates", __FILE__)
4
+
5
+ def init
6
+ template "toast-api.rb.erb", 'config/toast-api.rb'
7
+ empty_directory "config/toast-api/"
8
+ end
9
+ end
@@ -0,0 +1,179 @@
1
+ require 'toast/request_helpers'
2
+
3
+ class Toast::CanonicalRequest
4
+ include Toast::RequestHelpers
5
+ include Toast::Errors
6
+
7
+ def initialize id, base_config, auth, request
8
+ @id = id
9
+ @base_config = base_config
10
+ @selected_attributes = request.query_parameters.delete(:toast_select).try(:split,',')
11
+ @uri_params = request.query_parameters
12
+ @base_uri = base_uri(request)
13
+ @verb = request.request_method.downcase
14
+ @auth = auth
15
+ @request = request
16
+ end
17
+
18
+ def respond
19
+ if @verb.in? %w(get patch put delete)
20
+ self.send(@verb)
21
+ else
22
+ response :method_not_allowed,
23
+ headers: {'Allow' => allowed_methods(@base_config)},
24
+ msg: "method #{@verb.upcase} not supported for collection URIs"
25
+ end
26
+ end
27
+
28
+
29
+ private
30
+ def get
31
+ if @base_config.via_get.nil?
32
+ # not declared under expose {}
33
+ response :method_not_allowed,
34
+ headers: {'Allow' => allowed_methods(@base_config)},
35
+ msg: "GET not configured"
36
+ else
37
+ begin
38
+ model = @base_config.via_get.handler.call(
39
+ @base_config.model_class.find(@id), @uri_params
40
+ )
41
+
42
+ # call allow blocks to authorize
43
+ if @base_config.via_get.permissions.all?{|p| p.call(@auth, model, @uri_params)}
44
+ response :ok,
45
+ headers: {"Content-Type" => @base_config.media_type},
46
+ msg: "sent #{model.class}##{model.id}",
47
+ body: represent(model, @base_config)
48
+ else
49
+ response :unauthorized, msg: "authorization failed"
50
+ end
51
+
52
+ rescue ActiveRecord::RecordNotFound
53
+ response :not_found, msg: "#{@base_config.model_class}##{@id} not found"
54
+
55
+ rescue BadRequest => error
56
+ response :bad_request, msg: "`#{error.message}' in: #{error.source_location}"
57
+
58
+ rescue => error
59
+ response :internal_server_error, msg: "exception from via_get handler: " + error.message
60
+ end
61
+ end
62
+ end
63
+
64
+ def put
65
+ patch
66
+ end
67
+
68
+ def patch
69
+ if @base_config.via_patch.nil?
70
+ # not declared under expose {}
71
+ response :method_not_allowed,
72
+ headers: {'Allow' => allowed_methods(@base_config)},
73
+ msg: "PATCH not configured"
74
+ else
75
+ begin
76
+ # decode payload
77
+ payload = JSON.parse(@request.body.read)
78
+
79
+ # remove all attributes not in writables from payload
80
+ payload.delete_if do |attr,val|
81
+ unless attr.to_sym.in?(@base_config.writables)
82
+ Toast.logger.warn "<PATCH #{@request.fullpath}> received attribute `#{attr}' is not writable or unknown"
83
+ true
84
+ end
85
+ end
86
+
87
+ model_instance = @base_config.model_class.find(@id)
88
+ call_allow(@base_config.via_patch.permissions,
89
+ @auth, model_instance, @uri_params)
90
+
91
+ if call_handler(@base_config.via_patch.handler,
92
+ model_instance, payload, @uri_params)
93
+
94
+ response :ok, headers: {"Content-Type" => @base_config.media_type},
95
+ msg: "updated #{@base_config.model_class}##{@id}",
96
+ body: represent(@base_config.model_class.find(@id), @base_config)
97
+
98
+ else
99
+ message = model_instance.errors.count > 0 ?
100
+ ": " + model_instance.errors.full_messages.join(',') : ''
101
+
102
+ response :conflict,
103
+ msg: "patch of #{model_instance.class}##{model_instance.id} aborted#{message}"
104
+ end
105
+
106
+ rescue JSON::ParserError => error
107
+ response :internal_server_error, msg: "expect JSON body"
108
+
109
+ rescue ActiveRecord::RecordNotFound => error
110
+ response :not_found, msg: error.message
111
+
112
+ rescue BadRequest => error
113
+ response :bad_request, msg: "`#{error.message}' in: #{error.source_location}"
114
+
115
+ rescue AllowError => error
116
+ response :internal_server_error,
117
+ msg: "exception raised in allow block: `#{error.orig_error.message}' in #{error.source_location}"
118
+ rescue HandlerError => error
119
+ response :internal_server_error,
120
+ msg: "exception raised in via_patch handler: `#{error.orig_error.message}' in #{error.source_location}"
121
+ rescue NotAllowed => error
122
+ response :unauthorized, msg: "not authorized by allow block in: #{error.source_location}"
123
+
124
+ rescue => error
125
+ response :internal_server_error, msg: "exception from via_patch handler: "+ error.message
126
+ end
127
+ end
128
+ end
129
+
130
+ def delete
131
+ if @base_config.via_delete.nil?
132
+ # not declared
133
+ response :method_not_allowed,
134
+ headers: {'Allow' => allowed_methods(@base_config)},
135
+ msg: "DELETE not configured"
136
+ else
137
+ begin
138
+
139
+ model_instance = @base_config.model_class.find(@id)
140
+
141
+ call_allow(@base_config.via_delete.permissions,
142
+ @auth, model_instance, @uri_params)
143
+
144
+ if call_handler(@base_config.via_delete.handler,
145
+ model_instance, @uri_params)
146
+ response :no_content, msg: "deleted #{@base_config.model_class}##{@id}"
147
+ else
148
+
149
+ message = model_instance.errors.count > 0 ?
150
+ ": " + model_instance.errors.full_messages.join(',') : ''
151
+
152
+ response :conflict,
153
+ msg: "deletion of #{model_instance.class}##{model_instance.id} aborted#{message}"
154
+ end
155
+
156
+ rescue AllowError => error
157
+ return response :internal_server_error,
158
+ msg: "exception raised in allow block: `#{error.orig_error.message}' in #{error.source_location}"
159
+
160
+ rescue BadRequest => error
161
+ response :bad_request, msg: "`#{error.message}' in: #{error.source_location}"
162
+
163
+ rescue HandlerError => error
164
+ return response :internal_server_error,
165
+ msg: "exception raised in handler: `#{error.orig_error.message}' in #{error.source_location}"
166
+ rescue NotAllowed => error
167
+ return response :unauthorized, msg: "not authorized by allow block in: #{error.source_location}"
168
+
169
+ rescue ActiveRecord::RecordNotFound => error
170
+ response :not_found,
171
+ msg: error.message
172
+
173
+ rescue => error
174
+ response :internal_server_error,
175
+ msg: "exception from via_delete handler: #{error.message}"
176
+ end
177
+ end
178
+ end
179
+ end