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.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +985 -0
  3. data/LICENSE +20 -0
  4. data/README.md +873 -0
  5. data/hanami-action.gemspec +39 -0
  6. data/lib/hanami/action/body_parser/json.rb +20 -0
  7. data/lib/hanami/action/body_parser/multipart_form.rb +22 -0
  8. data/lib/hanami/action/body_parser.rb +109 -0
  9. data/lib/hanami/action/cache/cache_control.rb +84 -0
  10. data/lib/hanami/action/cache/conditional_get.rb +101 -0
  11. data/lib/hanami/action/cache/directives.rb +126 -0
  12. data/lib/hanami/action/cache/expires.rb +84 -0
  13. data/lib/hanami/action/cache.rb +29 -0
  14. data/lib/hanami/action/config/formats.rb +256 -0
  15. data/lib/hanami/action/config.rb +172 -0
  16. data/lib/hanami/action/constants.rb +283 -0
  17. data/lib/hanami/action/cookie_jar.rb +214 -0
  18. data/lib/hanami/action/cookies.rb +27 -0
  19. data/lib/hanami/action/csrf_protection.rb +217 -0
  20. data/lib/hanami/action/errors.rb +109 -0
  21. data/lib/hanami/action/flash.rb +176 -0
  22. data/lib/hanami/action/halt.rb +18 -0
  23. data/lib/hanami/action/mime/request_mime_weight.rb +66 -0
  24. data/lib/hanami/action/mime.rb +438 -0
  25. data/lib/hanami/action/params.rb +342 -0
  26. data/lib/hanami/action/rack/file.rb +41 -0
  27. data/lib/hanami/action/rack_utils.rb +11 -0
  28. data/lib/hanami/action/request/session.rb +68 -0
  29. data/lib/hanami/action/request.rb +141 -0
  30. data/lib/hanami/action/response.rb +481 -0
  31. data/lib/hanami/action/session.rb +47 -0
  32. data/lib/hanami/action/validatable.rb +166 -0
  33. data/lib/hanami/action/version.rb +13 -0
  34. data/lib/hanami/action/view_name_inferrer.rb +56 -0
  35. data/lib/hanami/action.rb +672 -0
  36. data/lib/hanami/http/status.rb +149 -0
  37. data/lib/hanami-action.rb +3 -0
  38. metadata +153 -0
