hanami-action 3.0.0.rc1
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 +7 -0
- data/CHANGELOG.md +985 -0
- data/LICENSE +20 -0
- data/README.md +873 -0
- data/hanami-action.gemspec +39 -0
- data/lib/hanami/action/body_parser/json.rb +20 -0
- data/lib/hanami/action/body_parser/multipart_form.rb +22 -0
- data/lib/hanami/action/body_parser.rb +109 -0
- data/lib/hanami/action/cache/cache_control.rb +84 -0
- data/lib/hanami/action/cache/conditional_get.rb +101 -0
- data/lib/hanami/action/cache/directives.rb +126 -0
- data/lib/hanami/action/cache/expires.rb +84 -0
- data/lib/hanami/action/cache.rb +29 -0
- data/lib/hanami/action/config/formats.rb +256 -0
- data/lib/hanami/action/config.rb +172 -0
- data/lib/hanami/action/constants.rb +283 -0
- data/lib/hanami/action/cookie_jar.rb +214 -0
- data/lib/hanami/action/cookies.rb +27 -0
- data/lib/hanami/action/csrf_protection.rb +217 -0
- data/lib/hanami/action/errors.rb +109 -0
- data/lib/hanami/action/flash.rb +176 -0
- data/lib/hanami/action/halt.rb +18 -0
- data/lib/hanami/action/mime/request_mime_weight.rb +66 -0
- data/lib/hanami/action/mime.rb +438 -0
- data/lib/hanami/action/params.rb +342 -0
- data/lib/hanami/action/rack/file.rb +41 -0
- data/lib/hanami/action/rack_utils.rb +11 -0
- data/lib/hanami/action/request/session.rb +68 -0
- data/lib/hanami/action/request.rb +141 -0
- data/lib/hanami/action/response.rb +481 -0
- data/lib/hanami/action/session.rb +47 -0
- data/lib/hanami/action/validatable.rb +166 -0
- data/lib/hanami/action/version.rb +13 -0
- data/lib/hanami/action/view_name_inferrer.rb +56 -0
- data/lib/hanami/action.rb +672 -0
- data/lib/hanami/http/status.rb +149 -0
- data/lib/hanami-action.rb +3 -0
- metadata +153 -0
data/README.md
ADDED
|
@@ -0,0 +1,873 @@
|
|
|
1
|
+
<!--- This file is synced from hanakai-rb/repo-sync -->
|
|
2
|
+
|
|
3
|
+
[actions]: https://github.com/hanami/hanami-action/actions
|
|
4
|
+
[chat]: https://discord.gg/naQApPAsZB
|
|
5
|
+
[forum]: https://discourse.hanamirb.org
|
|
6
|
+
[rubygem]: https://rubygems.org/gems/hanami-action
|
|
7
|
+
|
|
8
|
+
# Hanami Action [][rubygem] [][actions]
|
|
9
|
+
|
|
10
|
+
[][forum]
|
|
11
|
+
[][chat]
|
|
12
|
+
|
|
13
|
+
> [!NOTE]
|
|
14
|
+
> **Hanami Controller has been renamed to Hanami Action.**
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
Add this line to your application's Gemfile:
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
gem "hanami-action"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
And then execute:
|
|
25
|
+
|
|
26
|
+
```shell
|
|
27
|
+
$ bundle
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Or install it yourself as:
|
|
31
|
+
|
|
32
|
+
```shell
|
|
33
|
+
$ gem install hanami-action
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Usage
|
|
37
|
+
|
|
38
|
+
Hanami Action is a micro library for web frameworks. It works beautifully with [Hanami Router](https://github.com/hanami/router), but it can be employed everywhere. It's designed to be fast and testable.
|
|
39
|
+
|
|
40
|
+
### Actions
|
|
41
|
+
|
|
42
|
+
The core of this framework are the actions.
|
|
43
|
+
They are the endpoints that respond to incoming HTTP requests.
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
class Show < Hanami::Action
|
|
47
|
+
def handle(request, response)
|
|
48
|
+
response[:article] = ArticleRepository.new.find(request.params[:id])
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`Hanami::Action` follows the Hanami philosophy: a single purpose object with a minimal interface.
|
|
54
|
+
|
|
55
|
+
In this case, `Hanami::Action` provides the key public interface of `#call(env)`, making your actions Rack-compatible.
|
|
56
|
+
To provide custom behaviour when your actions are being called, you can implement `#handle(request, response)`
|
|
57
|
+
|
|
58
|
+
**An action is an object** and **you have full control over it**.
|
|
59
|
+
In other words, you have the freedom to instantiate, inject dependencies and test it, both at the unit and integration level.
|
|
60
|
+
|
|
61
|
+
In the example below, the default repository is `ArticleRepository`. During a unit test we can inject a stubbed version, and invoke `#call` with the params.
|
|
62
|
+
__We're avoiding HTTP calls__, we're also going to avoid hitting the database (it depends on the stubbed repository), __we're just dealing with message passing__.
|
|
63
|
+
Imagine how **fast** the unit test could be.
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
class Show < Hanami::Action
|
|
67
|
+
def initialize(configuration:, repository: ArticleRepository.new)
|
|
68
|
+
@repository = repository
|
|
69
|
+
super(configuration: configuration)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def handle(request, response)
|
|
73
|
+
response[:article] = repository.find(request.params[:id])
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
attr_reader :repository
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
configuration = Hanami::Action::Configuration.new
|
|
82
|
+
action = Show.new(configuration: configuration, repository: ArticleRepository.new)
|
|
83
|
+
action.call(id: 23)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Params
|
|
87
|
+
|
|
88
|
+
The request params are part of the request passed as an argument to the `#handle` method.
|
|
89
|
+
If routed with *Hanami::Router*, it extracts the relevant bits from the Rack `env` (e.g. the requested `:id`).
|
|
90
|
+
Otherwise everything is passed as is: the full Rack `env` in production, and the given `Hash` for unit tests.
|
|
91
|
+
|
|
92
|
+
With `Hanami::Router`:
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
class Show < Hanami::Action
|
|
96
|
+
def handle(request, *)
|
|
97
|
+
# ...
|
|
98
|
+
puts request.params # => { id: 23 } extracted from Rack env
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Standalone:
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
class Show < Hanami::Action
|
|
107
|
+
def handle(request, *)
|
|
108
|
+
# ...
|
|
109
|
+
puts request.params # => { :"rack.version"=>[1, 2], :"rack.input"=>#<StringIO:0x007fa563463948>, ... }
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Unit Testing:
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
class Show < Hanami::Action
|
|
118
|
+
def handle(request, *)
|
|
119
|
+
# ...
|
|
120
|
+
puts request.params # => { id: 23, key: "value" } passed as it is from testing
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
action = Show.new(configuration: configuration)
|
|
125
|
+
response = action.call(id: 23, key: "value")
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
#### Allowlisting
|
|
129
|
+
|
|
130
|
+
Params represent an untrusted input.
|
|
131
|
+
For security reasons it's recommended to allowlist them.
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
require "dry/validation"
|
|
135
|
+
require "hanami/action"
|
|
136
|
+
|
|
137
|
+
class Signup < Hanami::Action
|
|
138
|
+
params do
|
|
139
|
+
required(:first_name).filled(:str?)
|
|
140
|
+
required(:last_name).filled(:str?)
|
|
141
|
+
required(:email).filled(:str?)
|
|
142
|
+
|
|
143
|
+
required(:address).schema do
|
|
144
|
+
required(:line_one).filled(:str?)
|
|
145
|
+
required(:state).filled(:str?)
|
|
146
|
+
required(:country).filled(:str?)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def handle(request, *)
|
|
151
|
+
# Describe inheritance hierarchy
|
|
152
|
+
puts request.params.class # => Signup::Params
|
|
153
|
+
puts request.params.class.superclass # => Hanami::Action::Params
|
|
154
|
+
|
|
155
|
+
# Allowlist :first_name, but not :admin
|
|
156
|
+
puts request.params[:first_name] # => "Luca"
|
|
157
|
+
puts request.params[:admin] # => nil
|
|
158
|
+
|
|
159
|
+
# Allowlist nested params [:address][:line_one], not [:address][:line_two]
|
|
160
|
+
puts request.params[:address][:line_one] # => "69 Tender St"
|
|
161
|
+
puts request.params[:address][:line_two] # => nil
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
#### Validations & Coercions
|
|
167
|
+
|
|
168
|
+
Because params are a well defined set of data required to fulfill a feature
|
|
169
|
+
in your application, you can validate them. So you can avoid hitting lower MVC layers
|
|
170
|
+
when params are invalid.
|
|
171
|
+
|
|
172
|
+
If you specify the `:type` option, the param will be coerced.
|
|
173
|
+
|
|
174
|
+
```ruby
|
|
175
|
+
require "dry/validation"
|
|
176
|
+
require "hanami/action"
|
|
177
|
+
|
|
178
|
+
class Signup < Hanami::Action
|
|
179
|
+
MEGABYTE = 1024 ** 2
|
|
180
|
+
|
|
181
|
+
params do
|
|
182
|
+
required(:first_name).filled(:str?)
|
|
183
|
+
required(:last_name).filled(:str?)
|
|
184
|
+
required(:email).filled?(:str?, format?: /\A.+@.+\z/)
|
|
185
|
+
required(:password).filled(:str?).confirmation
|
|
186
|
+
required(:terms_of_service).filled(:bool?)
|
|
187
|
+
required(:age).filled(:int?, included_in?: 18..99)
|
|
188
|
+
optional(:avatar).filled(size?: 1..(MEGABYTE * 3))
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def handle(request, *)
|
|
192
|
+
halt 400 unless request.params.valid?
|
|
193
|
+
# ...
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### Response
|
|
199
|
+
|
|
200
|
+
The output of `#call` is a `Hanami::Action::Response`:
|
|
201
|
+
|
|
202
|
+
```ruby
|
|
203
|
+
class Show < Hanami::Action
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
action = Show.new(configuration: configuration)
|
|
207
|
+
action.call({}) # => #<Hanami::Action::Response:0x00007fe8be968418 @status=200 ...>
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
This is the same `response` object passed to `#handle`, where you can use its accessors to explicitly set status, headers, and body:
|
|
211
|
+
|
|
212
|
+
```ruby
|
|
213
|
+
class Show < Hanami::Action
|
|
214
|
+
def handle(*, response)
|
|
215
|
+
response.status = 201
|
|
216
|
+
response.body = "Hi!"
|
|
217
|
+
response.headers.merge!("X-Custom" => "OK")
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
action = Show.new
|
|
222
|
+
action.call({}) # => [201, { "X-Custom" => "OK" }, ["Hi!"]]
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
### Exposures
|
|
226
|
+
|
|
227
|
+
In case you need to send data from the action to other layers of your application, you can use exposures.
|
|
228
|
+
By default, an action exposes the received params.
|
|
229
|
+
|
|
230
|
+
```ruby
|
|
231
|
+
class Show < Hanami::Action
|
|
232
|
+
def handle(request, response)
|
|
233
|
+
response[:article] = ArticleRepository.new.find(request.params[:id])
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
action = Show.new(configuration: configuration)
|
|
238
|
+
response = action.call(id: 23)
|
|
239
|
+
|
|
240
|
+
article = response[:article]
|
|
241
|
+
article.class # => Article
|
|
242
|
+
article.id # => 23
|
|
243
|
+
|
|
244
|
+
response.exposures.keys # => [:params, :article]
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
### Callbacks
|
|
248
|
+
|
|
249
|
+
If you need to execute logic **before** or **after** `#handle` is invoked, you can use _callbacks_.
|
|
250
|
+
They are useful for shared logic like authentication checks.
|
|
251
|
+
|
|
252
|
+
```ruby
|
|
253
|
+
class Show < Hanami::Action
|
|
254
|
+
before :authenticate, :set_article
|
|
255
|
+
|
|
256
|
+
def handle(*)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
private
|
|
260
|
+
|
|
261
|
+
def authenticate
|
|
262
|
+
# ...
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# `request` and `response` in the method signature is optional
|
|
266
|
+
def set_article(request, response)
|
|
267
|
+
response[:article] = ArticleRepository.new.find(request.params[:id])
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
Callbacks can also be expressed as anonymous lambdas:
|
|
273
|
+
|
|
274
|
+
```ruby
|
|
275
|
+
class Show < Hanami::Action
|
|
276
|
+
before { ... } # do some authentication stuff
|
|
277
|
+
before { |request, response| response[:article] = ArticleRepository.new.find(request.params[:id]) }
|
|
278
|
+
|
|
279
|
+
def handle(*)
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
### Exceptions management
|
|
285
|
+
|
|
286
|
+
When the app raises an exception, `hanami-action`, does **NOT** manage it.
|
|
287
|
+
You can write custom exception handling on per action or configuration basis.
|
|
288
|
+
|
|
289
|
+
An exception handler can be a valid HTTP status code (eg. `500`, `401`), or a `Symbol` that represents an action method.
|
|
290
|
+
|
|
291
|
+
```ruby
|
|
292
|
+
class Show < Hanami::Action
|
|
293
|
+
handle_exception StandardError => 500
|
|
294
|
+
|
|
295
|
+
def handle(*)
|
|
296
|
+
raise
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
action = Show.new(configuration: configuration)
|
|
301
|
+
action.call({}) # => [500, {}, ["Internal Server Error"]]
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
You can map a specific raised exception to a different HTTP status.
|
|
305
|
+
|
|
306
|
+
```ruby
|
|
307
|
+
class Show < Hanami::Action
|
|
308
|
+
handle_exception RecordNotFound => 404
|
|
309
|
+
|
|
310
|
+
def handle(*)
|
|
311
|
+
raise RecordNotFound
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
action = Show.new(configuration: configuration)
|
|
316
|
+
action.call({}) # => [404, {}, ["Not Found"]]
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
You can also define custom handlers for exceptions.
|
|
320
|
+
|
|
321
|
+
```ruby
|
|
322
|
+
class Create < Hanami::Action
|
|
323
|
+
handle_exception ArgumentError => :my_custom_handler
|
|
324
|
+
|
|
325
|
+
def handle(*)
|
|
326
|
+
raise ArgumentError.new("Invalid arguments")
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
private
|
|
330
|
+
|
|
331
|
+
def my_custom_handler(request, response, exception)
|
|
332
|
+
response.status = 400
|
|
333
|
+
response.body = exception.message
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
action = Create.new(configuration: configuration)
|
|
338
|
+
action.call({}) # => [400, {}, ["Invalid arguments"]]
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
Exception policies can be defined globally via configuration:
|
|
342
|
+
|
|
343
|
+
```ruby
|
|
344
|
+
configuration = Hanami::Action::Configuration.new do |config|
|
|
345
|
+
config.handle_exception RecordNotFound => 404
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
class Show < Hanami::Action
|
|
349
|
+
def handle(*)
|
|
350
|
+
raise RecordNotFound
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
action = Show.new(configuration: configuration)
|
|
355
|
+
action.call({}) # => [404, {}, ["Not Found"]]
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
#### Inherited Exceptions
|
|
359
|
+
|
|
360
|
+
```ruby
|
|
361
|
+
class MyCustomException < StandardError
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
module Articles
|
|
365
|
+
class Index < Hanami::Action
|
|
366
|
+
handle_exception MyCustomException => :handle_my_exception
|
|
367
|
+
|
|
368
|
+
def handle(*)
|
|
369
|
+
raise MyCustomException
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
private
|
|
373
|
+
|
|
374
|
+
def handle_my_exception(request, response, exception)
|
|
375
|
+
# ...
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
class Show < Hanami::Action
|
|
380
|
+
handle_exception StandardError => :handle_standard_error
|
|
381
|
+
|
|
382
|
+
def handle(*)
|
|
383
|
+
raise MyCustomException
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
private
|
|
387
|
+
|
|
388
|
+
def handle_standard_error(request, response, exception)
|
|
389
|
+
# ...
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
Articles::Index.new.call({}) # => `handle_my_exception` will be invoked
|
|
395
|
+
Articles::Show.new.call({}) # => `handle_standard_error` will be invoked,
|
|
396
|
+
# because `MyCustomException` inherits from `StandardError`
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
### Throwable HTTP statuses
|
|
400
|
+
|
|
401
|
+
When `#halt` is used with a valid HTTP code, it stops the execution and sets the proper status and body for the response:
|
|
402
|
+
|
|
403
|
+
```ruby
|
|
404
|
+
class Show < Hanami::Action
|
|
405
|
+
before :authenticate!
|
|
406
|
+
|
|
407
|
+
def handle(*)
|
|
408
|
+
# ...
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
private
|
|
412
|
+
|
|
413
|
+
def authenticate!
|
|
414
|
+
halt 401 unless authenticated?
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
action = Show.new(configuration: configuration)
|
|
419
|
+
action.call({}) # => [401, {}, ["Unauthorized"]]
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
Alternatively, you can specify a custom message.
|
|
423
|
+
|
|
424
|
+
```ruby
|
|
425
|
+
class Show < Hanami::Action
|
|
426
|
+
def handle(request, response)
|
|
427
|
+
response[:droid] = DroidRepository.new.find(request.params[:id]) or not_found
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
private
|
|
431
|
+
|
|
432
|
+
def not_found
|
|
433
|
+
halt 404, "This is not the droid you're looking for"
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
action = Show.new(configuration: configuration)
|
|
438
|
+
action.call({}) # => [404, {}, ["This is not the droid you're looking for"]]
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
### Cookies
|
|
442
|
+
|
|
443
|
+
You can read the original cookies sent from the HTTP client via `request.cookies`.
|
|
444
|
+
If you want to send cookies in the response, use `response.cookies`.
|
|
445
|
+
|
|
446
|
+
They are read as a Hash from Rack env:
|
|
447
|
+
|
|
448
|
+
```ruby
|
|
449
|
+
require "hanami/action"
|
|
450
|
+
require "hanami/action/cookies"
|
|
451
|
+
|
|
452
|
+
class ReadCookiesFromRackEnv < Hanami::Action
|
|
453
|
+
include Hanami::Action::Cookies
|
|
454
|
+
|
|
455
|
+
def handle(request, *)
|
|
456
|
+
# ...
|
|
457
|
+
request.cookies[:foo] # => "bar"
|
|
458
|
+
end
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
action = ReadCookiesFromRackEnv.new(configuration: configuration)
|
|
462
|
+
action.call({"HTTP_COOKIE" => "foo=bar"})
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
They are set like a Hash:
|
|
466
|
+
|
|
467
|
+
```ruby
|
|
468
|
+
require "hanami/action"
|
|
469
|
+
require "hanami/action/cookies"
|
|
470
|
+
|
|
471
|
+
class SetCookies < Hanami::Action
|
|
472
|
+
include Hanami::Action::Cookies
|
|
473
|
+
|
|
474
|
+
def handle(*, response)
|
|
475
|
+
# ...
|
|
476
|
+
response.cookies[:foo] = "bar"
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
action = SetCookies.new(configuration: configuration)
|
|
481
|
+
action.call({}) # => [200, {"Set-Cookie" => "foo=bar"}, "..."]
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
They are removed by setting their value to `nil`:
|
|
485
|
+
|
|
486
|
+
```ruby
|
|
487
|
+
require "hanami/action"
|
|
488
|
+
require "hanami/action/cookies"
|
|
489
|
+
|
|
490
|
+
class RemoveCookies < Hanami::Action
|
|
491
|
+
include Hanami::Action::Cookies
|
|
492
|
+
|
|
493
|
+
def handle(*, response)
|
|
494
|
+
# ...
|
|
495
|
+
response.cookies[:foo] = nil
|
|
496
|
+
end
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
action = RemoveCookies.new(configuration: configuration)
|
|
500
|
+
action.call({}) # => [200, {"Set-Cookie" => "foo=; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 -0000"}, "..."]
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
Default values can be set in configuration, but overridden case by case.
|
|
504
|
+
|
|
505
|
+
```ruby
|
|
506
|
+
require "hanami/action"
|
|
507
|
+
require "hanami/action/cookies"
|
|
508
|
+
|
|
509
|
+
configuration = Hanami::Action::Configuration.new do |config|
|
|
510
|
+
config.cookies(max_age: 300) # 5 minutes
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
class SetCookies < Hanami::Action
|
|
514
|
+
include Hanami::Action::Cookies
|
|
515
|
+
|
|
516
|
+
def handle(*, response)
|
|
517
|
+
# ...
|
|
518
|
+
response.cookies[:foo] = { value: "bar", max_age: 100 }
|
|
519
|
+
end
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
action = SetCookies.new(configuration: configuration)
|
|
523
|
+
action.call({}) # => [200, {"Set-Cookie" => "foo=bar; max-age=100;"}, "..."]
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
### Sessions
|
|
527
|
+
|
|
528
|
+
Actions have builtin support for Rack sessions.
|
|
529
|
+
Similarly to cookies, you can read the session sent by the HTTP client via
|
|
530
|
+
`request.session`, and also manipulate it via `response.session`.
|
|
531
|
+
|
|
532
|
+
```ruby
|
|
533
|
+
require "hanami/action"
|
|
534
|
+
require "hanami/action/session"
|
|
535
|
+
|
|
536
|
+
class ReadSessionFromRackEnv < Hanami::Action
|
|
537
|
+
include Hanami::Action::Session
|
|
538
|
+
|
|
539
|
+
def handle(request, *)
|
|
540
|
+
# ...
|
|
541
|
+
request.session[:age] # => "35"
|
|
542
|
+
end
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
action = ReadSessionFromRackEnv.new(configuration: configuration)
|
|
546
|
+
action.call({ "rack.session" => { "age" => "35" } })
|
|
547
|
+
```
|
|
548
|
+
|
|
549
|
+
Values can be set like a Hash:
|
|
550
|
+
|
|
551
|
+
```ruby
|
|
552
|
+
require "hanami/action"
|
|
553
|
+
require "hanami/action/session"
|
|
554
|
+
|
|
555
|
+
class SetSession < Hanami::Action
|
|
556
|
+
include Hanami::Action::Session
|
|
557
|
+
|
|
558
|
+
def handle(*, response)
|
|
559
|
+
# ...
|
|
560
|
+
response.session[:age] = 31
|
|
561
|
+
end
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
action = SetSession.new(configuration: configuration)
|
|
565
|
+
action.call({}) # => [200, {"Set-Cookie"=>"rack.session=..."}, "..."]
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
Values can be removed like a Hash:
|
|
569
|
+
|
|
570
|
+
```ruby
|
|
571
|
+
require "hanami/action"
|
|
572
|
+
require "hanami/action/session"
|
|
573
|
+
|
|
574
|
+
class RemoveSession < Hanami::Action
|
|
575
|
+
include Hanami::Action::Session
|
|
576
|
+
|
|
577
|
+
def handle(*, response)
|
|
578
|
+
# ...
|
|
579
|
+
response.session[:age] = nil
|
|
580
|
+
end
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
action = RemoveSession.new(configuration: configuration)
|
|
584
|
+
action.call({}) # => [200, {"Set-Cookie"=>"rack.session=..."}, "..."] it removes that value from the session
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
While Hanami::Action supports sessions natively, it's **session store agnostic**.
|
|
588
|
+
You have to specify the session store in your Rack middleware configuration (eg `config.ru`).
|
|
589
|
+
|
|
590
|
+
```ruby
|
|
591
|
+
use Rack::Session::Cookie, secret: SecureRandom.hex(64)
|
|
592
|
+
run Show.new(configuration: configuration)
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
### HTTP Cache
|
|
596
|
+
|
|
597
|
+
Hanami::Action sets your headers correctly according to RFC 2616 / 14.9 for more on standard cache control directives: http://tools.ietf.org/html/rfc2616#section-14.9.1
|
|
598
|
+
|
|
599
|
+
You can easily set the Cache-Control header for your actions:
|
|
600
|
+
|
|
601
|
+
```ruby
|
|
602
|
+
require "hanami/action"
|
|
603
|
+
require "hanami/action/cache"
|
|
604
|
+
|
|
605
|
+
class HttpCacheAction < Hanami::Action
|
|
606
|
+
include Hanami::Action::Cache
|
|
607
|
+
cache_control :public, max_age: 600 # => Cache-Control: public, max-age=600
|
|
608
|
+
|
|
609
|
+
def handle(*)
|
|
610
|
+
# ...
|
|
611
|
+
end
|
|
612
|
+
end
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
Expires header can be specified using `expires` method:
|
|
616
|
+
|
|
617
|
+
```ruby
|
|
618
|
+
require "hanami/action"
|
|
619
|
+
require "hanami/action/cache"
|
|
620
|
+
|
|
621
|
+
class HttpCacheAction < Hanami::Action
|
|
622
|
+
include Hanami::Action::Cache
|
|
623
|
+
expires 60, :public, max_age: 600 # => Expires: Sun, 03 Aug 2014 17:47:02 GMT, Cache-Control: public, max-age=600
|
|
624
|
+
|
|
625
|
+
def handle(*)
|
|
626
|
+
# ...
|
|
627
|
+
end
|
|
628
|
+
end
|
|
629
|
+
```
|
|
630
|
+
|
|
631
|
+
### Conditional Get
|
|
632
|
+
|
|
633
|
+
According to HTTP specification, conditional GETs provide a way for web servers to inform clients that the response to a GET request hasn't change since the last request returning a `304 (Not Modified)` response.
|
|
634
|
+
|
|
635
|
+
Passing the `HTTP_IF_NONE_MATCH` (content identifier) or `HTTP_IF_MODIFIED_SINCE` (timestamp) headers allows the web server define if the client has a fresh version of a given resource.
|
|
636
|
+
|
|
637
|
+
You can easily take advantage of Conditional Get using `#fresh` method:
|
|
638
|
+
|
|
639
|
+
```ruby
|
|
640
|
+
require "hanami/action"
|
|
641
|
+
require "hanami/action/cache"
|
|
642
|
+
|
|
643
|
+
class ConditionalGetAction < Hanami::Action
|
|
644
|
+
include Hanami::Action::Cache
|
|
645
|
+
|
|
646
|
+
def handle(*)
|
|
647
|
+
# ...
|
|
648
|
+
fresh etag: resource.cache_key
|
|
649
|
+
# => halt 304 with header IfNoneMatch = resource.cache_key
|
|
650
|
+
end
|
|
651
|
+
end
|
|
652
|
+
```
|
|
653
|
+
|
|
654
|
+
If `resource.cache_key` is equal to `IfNoneMatch` header, then hanami will `halt 304`.
|
|
655
|
+
|
|
656
|
+
An alternative to hashing based check, is the time based check:
|
|
657
|
+
|
|
658
|
+
```ruby
|
|
659
|
+
require "hanami/action"
|
|
660
|
+
require "hanami/action/cache"
|
|
661
|
+
|
|
662
|
+
class ConditionalGetAction < Hanami::Action
|
|
663
|
+
include Hanami::Action::Cache
|
|
664
|
+
|
|
665
|
+
def handle(*)
|
|
666
|
+
# ...
|
|
667
|
+
fresh last_modified: resource.updated_at
|
|
668
|
+
# => halt 304 with header IfModifiedSince = resource.updated_at.httpdate
|
|
669
|
+
end
|
|
670
|
+
end
|
|
671
|
+
```
|
|
672
|
+
|
|
673
|
+
If `resource.updated_at` is equal to `IfModifiedSince` header, then hanami will `halt 304`.
|
|
674
|
+
|
|
675
|
+
### Redirect
|
|
676
|
+
|
|
677
|
+
If you need to redirect the client to another resource, use `response.redirect_to`:
|
|
678
|
+
|
|
679
|
+
```ruby
|
|
680
|
+
class Create < Hanami::Action
|
|
681
|
+
def handle(*, response)
|
|
682
|
+
# ...
|
|
683
|
+
response.redirect_to "http://example.com/articles/23"
|
|
684
|
+
end
|
|
685
|
+
end
|
|
686
|
+
|
|
687
|
+
action = Create.new(configuration: configuration)
|
|
688
|
+
action.call({ article: { title: "Hello" }}) # => [302, {"Location" => "/articles/23"}, ""]
|
|
689
|
+
```
|
|
690
|
+
|
|
691
|
+
You can also redirect with a custom status code:
|
|
692
|
+
|
|
693
|
+
```ruby
|
|
694
|
+
class Create < Hanami::Action
|
|
695
|
+
def handle(*, response)
|
|
696
|
+
# ...
|
|
697
|
+
response.redirect_to "http://example.com/articles/23", status: 301
|
|
698
|
+
end
|
|
699
|
+
end
|
|
700
|
+
|
|
701
|
+
action = Create.new(configuration: configuration)
|
|
702
|
+
action.call({ article: { title: "Hello" }}) # => [301, {"Location" => "/articles/23"}, ""]
|
|
703
|
+
```
|
|
704
|
+
|
|
705
|
+
### MIME Types
|
|
706
|
+
|
|
707
|
+
`Hanami::Action` automatically sets the `Content-Type` header, according to the request.
|
|
708
|
+
|
|
709
|
+
```ruby
|
|
710
|
+
class Show < Hanami::Action
|
|
711
|
+
def handle(*)
|
|
712
|
+
end
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
action = Show.new(configuration: configuration)
|
|
716
|
+
|
|
717
|
+
response = action.call({ "HTTP_ACCEPT" => "*/*" }) # Content-Type "application/octet-stream"
|
|
718
|
+
response.format # :all
|
|
719
|
+
|
|
720
|
+
response = action.call({ "HTTP_ACCEPT" => "text/html" }) # Content-Type "text/html"
|
|
721
|
+
response.format # :html
|
|
722
|
+
```
|
|
723
|
+
|
|
724
|
+
However, you can force this value:
|
|
725
|
+
|
|
726
|
+
```ruby
|
|
727
|
+
class Show < Hanami::Action
|
|
728
|
+
def handle(*, response)
|
|
729
|
+
# ...
|
|
730
|
+
response.format = :json
|
|
731
|
+
end
|
|
732
|
+
end
|
|
733
|
+
|
|
734
|
+
action = Show.new(configuration: configuration)
|
|
735
|
+
|
|
736
|
+
response = action.call({ "HTTP_ACCEPT" => "*/*" }) # Content-Type "application/json"
|
|
737
|
+
response.format # :json
|
|
738
|
+
|
|
739
|
+
response = action.call({ "HTTP_ACCEPT" => "text/html" }) # Content-Type "application/json"
|
|
740
|
+
response.format # :json
|
|
741
|
+
```
|
|
742
|
+
|
|
743
|
+
You can restrict the accepted MIME types:
|
|
744
|
+
|
|
745
|
+
```ruby
|
|
746
|
+
class Show < Hanami::Action
|
|
747
|
+
accept :html, :json
|
|
748
|
+
|
|
749
|
+
def handle(*)
|
|
750
|
+
# ...
|
|
751
|
+
end
|
|
752
|
+
end
|
|
753
|
+
|
|
754
|
+
# When called with "*/*" => 200
|
|
755
|
+
# When called with "text/html" => 200
|
|
756
|
+
# When called with "application/json" => 200
|
|
757
|
+
# When called with "application/xml" => 415
|
|
758
|
+
```
|
|
759
|
+
|
|
760
|
+
You can check if the requested MIME type is accepted by the client.
|
|
761
|
+
|
|
762
|
+
```ruby
|
|
763
|
+
class Show < Hanami::Action
|
|
764
|
+
def handle(request, response)
|
|
765
|
+
# ...
|
|
766
|
+
# @_env["HTTP_ACCEPT"] # => "text/html,application/xhtml+xml,application/xml;q=0.9"
|
|
767
|
+
|
|
768
|
+
request.accept?("text/html") # => true
|
|
769
|
+
request.accept?("application/xml") # => true
|
|
770
|
+
request.accept?("application/json") # => false
|
|
771
|
+
response.format # :html
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
# @_env["HTTP_ACCEPT"] # => "*/*"
|
|
775
|
+
|
|
776
|
+
request.accept?("text/html") # => true
|
|
777
|
+
request.accept?("application/xml") # => true
|
|
778
|
+
request.accept?("application/json") # => true
|
|
779
|
+
response.format # :html
|
|
780
|
+
end
|
|
781
|
+
end
|
|
782
|
+
```
|
|
783
|
+
|
|
784
|
+
Hanami::Action is shipped with an extensive list of the most common MIME types.
|
|
785
|
+
Also, you can register your own:
|
|
786
|
+
|
|
787
|
+
```ruby
|
|
788
|
+
configuration = Hanami::Action::Configuration.new do |config|
|
|
789
|
+
config.format custom: "application/custom"
|
|
790
|
+
end
|
|
791
|
+
|
|
792
|
+
class Index < Hanami::Action
|
|
793
|
+
def handle(*)
|
|
794
|
+
end
|
|
795
|
+
end
|
|
796
|
+
|
|
797
|
+
action = Index.new(configuration: configuration)
|
|
798
|
+
|
|
799
|
+
response = action.call({ "HTTP_ACCEPT" => "application/custom" }) # => Content-Type "application/custom"
|
|
800
|
+
response.format # => :custom
|
|
801
|
+
|
|
802
|
+
class Show < Hanami::Action
|
|
803
|
+
def handle(*, response)
|
|
804
|
+
# ...
|
|
805
|
+
response.format = :custom
|
|
806
|
+
end
|
|
807
|
+
end
|
|
808
|
+
|
|
809
|
+
action = Show.new(configuration: configuration)
|
|
810
|
+
|
|
811
|
+
response = action.call({ "HTTP_ACCEPT" => "*/*" }) # => Content-Type "application/custom"
|
|
812
|
+
response.format # => :custom
|
|
813
|
+
```
|
|
814
|
+
|
|
815
|
+
### Streamed Responses
|
|
816
|
+
|
|
817
|
+
When the work to be done by the server takes time, it may be a good idea to stream your response. Here's an example of a streamed CSV.
|
|
818
|
+
|
|
819
|
+
```ruby
|
|
820
|
+
configuration = Hanami::Action::Configuration.new do |config|
|
|
821
|
+
config.format csv: 'text/csv'
|
|
822
|
+
end
|
|
823
|
+
|
|
824
|
+
class Csv < Hanami::Action
|
|
825
|
+
def handle(*, response)
|
|
826
|
+
response.format = :csv
|
|
827
|
+
response.body = Enumerator.new do |yielder|
|
|
828
|
+
yielder << csv_header
|
|
829
|
+
|
|
830
|
+
# Expensive operation is streamed as each line becomes available
|
|
831
|
+
csv_body.each_line do |line|
|
|
832
|
+
yielder << line
|
|
833
|
+
end
|
|
834
|
+
end
|
|
835
|
+
end
|
|
836
|
+
end
|
|
837
|
+
```
|
|
838
|
+
|
|
839
|
+
Note:
|
|
840
|
+
* In development, Hanami' code reloading needs to be disabled for streaming to work. This is because `Shotgun` interferes with the streaming action. You can disable it like this `hanami server --code-reloading=false`
|
|
841
|
+
* Streaming does not work with WEBrick as it buffers its response. We recommend using `puma`, though you may find success with other servers
|
|
842
|
+
|
|
843
|
+
### No rendering, please
|
|
844
|
+
|
|
845
|
+
Hanami::Action is designed to be a pure HTTP endpoint, rendering belongs to other layers of MVC.
|
|
846
|
+
You can set the body directly (see [response](#response)), or use [Hanami::View](https://github.com/hanami/view).
|
|
847
|
+
|
|
848
|
+
### Rack integration
|
|
849
|
+
|
|
850
|
+
Hanami::Action is compatible with Rack. If you need to use any Rack middleware, please mount them in `config.ru`.
|
|
851
|
+
|
|
852
|
+
### Thread safety
|
|
853
|
+
|
|
854
|
+
An Action is **immutable**, it works without global state, so it's thread-safe by design.
|
|
855
|
+
|
|
856
|
+
## Contributing
|
|
857
|
+
|
|
858
|
+
1. Fork it
|
|
859
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
|
860
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
|
861
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
|
862
|
+
5. Create new Pull Request
|
|
863
|
+
|
|
864
|
+
## Links
|
|
865
|
+
|
|
866
|
+
- [User documentation](https://hanamirb.org)
|
|
867
|
+
- [API documentation](http://rubydoc.info/gems/hanami-action)
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
## License
|
|
871
|
+
|
|
872
|
+
See `LICENSE` file.
|
|
873
|
+
|