@@ -0,0 +1,256 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "hanami/utils/kernel"
4
+ require "dry/core"
5
+
6
+ module Hanami
7
+ class Action
8
+ class Config
9
+ # Action format configuration.
10
+ #
11
+ # @since 2.0.0
12
+ # @api private
13
+ class Formats
14
+ include Dry.Equalizer(:accepted, :mapping)
15
+
16
+ # @since 2.0.0
17
+ # @api private
18
+ attr_reader :mapping
19
+
20
+ # The array of formats to accept requests by.
21
+ #
22
+ # @example
23
+ # config.formats.accepted = [:html, :json]
24
+ # config.formats.accepted # => [:html, :json]
25
+ #
26
+ # @since 2.0.0
27
+ # @api public
28
+ attr_reader :accepted
29
+
30
+ # The registered body parsers, as a hash mapping content types to callable parsers.
31
+ #
32
+ # @return [Hash{String => #call}]
33
+ #
34
+ # @since x.x.x
35
+ # @api public
36
+ attr_reader :body_parsers
37
+
38
+ # Returns the default format name.
39
+ #
40
+ # When a request is received that cannot
41
+ #
42
+ # @return [Symbol, nil] the default format name, if any
43
+ #
44
+ # @example
45
+ # @config.formats.default # => :json
46
+ #
47
+ # @since 2.0.0
48
+ # @api public
49
+ attr_reader :default
50
+
51
+ # @since 2.0.0
52
+ # @api private
53
+ def initialize(accepted: [], default: nil, mapping: {})
54
+ @accepted = accepted
55
+ @default = default
56
+ @mapping = mapping
57
+ @body_parsers = {}
58
+
59
+ register_default_body_parsers
60
+ end
61
+
62
+ # @since 2.0.0
63
+ # @api private
64
+ private def initialize_copy(original)
65
+ super
66
+ @accepted = original.accepted.dup
67
+ @default = original.default
68
+ @mapping = original.mapping.dup
69
+ @body_parsers = original.body_parsers.dup
70
+ end
71
+
72
+ # !@attribute [w] accepted
73
+ # @since 2.3.0
74
+ # @api public
75
+ def accepted=(formats)
76
+ @accepted = formats.map { |f| Hanami::Utils::Kernel.Symbol(f) }
77
+ end
78
+
79
+ # @since 2.3.0
80
+ def accept(*formats)
81
+ self.default = formats.first if default.nil?
82
+ self.accepted = accepted | formats
83
+ end
84
+
85
+ # @api private
86
+ def accepted_formats(standard_formats = {})
87
+ accepted.to_h { |format|
88
+ [
89
+ format,
90
+ mapping.fetch(format) { standard_formats[format] }
91
+ ]
92
+ }
93
+ end
94
+
95
+ # @since 2.3.0
96
+ def default=(format)
97
+ @default = format.to_sym
98
+ end
99
+
100
+ # Registers a format and its associated media types.
101
+ #
102
+ # @param format [Symbol] the format name
103
+ # @param media_type [String] the format's media type
104
+ # @param accept_types [Array<String>] media types to accept in request `Accept` headers
105
+ # @param content_types [Array<String>] media types to accept in request `Content-Type` headers
106
+ #
107
+ # @example
108
+ # config.formats.register(:scim, media_type: "application/json+scim")
109
+ #
110
+ # config.formats.register(
111
+ # :jsonapi,
112
+ # "application/vnd.api+json",
113
+ # accept_types: ["application/vnd.api+json", "application/json"],
114
+ # content_types: ["application/vnd.api+json", "application/json"]
115
+ # )
116
+ #
117
+ # @return [self]
118
+ #
119
+ # @since 2.3.0
120
+ # @api public
121
+ def register(format, media_type, accept_types: [media_type], content_types: [media_type], parser: nil)
122
+ mapping[format] = Mime::Format.new(
123
+ name: format.to_sym,
124
+ media_type: media_type,
125
+ accept_types: accept_types,
126
+ content_types: content_types
127
+ )
128
+
129
+ if parser
130
+ Array(content_types).each do |ct|
131
+ @body_parsers[ct.downcase] = parser
132
+ end
133
+ end
134
+
135
+ self
136
+ end
137
+
138
+ # @since 2.0.0
139
+ # @api private
140
+ def empty?
141
+ accepted.empty?
142
+ end
143
+
144
+ # @since 2.0.0
145
+ # @api private
146
+ def any?
147
+ @accepted.any?
148
+ end
149
+
150
+ # @since 2.0.0
151
+ # @api private
152
+ def map(&blk)
153
+ @accepted.map(&blk)
154
+ end
155
+
156
+ # Clears any previously added mappings and format values.
157
+ #
158
+ # @return [self]
159
+ #
160
+ # @since 2.0.0
161
+ # @api public
162
+ def clear
163
+ @accepted = []
164
+ @default = nil
165
+ @mapping = {}
166
+
167
+ self
168
+ end
169
+
170
+ # Returns an array of all accepted media types.
171
+ #
172
+ # @since 2.3.0
173
+ # @api public
174
+ def accept_types
175
+ accepted.map { |format| mapping[format]&.accept_types }.flatten(1).compact
176
+ end
177
+
178
+ # Retrieve the format name associated with the given media type
179
+ #
180
+ # @param media_type [String] the media Type
181
+ #
182
+ # @return [Symbol,NilClass] the associated format name, if any
183
+ #
184
+ # @example
185
+ # @config.formats.format_for("application/json") # => :json
186
+ #
187
+ # @see #mime_type_for
188
+ #
189
+ # @since 2.0.0
190
+ # @api public
191
+ def format_for(media_type)
192
+ mapping.values.reverse.find { |format| format.media_type == media_type }&.name
193
+ end
194
+
195
+ # Returns the media type associated with the given format.
196
+ #
197
+ # @param format [Symbol] the format name
198
+ #
199
+ # @return [String, nil] the associated media type, if any
200
+ #
201
+ # @example
202
+ # @config.formats.media_type_for(:json) # => "application/json"
203
+ #
204
+ # @see #format_for
205
+ #
206
+ # @since 2.3.0
207
+ # @api public
208
+ def media_type_for(format)
209
+ mapping[format]&.media_type
210
+ end
211
+
212
+ # @api private
213
+ def accept_types_for(format)
214
+ mapping[format]&.accept_types || []
215
+ end
216
+
217
+ # @api private
218
+ def content_types_for(format)
219
+ mapping[format]&.content_types || []
220
+ end
221
+
222
+ # @see #media_type_for
223
+ # @since 2.0.0
224
+ # @api public
225
+ alias_method :mime_type_for, :media_type_for
226
+
227
+ # @see #media_type_for
228
+ # @since 2.0.0
229
+ # @api public
230
+ alias_method :mime_types_for, :accept_types_for
231
+
232
+ # Finds the parser for a content type.
233
+ #
234
+ # @param content_type [String] the content type
235
+ #
236
+ # @return [#call, nil] the parser callable, if registered
237
+ #
238
+ # @api private
239
+ def body_parser_for(content_type)
240
+ @body_parsers[content_type&.downcase]
241
+ end
242
+
243
+ private
244
+
245
+ def register_default_body_parsers
246
+ # Multipart forms (ordinary urlencoded forms are handled by Rack automatically)
247
+ @body_parsers["multipart/form-data"] = BodyParser::MultipartForm
248
+
249
+ # JSON
250
+ @body_parsers["application/json"] = BodyParser::JSON
251
+ @body_parsers["application/vnd.api+json"] = BodyParser::JSON
252
+ end
253
+ end
254
+ end
255
+ end
256
+ end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/configurable"
4
+
5
+ module Hanami
6
+ class Action
7
+ # Config for `Hanami::Action` classes.
8
+ #
9
+ # @see Hanami::Action.config
10
+ #
11
+ # @api public
12
+ # @since 2.0.0
13
+ class Config < Dry::Configurable::Config
14
+ # Default public directory
15
+ #
16
+ # This serves as the root directory for file downloads
17
+ #
18
+ # @since 1.0.0
19
+ #
20
+ # @api private
21
+ DEFAULT_PUBLIC_DIRECTORY = "public"
22
+
23
+ # @!attribute [rw] handled_exceptions
24
+ #
25
+ # Specifies how to handle exceptions with an HTTP status.
26
+ #
27
+ # Raised exceptions will return the corresponding HTTP status.
28
+ #
29
+ # @return [Hash{Exception=>Integer}] exception classes as keys and HTTP statuses as values
30
+ #
31
+ # @example
32
+ # config.handled_exceptions = {ArgumentError => 400}
33
+ #
34
+ # @since 0.2.0
35
+
36
+ # Specifies how to handle exceptions with an HTTP status
37
+ #
38
+ # Raised exceptions will return the corresponding HTTP status
39
+ #
40
+ # The specified exceptions will be merged with any previously configured
41
+ # exceptions
42
+ #
43
+ # @param exceptions [Hash{Exception=>Integer}] exception classes as keys
44
+ # and HTTP statuses as values
45
+ #
46
+ # @return [void]
47
+ #
48
+ # @example
49
+ # config.handle_exceptions(ArgumentError => 400}
50
+ #
51
+ # @see handled_exceptions
52
+ #
53
+ # @since 0.2.0
54
+ def handle_exception(exceptions)
55
+ self.handled_exceptions = handled_exceptions
56
+ .merge(exceptions)
57
+ .sort do |(ex1, _), (ex2, _)|
58
+ next 0 if [ex1, ex2].any?(String)
59
+
60
+ ex1.ancestors.include?(ex2) ? -1 : 1
61
+ end
62
+ .to_h
63
+ end
64
+
65
+ # @!attribute [r] formats
66
+ # Returns the format config for the action.
67
+ #
68
+ # @return [Config::Formats]
69
+ #
70
+ # @since 2.0.0
71
+ # @api public
72
+
73
+ # @!attribute [rw] default_charset
74
+ #
75
+ # Sets a charset (character set) as default fallback for all the requests
76
+ # without a strict requirement for the charset.
77
+ #
78
+ # By default, this value is nil.
79
+ #
80
+ # @return [String]
81
+ #
82
+ # @see Hanami::Action::Mime
83
+ #
84
+ # @since 0.3.0
85
+
86
+ # @!attribute [rw] default_headers
87
+ #
88
+ # Sets default headers for all responses.
89
+ #
90
+ # By default, this is an empty hash.
91
+ #
92
+ # @return [Hash{String=>String}] the headers
93
+ #
94
+ # @example
95
+ # config.default_headers = {"X-Frame-Options" => "DENY"}
96
+ #
97
+ # @see default_headers
98
+ #
99
+ # @since 0.4.0
100
+
101
+ # @!attribute [rw] default_tld_length
102
+ #
103
+ # Sets the default TLD length for host names. It is used to extract the
104
+ # subdomain(s) in `Request#subdomains`.
105
+ #
106
+ # Defaults to 1.
107
+ #
108
+ # @example
109
+ # # For *.example.com
110
+ # config.default_tld_length = 1
111
+ #
112
+ # # Or for *.example.co.uk
113
+ # config.default_tld_length = 2
114
+ #
115
+ # @return [Integer] the number of subdomains
116
+ #
117
+ # @since 2.3.0
118
+
119
+ # @!attribute [rw] cookies
120
+ #
121
+ # Sets default cookie options for all responses.
122
+ #
123
+ # By default this, is an empty hash.
124
+ #
125
+ # @return [Hash{Symbol=>String}] the cookie options
126
+ #
127
+ # @example
128
+ # config.cookies = {
129
+ # domain: "hanamirb.org",
130
+ # path: "/action",
131
+ # secure: true,
132
+ # httponly: true
133
+ # }
134
+ #
135
+ # @since 0.4.0
136
+
137
+ # @!attribute [rw] root_directory
138
+ #
139
+ # Sets the the for the public directory, which is used for file downloads.
140
+ # This must be an existent directory.
141
+ #
142
+ # Defaults to the current working directory.
143
+ #
144
+ # @return [String] the directory path
145
+ #
146
+ # @api private
147
+ #
148
+ # @since 1.0.0
149
+
150
+ # @!attribute [rw] public_directory
151
+ #
152
+ # Sets the path to public directory. This directory is used for file downloads.
153
+ #
154
+ # This given directory will be appended onto the root directory.
155
+ #
156
+ # By default, the public directory is `"public"`.
157
+ # @return [String] the public directory path
158
+ #
159
+ # @example
160
+ # config.public_directory = "public"
161
+ # config.public_directory # => "/path/to/root/public"
162
+ #
163
+ # @see root_directory
164
+ #
165
+ # @since 2.0.0
166
+ def public_directory
167
+ # This must be a string, for Rack compatibility
168
+ root_directory.join(super).to_s
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,283 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack"
4
+
5
+ module Hanami
6
+ class Action
7
+ # Rack SPEC response code
8
+ #
9
+ # @since 1.0.0
10
+ # @api private
11
+ RESPONSE_CODE = 0
12
+
13
+ # Rack SPEC response headers
14
+ #
15
+ # @since 1.0.0
16
+ # @api private
17
+ RESPONSE_HEADERS = 1
18
+
19
+ # Rack SPEC response body
20
+ #
21
+ # @since 1.0.0
22
+ # @api private
23
+ RESPONSE_BODY = 2
24
+
25
+ # @since 1.0.0
26
+ # @api private
27
+ DEFAULT_ERROR_CODE = 500
28
+
29
+ # Status codes that by RFC must not include a message body
30
+ #
31
+ # @since 0.3.2
32
+ # @api private
33
+ HTTP_STATUSES_WITHOUT_BODY = Set.new((100..199).to_a << 204 << 205 << 304).freeze
34
+
35
+ # Not Found
36
+ #
37
+ # @since 1.0.0
38
+ # @api private
39
+ NOT_FOUND = 404
40
+
41
+ # Entity headers allowed in blank body responses, according to
42
+ # RFC 2616 - Section 10 (HTTP 1.1).
43
+ #
44
+ # "The response MAY include new or updated metainformation in the form
45
+ # of entity-headers".
46
+ #
47
+ # @since 0.4.0
48
+ # @api private
49
+ #
50
+ # @see http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.2.5
51
+ # @see http://www.w3.org/Protocols/rfc2616/rfc2616-sec7.html
52
+ ENTITY_HEADERS = {
53
+ "allow" => true,
54
+ "content-encoding" => true,
55
+ "content-language" => true,
56
+ "content-location" => true,
57
+ "content-md5" => true,
58
+ "content-range" => true,
59
+ "expires" => true,
60
+ "last-modified" => true,
61
+ "extension-header" => true
62
+ }.freeze
63
+
64
+ # The request relative path
65
+ #
66
+ # @since 2.0.0
67
+ # @api private
68
+ PATH_INFO = ::Rack::PATH_INFO
69
+
70
+ # The request content type (env key, CGI-style uppercase). Rack exposes a `CONTENT_TYPE`
71
+ # constant but it refers to the response header (lowercase in Rack 3), not the env key.
72
+ #
73
+ # @api private
74
+ CONTENT_TYPE = "CONTENT_TYPE"
75
+
76
+ # The request method
77
+ #
78
+ # @since 0.3.2
79
+ # @api private
80
+ REQUEST_METHOD = ::Rack::REQUEST_METHOD
81
+
82
+ # The Content-Length HTTP header
83
+ #
84
+ # @since 1.0.0
85
+ # @api private
86
+ CONTENT_LENGTH = ::Rack::CONTENT_LENGTH
87
+
88
+ # The non-standard HTTP header to pass the control over when a resource
89
+ # cannot be found by the current endpoint
90
+ #
91
+ # @since 1.0.0
92
+ # @api private
93
+ X_CASCADE = "X-Cascade"
94
+
95
+ # HEAD request
96
+ #
97
+ # @since 0.3.2
98
+ # @api private
99
+ HEAD = ::Rack::HEAD
100
+
101
+ # GET request
102
+ #
103
+ # @since 2.0.0
104
+ # @api private
105
+ GET = ::Rack::GET
106
+
107
+ # TRACE request
108
+ #
109
+ # @since 2.0.0
110
+ # @api private
111
+ TRACE = ::Rack::TRACE
112
+
113
+ # OPTIONS request
114
+ #
115
+ # @since 2.0.0
116
+ # @api private
117
+ OPTIONS = ::Rack::OPTIONS
118
+
119
+ # The key that returns accepted mime types from the Rack env
120
+ #
121
+ # @since 0.1.0
122
+ # @api private
123
+ HTTP_ACCEPT = "HTTP_ACCEPT"
124
+
125
+ # The default mime type for an incoming HTTP request
126
+ #
127
+ # @since 0.1.0
128
+ # @api private
129
+ DEFAULT_ACCEPT = "*/*"
130
+
131
+ # The default mime type that is returned in the response
132
+ #
133
+ # @since 0.1.0
134
+ # @api private
135
+ DEFAULT_CONTENT_TYPE = "application/octet-stream"
136
+
137
+ # @since 0.2.0
138
+ # @api private
139
+ RACK_ERRORS = ::Rack::RACK_ERRORS
140
+
141
+ # The HTTP header for Cache-Control
142
+ #
143
+ # @since 2.0.0
144
+ # @api private
145
+ CACHE_CONTROL = ::Rack::CACHE_CONTROL
146
+
147
+ # @since 2.0.0
148
+ # @api private
149
+ IF_NONE_MATCH = "HTTP_IF_NONE_MATCH"
150
+
151
+ # The HTTP header for ETag
152
+ #
153
+ # @since 2.0.0
154
+ # @api private
155
+ ETAG = ::Rack::ETAG
156
+
157
+ # @since 2.0.0
158
+ # @api private
159
+ IF_MODIFIED_SINCE = "HTTP_IF_MODIFIED_SINCE"
160
+
161
+ # The HTTP header for Expires
162
+ #
163
+ # @since 2.0.0
164
+ # @api private
165
+ EXPIRES = ::Rack::EXPIRES
166
+
167
+ # The HTTP header for Last-Modified
168
+ #
169
+ # @since 0.3.0
170
+ # @api private
171
+ LAST_MODIFIED = "Last-Modified"
172
+
173
+ # This isn't part of Rack SPEC
174
+ #
175
+ # Exception notifiers use <tt>rack.exception</tt> instead of
176
+ # <tt>rack.errors</tt>, so we need to support it.
177
+ #
178
+ # @since 0.5.0
179
+ # @api private
180
+ #
181
+ # @see Hanami::Action::Throwable::RACK_ERRORS
182
+ # @see http://www.rubydoc.info/github/rack/rack/file/SPEC#The_Error_Stream
183
+ # @see https://github.com/hanami/hanami-action/issues/133
184
+ RACK_EXCEPTION = "rack.exception"
185
+
186
+ # The HTTP header for redirects
187
+ #
188
+ # @since 0.2.0
189
+ # @api private
190
+ LOCATION = "Location"
191
+
192
+ # The key that returns Rack session params from the Rack env
193
+ # Please note that this is used only when an action is unit tested.
194
+ #
195
+ # @since 2.0.0
196
+ # @api private
197
+ #
198
+ # @example
199
+ # # action unit test
200
+ # action.call("rack.session" => { "foo" => "bar" })
201
+ # action.session[:foo] # => "bar"
202
+ #
203
+ # @api private
204
+ RACK_SESSION = ::Rack::RACK_SESSION
205
+
206
+ # @since 2.0.0
207
+ # @api private
208
+ REQUEST_ID = "hanami.request_id"
209
+
210
+ # @since 2.0.0
211
+ # @api private
212
+ DEFAULT_ID_LENGTH = 16
213
+
214
+ # The key that returns raw cookies from the Rack env
215
+ #
216
+ # @since 2.0.0
217
+ # @api private
218
+ HTTP_COOKIE = ::Rack::HTTP_COOKIE
219
+
220
+ # The key used by Rack to set the cookies as an Hash in the env
221
+ #
222
+ # @since 2.0.0
223
+ # @api private
224
+ COOKIE_HASH_KEY = ::Rack::RACK_REQUEST_COOKIE_HASH
225
+
226
+ # The key used by Rack to set the cookies as a String in the env
227
+ #
228
+ # @since 2.0.0
229
+ # @api private
230
+ COOKIE_STRING_KEY = ::Rack::RACK_REQUEST_COOKIE_STRING
231
+
232
+ # The key that returns raw input from the Rack env
233
+ #
234
+ # @since 2.0.0
235
+ # @api private
236
+ RACK_INPUT = ::Rack::RACK_INPUT
237
+
238
+ # The key that returns a request body parsed by Hanami Action.
239
+ #
240
+ # This is the canonical key for body parsing.
241
+ #
242
+ # @since x.x.x
243
+ # @api private
244
+ ACTION_PARSED_BODY = "hanami.action.parsed_body"
245
+
246
+ # The key that returns params from a parsed body by Hanami Action.
247
+ #
248
+ # This is the canonical key for parsed body params.
249
+ #
250
+ # @since x.x.x
251
+ # @api private
252
+ ACTION_BODY_PARAMS = "hanami.action.body_params"
253
+
254
+ # The key that returns a request body parsed by Hanami Router.
255
+ #
256
+ # This is maintained for backward compatibility with Hanami Router.
257
+ #
258
+ # @api private
259
+ ROUTER_PARSED_BODY = "router.parsed_body"
260
+
261
+ # The key that returns router params from the Rack env.
262
+ #
263
+ # This is maintained for backward compatibility with Hanami Router.
264
+ #
265
+ # @since 2.0.0
266
+ # @api private
267
+ ROUTER_PARAMS = "router.params"
268
+
269
+ # Default HTTP request method for Rack env
270
+ #
271
+ # @since 2.0.0
272
+ # @api private
273
+ DEFAULT_REQUEST_METHOD = GET
274
+
275
+ # @since 2.0.0
276
+ # @api private
277
+ DEFAULT_CHARSET = "utf-8"
278
+
279
+ # @since 2.2.0
280
+ # @api private
281
+ ACTION_INSTANCE = "hanami.action_instance"
282
+ end
283
+ end