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
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The Hanami::Action::Flash implementation is derived from Roda's FlashHash, also released under the
|
|
4
|
+
# MIT Licence:
|
|
5
|
+
#
|
|
6
|
+
# Copyright (c) 2014-2020 Jeremy Evans
|
|
7
|
+
# Copyright (c) 2010-2014 Michel Martens, Damian Janowski and Cyril David
|
|
8
|
+
# Copyright (c) 2008-2009 Christian Neukirchen
|
|
9
|
+
|
|
10
|
+
module Hanami
|
|
11
|
+
class Action
|
|
12
|
+
# A container to transport data with the HTTP session, with a lifespan of just one HTTP request
|
|
13
|
+
# or redirect.
|
|
14
|
+
#
|
|
15
|
+
# Behaves like a hash, returning entries for the current request, except for {#[]=}, which
|
|
16
|
+
# updates the hash for the next request.
|
|
17
|
+
#
|
|
18
|
+
# @since 0.3.0
|
|
19
|
+
# @api public
|
|
20
|
+
class Flash
|
|
21
|
+
# @since 2.0.0
|
|
22
|
+
# @api private
|
|
23
|
+
KEY = "_flash"
|
|
24
|
+
|
|
25
|
+
# @return [Hash] The flash hash for the next request, written to by {#[]=}.
|
|
26
|
+
#
|
|
27
|
+
# @see #[]=
|
|
28
|
+
#
|
|
29
|
+
# @since 2.0.0
|
|
30
|
+
# @api public
|
|
31
|
+
attr_reader :next
|
|
32
|
+
|
|
33
|
+
# Returns a new flash object.
|
|
34
|
+
#
|
|
35
|
+
# @param hash [Hash, nil] the flash hash for the current request; `nil` will become an empty hash.
|
|
36
|
+
#
|
|
37
|
+
# @since 0.3.0
|
|
38
|
+
# @api public
|
|
39
|
+
def initialize(hash = {})
|
|
40
|
+
@flash = hash || {}
|
|
41
|
+
@next = {}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Returns the flash hash for the current request.
|
|
45
|
+
#
|
|
46
|
+
# @return [Hash] the flash hash for the current request
|
|
47
|
+
#
|
|
48
|
+
# @since 2.0.0
|
|
49
|
+
# @api public
|
|
50
|
+
def now
|
|
51
|
+
@flash
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Returns the value for the given key in the current hash.
|
|
55
|
+
#
|
|
56
|
+
# @param key [Object] the key
|
|
57
|
+
#
|
|
58
|
+
# @return [Object, nil] the value
|
|
59
|
+
#
|
|
60
|
+
# @since 0.3.0
|
|
61
|
+
# @api public
|
|
62
|
+
def [](key)
|
|
63
|
+
@flash[key]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Updates the next hash with the given key and value.
|
|
67
|
+
#
|
|
68
|
+
# @param key [Object] the key
|
|
69
|
+
# @param value [Object] the value
|
|
70
|
+
#
|
|
71
|
+
# @since 0.3.0
|
|
72
|
+
# @api public
|
|
73
|
+
def []=(key, value)
|
|
74
|
+
@next[key] = value
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Calls the given block once for each element in the current hash.
|
|
78
|
+
#
|
|
79
|
+
# @yieldparam element [Array<(Object, Object)>] array containing the key and value from the
|
|
80
|
+
# hash
|
|
81
|
+
#
|
|
82
|
+
# @return [now]
|
|
83
|
+
#
|
|
84
|
+
# @since 1.2.0
|
|
85
|
+
# @api public
|
|
86
|
+
def each(&block)
|
|
87
|
+
@flash.each(&block)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Returns an array of objects returned by the block, called once for each element in the
|
|
91
|
+
# current hash.
|
|
92
|
+
#
|
|
93
|
+
# @yieldparam element [Array<(Object, Object)>] array containing the key and value from the
|
|
94
|
+
# hash
|
|
95
|
+
#
|
|
96
|
+
# @return [Array]
|
|
97
|
+
#
|
|
98
|
+
# @since 1.2.0
|
|
99
|
+
# @api public
|
|
100
|
+
def map(&block)
|
|
101
|
+
@flash.map(&block)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Returns `true` if the current hash contains no elements.
|
|
105
|
+
#
|
|
106
|
+
# @return [Boolean]
|
|
107
|
+
#
|
|
108
|
+
# @since 0.3.0
|
|
109
|
+
# @api public
|
|
110
|
+
def empty?
|
|
111
|
+
@flash.empty?
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Returns `true` if the given key is present in the current hash.
|
|
115
|
+
#
|
|
116
|
+
# @return [Boolean]
|
|
117
|
+
#
|
|
118
|
+
# @since 2.0.0
|
|
119
|
+
# @api public
|
|
120
|
+
def key?(key)
|
|
121
|
+
@flash.key?(key)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Removes entries from the next hash.
|
|
125
|
+
#
|
|
126
|
+
# @overload discard(key)
|
|
127
|
+
# Removes the given key from the next hash
|
|
128
|
+
#
|
|
129
|
+
# @param key [Object] key to discard
|
|
130
|
+
#
|
|
131
|
+
# @overload discard
|
|
132
|
+
# Clears the next hash
|
|
133
|
+
#
|
|
134
|
+
# @since 2.0.0
|
|
135
|
+
# @api public
|
|
136
|
+
def discard(key = (no_arg = true))
|
|
137
|
+
if no_arg
|
|
138
|
+
@next.clear
|
|
139
|
+
else
|
|
140
|
+
@next.delete(key)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Copies entries from the current hash to the next hash
|
|
145
|
+
#
|
|
146
|
+
# @overload keep(key)
|
|
147
|
+
# Copies the entry for the given key from the current hash to the next
|
|
148
|
+
# hash
|
|
149
|
+
#
|
|
150
|
+
# @param key [Object] key to copy
|
|
151
|
+
#
|
|
152
|
+
# @overload keep
|
|
153
|
+
# Copies all entries from the current hash to the next hash
|
|
154
|
+
#
|
|
155
|
+
# @since 2.0.0
|
|
156
|
+
# @api public
|
|
157
|
+
def keep(key = (no_arg = true))
|
|
158
|
+
if no_arg
|
|
159
|
+
@next.merge!(@flash)
|
|
160
|
+
else
|
|
161
|
+
self[key] = self[key]
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Replaces the current hash with the next hash and clears the next hash
|
|
166
|
+
#
|
|
167
|
+
# @since 2.0.0
|
|
168
|
+
# @api public
|
|
169
|
+
def sweep
|
|
170
|
+
@flash = @next.dup
|
|
171
|
+
@next.clear
|
|
172
|
+
self
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "hanami/http/status"
|
|
4
|
+
|
|
5
|
+
module Hanami
|
|
6
|
+
class Action
|
|
7
|
+
# @api private
|
|
8
|
+
# @since 2.0.0
|
|
9
|
+
module Halt
|
|
10
|
+
# @api private
|
|
11
|
+
# @since 2.0.0
|
|
12
|
+
def self.call(status, body = nil)
|
|
13
|
+
code, message = Http::Status.for_code(status)
|
|
14
|
+
throw :halt, [code, body || message]
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hanami
|
|
4
|
+
class Action
|
|
5
|
+
module Mime
|
|
6
|
+
# @since 1.0.1
|
|
7
|
+
# @api private
|
|
8
|
+
class RequestMimeWeight
|
|
9
|
+
# @since 2.0.0
|
|
10
|
+
# @api private
|
|
11
|
+
MIME_SEPARATOR = "/"
|
|
12
|
+
private_constant :MIME_SEPARATOR
|
|
13
|
+
|
|
14
|
+
# @since 2.0.0
|
|
15
|
+
# @api private
|
|
16
|
+
MIME_WILDCARD = "*"
|
|
17
|
+
private_constant :MIME_WILDCARD
|
|
18
|
+
|
|
19
|
+
include Comparable
|
|
20
|
+
|
|
21
|
+
# @since 1.0.1
|
|
22
|
+
# @api private
|
|
23
|
+
attr_reader :quality
|
|
24
|
+
|
|
25
|
+
# @since 1.0.1
|
|
26
|
+
# @api private
|
|
27
|
+
attr_reader :index
|
|
28
|
+
|
|
29
|
+
# @since 1.0.1
|
|
30
|
+
# @api private
|
|
31
|
+
attr_reader :mime
|
|
32
|
+
|
|
33
|
+
# @since 1.0.1
|
|
34
|
+
# @api private
|
|
35
|
+
attr_reader :format
|
|
36
|
+
|
|
37
|
+
# @since 1.0.1
|
|
38
|
+
# @api private
|
|
39
|
+
attr_reader :priority
|
|
40
|
+
|
|
41
|
+
# @since 1.0.1
|
|
42
|
+
# @api private
|
|
43
|
+
def initialize(mime, quality, index, format = mime)
|
|
44
|
+
@quality, @index, @format = quality, index, format
|
|
45
|
+
calculate_priority(mime)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @since 1.0.1
|
|
49
|
+
# @api private
|
|
50
|
+
def <=>(other)
|
|
51
|
+
return priority <=> other.priority unless priority == other.priority
|
|
52
|
+
|
|
53
|
+
other.index <=> index
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
# @since 1.0.1
|
|
59
|
+
# @api private
|
|
60
|
+
def calculate_priority(mime)
|
|
61
|
+
@priority ||= (mime.split(MIME_SEPARATOR, 2).count(MIME_WILDCARD) * -10) + quality
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "hanami/utils"
|
|
4
|
+
require "rack/utils"
|
|
5
|
+
require "rack/mime"
|
|
6
|
+
require_relative "errors"
|
|
7
|
+
|
|
8
|
+
module Hanami
|
|
9
|
+
class Action
|
|
10
|
+
# @api private
|
|
11
|
+
module Mime
|
|
12
|
+
# Most commom media types used for responses
|
|
13
|
+
#
|
|
14
|
+
# @since 1.0.0
|
|
15
|
+
# @api public
|
|
16
|
+
TYPES = {
|
|
17
|
+
atom: "application/atom+xml",
|
|
18
|
+
avi: "video/x-msvideo",
|
|
19
|
+
bmp: "image/bmp",
|
|
20
|
+
bz2: "application/x-bzip2",
|
|
21
|
+
bz: "application/x-bzip",
|
|
22
|
+
chm: "application/vnd.ms-htmlhelp",
|
|
23
|
+
css: "text/css",
|
|
24
|
+
csv: "text/csv",
|
|
25
|
+
flv: "video/x-flv",
|
|
26
|
+
form: "application/x-www-form-urlencoded",
|
|
27
|
+
gif: "image/gif",
|
|
28
|
+
gz: "application/x-gzip",
|
|
29
|
+
h264: "video/h264",
|
|
30
|
+
html: "text/html",
|
|
31
|
+
ico: "image/vnd.microsoft.icon",
|
|
32
|
+
ics: "text/calendar",
|
|
33
|
+
jpg: "image/jpeg",
|
|
34
|
+
js: "application/javascript",
|
|
35
|
+
json: "application/json",
|
|
36
|
+
manifest: "text/cache-manifest",
|
|
37
|
+
mov: "video/quicktime",
|
|
38
|
+
mp3: "audio/mpeg",
|
|
39
|
+
mp4: "video/mp4",
|
|
40
|
+
mp4a: "audio/mp4",
|
|
41
|
+
mpg: "video/mpeg",
|
|
42
|
+
multipart: "multipart/form-data",
|
|
43
|
+
oga: "audio/ogg",
|
|
44
|
+
ogg: "application/ogg",
|
|
45
|
+
ogv: "video/ogg",
|
|
46
|
+
pdf: "application/pdf",
|
|
47
|
+
pgp: "application/pgp-encrypted",
|
|
48
|
+
png: "image/png",
|
|
49
|
+
psd: "image/vnd.adobe.photoshop",
|
|
50
|
+
rss: "application/rss+xml",
|
|
51
|
+
rtf: "application/rtf",
|
|
52
|
+
sh: "application/x-sh",
|
|
53
|
+
svg: "image/svg+xml",
|
|
54
|
+
swf: "application/x-shockwave-flash",
|
|
55
|
+
tar: "application/x-tar",
|
|
56
|
+
torrent: "application/x-bittorrent",
|
|
57
|
+
tsv: "text/tab-separated-values",
|
|
58
|
+
txt: "text/plain",
|
|
59
|
+
uri: "text/uri-list",
|
|
60
|
+
vcs: "text/x-vcalendar",
|
|
61
|
+
wav: "audio/x-wav",
|
|
62
|
+
webm: "video/webm",
|
|
63
|
+
wmv: "video/x-ms-wmv",
|
|
64
|
+
woff2: "application/font-woff2",
|
|
65
|
+
woff: "application/font-woff",
|
|
66
|
+
wsdl: "application/wsdl+xml",
|
|
67
|
+
xhtml: "application/xhtml+xml",
|
|
68
|
+
xml: "application/xml",
|
|
69
|
+
xslt: "application/xslt+xml",
|
|
70
|
+
yml: "text/yaml",
|
|
71
|
+
zip: "application/zip"
|
|
72
|
+
}.freeze
|
|
73
|
+
|
|
74
|
+
# @api private
|
|
75
|
+
ANY_TYPE = "*/*"
|
|
76
|
+
|
|
77
|
+
# @api private
|
|
78
|
+
Format = Data.define(:name, :media_type, :accept_types, :content_types) do
|
|
79
|
+
def initialize(name:, media_type:, accept_types: [media_type], content_types: [media_type])
|
|
80
|
+
super
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# @api private
|
|
85
|
+
FORMATS = TYPES
|
|
86
|
+
.to_h { |name, media_type| [name, Format.new(name:, media_type:)] }
|
|
87
|
+
.update(
|
|
88
|
+
all: Format.new(
|
|
89
|
+
name: :all,
|
|
90
|
+
media_type: "application/octet-stream",
|
|
91
|
+
accept_types: ["*/*"],
|
|
92
|
+
content_types: ["*/*"]
|
|
93
|
+
),
|
|
94
|
+
html: Format.new(
|
|
95
|
+
name: :html,
|
|
96
|
+
media_type: "text/html",
|
|
97
|
+
content_types: ["application/x-www-form-urlencoded", "multipart/form-data"]
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
.freeze
|
|
101
|
+
private_constant :FORMATS
|
|
102
|
+
|
|
103
|
+
# @api private
|
|
104
|
+
MEDIA_TYPES_TO_FORMATS = FORMATS.each_with_object({}) { |(_name, format), hsh|
|
|
105
|
+
hsh[format.media_type] = format
|
|
106
|
+
}.freeze
|
|
107
|
+
private_constant :MEDIA_TYPES_TO_FORMATS
|
|
108
|
+
|
|
109
|
+
# @api private
|
|
110
|
+
ACCEPT_TYPES_TO_FORMATS = FORMATS.each_with_object({}) { |(_name, format), hsh|
|
|
111
|
+
format.accept_types.each { |type| hsh[type] = format }
|
|
112
|
+
}.freeze
|
|
113
|
+
private_constant :ACCEPT_TYPES_TO_FORMATS
|
|
114
|
+
|
|
115
|
+
class << self
|
|
116
|
+
# Yields if an action is configured with `formats`, the request has an `Accept` header, and
|
|
117
|
+
# none of the Accept types matches the accepted formats. The given block is expected to halt
|
|
118
|
+
# the request handling.
|
|
119
|
+
#
|
|
120
|
+
# If any of these conditions are not met, then the request is acceptable and the method
|
|
121
|
+
# returns without yielding.
|
|
122
|
+
#
|
|
123
|
+
# @see Action#enforce_accepted_media_types
|
|
124
|
+
# @see Config#formats
|
|
125
|
+
#
|
|
126
|
+
# @api private
|
|
127
|
+
def enforce_accept(request, config)
|
|
128
|
+
return unless request.accept_header?
|
|
129
|
+
|
|
130
|
+
accept_types = ::Rack::Utils.q_values(request.accept).map(&:first)
|
|
131
|
+
return if accept_types.any? { |type| accepted_type?(type, config) }
|
|
132
|
+
|
|
133
|
+
yield
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Yields if an action is configured with `formats`, the request has a `Content-Type` header,
|
|
137
|
+
# and the content type does not match the accepted formats. The given block is expected to
|
|
138
|
+
# halt the request handling.
|
|
139
|
+
#
|
|
140
|
+
# If any of these conditions are not met, then the request is acceptable and the method
|
|
141
|
+
# returns without yielding.
|
|
142
|
+
#
|
|
143
|
+
# @see Action#enforce_accepted_media_types
|
|
144
|
+
# @see Config#formats
|
|
145
|
+
#
|
|
146
|
+
# @api private
|
|
147
|
+
def enforce_content_type(request, config)
|
|
148
|
+
# Compare media type (without parameters) instead of full Content-Type header to avoid
|
|
149
|
+
# false negatives (e.g., multipart/form-data; boundary=...)
|
|
150
|
+
media_type = request.media_type
|
|
151
|
+
|
|
152
|
+
return if media_type.nil?
|
|
153
|
+
|
|
154
|
+
return if accepted_content_type?(media_type, config)
|
|
155
|
+
|
|
156
|
+
yield
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Returns a string combining a media type and charset, intended for setting as the
|
|
160
|
+
# `Content-Type` header for the response to the given request.
|
|
161
|
+
#
|
|
162
|
+
# This uses the request's `Accept` header (if present) along with the configured formats to
|
|
163
|
+
# determine the best content type to return.
|
|
164
|
+
#
|
|
165
|
+
# @return [String]
|
|
166
|
+
#
|
|
167
|
+
# @see Action#call
|
|
168
|
+
#
|
|
169
|
+
# @api private
|
|
170
|
+
def response_content_type_with_charset(request, config)
|
|
171
|
+
content_type_with_charset(
|
|
172
|
+
response_content_type(request, config),
|
|
173
|
+
config.default_charset || Action::DEFAULT_CHARSET
|
|
174
|
+
)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Returns the default response Content-Type (with charset) for a request without a usable
|
|
178
|
+
# `Accept` header. This depends only on config, so an action can compute it once and reuse
|
|
179
|
+
# it across requests instead of recomputing per call.
|
|
180
|
+
#
|
|
181
|
+
# @return [String]
|
|
182
|
+
#
|
|
183
|
+
# @see Action#call
|
|
184
|
+
#
|
|
185
|
+
# @api private
|
|
186
|
+
def default_response_content_type_with_charset(config)
|
|
187
|
+
content_type_with_charset(
|
|
188
|
+
default_response_content_type(config),
|
|
189
|
+
config.default_charset || Action::DEFAULT_CHARSET
|
|
190
|
+
)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Returns a format name for the given content type.
|
|
194
|
+
#
|
|
195
|
+
# The format name will come from the configured formats, if such a format is configured
|
|
196
|
+
# there, or instead from the default list of formats in `Mime::TYPES`.
|
|
197
|
+
#
|
|
198
|
+
# Returns nil if no matching format can be found.
|
|
199
|
+
#
|
|
200
|
+
# This is used to return the format name a {Response}.
|
|
201
|
+
#
|
|
202
|
+
# @example
|
|
203
|
+
# format_from_media_type("application/json;charset=utf-8", config) # => :json
|
|
204
|
+
#
|
|
205
|
+
# @return [Symbol, nil]
|
|
206
|
+
#
|
|
207
|
+
# @see Response#format
|
|
208
|
+
# @see Action#finish
|
|
209
|
+
#
|
|
210
|
+
# @api private
|
|
211
|
+
def format_from_media_type(media_type, config)
|
|
212
|
+
return if media_type.nil?
|
|
213
|
+
|
|
214
|
+
mt = extract_media_type(media_type)
|
|
215
|
+
config.formats.format_for(mt) || MEDIA_TYPES_TO_FORMATS[mt]&.name
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Returns a format name and content type pair for a given format name or content type
|
|
219
|
+
# string.
|
|
220
|
+
#
|
|
221
|
+
# @example
|
|
222
|
+
# format_and_media_type(:json, config)
|
|
223
|
+
# # => [:json, "application/json"]
|
|
224
|
+
#
|
|
225
|
+
# format_and_media_type("application/json", config)
|
|
226
|
+
# # => [:json, "application/json"]
|
|
227
|
+
#
|
|
228
|
+
# @example Unknown format name
|
|
229
|
+
# format_and_media_type(:unknown, config)
|
|
230
|
+
# # raises Hanami::Action::UnknownFormatError
|
|
231
|
+
#
|
|
232
|
+
# @example Unknown content type
|
|
233
|
+
# format_and_media_type("application/unknown", config)
|
|
234
|
+
# # => [nil, "application/unknown"]
|
|
235
|
+
#
|
|
236
|
+
# @return [Array<(Symbol, String)>]
|
|
237
|
+
#
|
|
238
|
+
# @raise [Hanami::Action::UnknownFormatError] if an unknown format name is given
|
|
239
|
+
#
|
|
240
|
+
# @api private
|
|
241
|
+
def format_and_media_type(value, config)
|
|
242
|
+
case value
|
|
243
|
+
when Symbol
|
|
244
|
+
[value, format_to_media_type(value, config)]
|
|
245
|
+
when String
|
|
246
|
+
[format_from_media_type(value, config), value]
|
|
247
|
+
else
|
|
248
|
+
raise UnknownFormatError.new(value)
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Returns a string combining the given content type and charset, intended for setting as a
|
|
253
|
+
# `Content-Type` header.
|
|
254
|
+
#
|
|
255
|
+
# @example
|
|
256
|
+
# Mime.content_type_with_charset("application/json", "utf-8")
|
|
257
|
+
# # => "application/json; charset=utf-8"
|
|
258
|
+
#
|
|
259
|
+
# @param content_type [String]
|
|
260
|
+
# @param charset [String]
|
|
261
|
+
#
|
|
262
|
+
# @return [String]
|
|
263
|
+
#
|
|
264
|
+
# @api private
|
|
265
|
+
def content_type_with_charset(content_type, charset)
|
|
266
|
+
"#{content_type}; charset=#{charset}"
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Extracts the media type from a Content-Type header value, removing parameters
|
|
270
|
+
# like charset, boundary, etc.
|
|
271
|
+
#
|
|
272
|
+
# @param content_type [String, nil] the Content-Type header value
|
|
273
|
+
# @return [String, nil] the media type without parameters, downcased
|
|
274
|
+
#
|
|
275
|
+
# @example
|
|
276
|
+
# extract_media_type("application/json; charset=utf-8")
|
|
277
|
+
# # => "application/json"
|
|
278
|
+
#
|
|
279
|
+
# extract_media_type("multipart/form-data; boundary=----WebKitFormBoundary")
|
|
280
|
+
# # => "multipart/form-data"
|
|
281
|
+
#
|
|
282
|
+
# @api private
|
|
283
|
+
def extract_media_type(content_type)
|
|
284
|
+
return nil if content_type.nil? || content_type.empty?
|
|
285
|
+
|
|
286
|
+
# Strip charset, boundary, and other parameters (separated by semicolon)
|
|
287
|
+
content_type.split(";", 2).first.strip.downcase
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Patched version of <tt>Rack::Utils.best_q_match</tt>.
|
|
291
|
+
#
|
|
292
|
+
# @api private
|
|
293
|
+
#
|
|
294
|
+
# @see http://www.rubydoc.info/gems/rack/Rack/Utils#best_q_match-class_method
|
|
295
|
+
# @see https://github.com/rack/rack/pull/659
|
|
296
|
+
# @see https://github.com/hanami/hanami-action/issues/59
|
|
297
|
+
# @see https://github.com/hanami/hanami-action/issues/104
|
|
298
|
+
# @see https://github.com/hanami/hanami-action/issues/275
|
|
299
|
+
def best_q_match(q_value_header, available_mimes)
|
|
300
|
+
::Rack::Utils.q_values(q_value_header).each_with_index.map { |(req_mime, quality), index|
|
|
301
|
+
match = available_mimes.find { |am| ::Rack::Mime.match?(am, req_mime) }
|
|
302
|
+
next unless match
|
|
303
|
+
|
|
304
|
+
RequestMimeWeight.new(req_mime, quality, index, match)
|
|
305
|
+
}.compact.max&.format
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Checks if a content type is acceptable for the configured formats.
|
|
309
|
+
#
|
|
310
|
+
# @param content_type [String] the media type to check
|
|
311
|
+
# @param config [Hanami::Action::Config] action configuration
|
|
312
|
+
# @return [Boolean] true if acceptable
|
|
313
|
+
#
|
|
314
|
+
# @api private
|
|
315
|
+
def accepted_content_type?(content_type, config)
|
|
316
|
+
accepted_content_types(config).any? { |accepted_content_type|
|
|
317
|
+
::Rack::Mime.match?(content_type, accepted_content_type)
|
|
318
|
+
}
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
private
|
|
322
|
+
|
|
323
|
+
# @api private
|
|
324
|
+
def accepted_type?(media_type, config)
|
|
325
|
+
accepted_types(config).any? { |accepted_type|
|
|
326
|
+
::Rack::Mime.match?(media_type, accepted_type)
|
|
327
|
+
}
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# @api private
|
|
331
|
+
def accepted_types(config)
|
|
332
|
+
return [ANY_TYPE] if config.formats.empty?
|
|
333
|
+
|
|
334
|
+
config.formats.map { |format| format_to_accept_types(format, config) }.flatten(1)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def format_to_accept_types(format, config)
|
|
338
|
+
configured_types = config.formats.accept_types_for(format)
|
|
339
|
+
return configured_types if configured_types.any?
|
|
340
|
+
|
|
341
|
+
FORMATS
|
|
342
|
+
.fetch(format) { raise Hanami::Action::UnknownFormatError.new(format) }
|
|
343
|
+
.accept_types
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# @api private
|
|
347
|
+
def accepted_content_types(config)
|
|
348
|
+
return [ANY_TYPE] if config.formats.empty?
|
|
349
|
+
|
|
350
|
+
config.formats.map { |format| format_to_content_types(format, config) }.flatten(1)
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# @api private
|
|
354
|
+
def format_to_content_types(format, config)
|
|
355
|
+
configured_types = config.formats.content_types_for(format)
|
|
356
|
+
return configured_types if configured_types.any?
|
|
357
|
+
|
|
358
|
+
FORMATS
|
|
359
|
+
.fetch(format) { raise Hanami::Action::UnknownFormatError.new(format) }
|
|
360
|
+
.content_types
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# @api private
|
|
364
|
+
def response_content_type(request, config)
|
|
365
|
+
# This method prepares the default `Content-Type` for the response. Importantly, it only
|
|
366
|
+
# does this after `#enforce_accept` and `#enforce_content_type` have already passed the
|
|
367
|
+
# request. So by the time we get here, the request has been deemed acceptable to the
|
|
368
|
+
# action, so we can try to be as helpful as possible in setting an appropriate content
|
|
369
|
+
# type for the response.
|
|
370
|
+
|
|
371
|
+
if request.accept_header?
|
|
372
|
+
content_type =
|
|
373
|
+
if config.formats.empty? || config.formats.accepted.include?(:all)
|
|
374
|
+
permissive_response_content_type(request, config)
|
|
375
|
+
else
|
|
376
|
+
restrictive_response_content_type(request, config)
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
return content_type if content_type
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
default_response_content_type(config)
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# Returns the response media type for a request without a usable `Accept` header: the
|
|
386
|
+
# configured default format's media type, or the global default content type.
|
|
387
|
+
#
|
|
388
|
+
# @api private
|
|
389
|
+
def default_response_content_type(config)
|
|
390
|
+
if config.formats.default
|
|
391
|
+
format_to_media_type(config.formats.default, config)
|
|
392
|
+
else
|
|
393
|
+
Action::DEFAULT_CONTENT_TYPE
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# @api private
|
|
398
|
+
def permissive_response_content_type(request, config)
|
|
399
|
+
# If no accepted formats are configured, or if the formats include :all, then we're
|
|
400
|
+
# working with a "permissive" action. In this case we simply want a response content type
|
|
401
|
+
# that corresponds to the request's accept header as closely as possible. This means we
|
|
402
|
+
# work from _all_ the media types we know of.
|
|
403
|
+
|
|
404
|
+
all_media_types =
|
|
405
|
+
(ACCEPT_TYPES_TO_FORMATS.keys | MEDIA_TYPES_TO_FORMATS.keys) +
|
|
406
|
+
config.formats.accept_types
|
|
407
|
+
|
|
408
|
+
best_q_match(request.accept, all_media_types)
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# @api private
|
|
412
|
+
def restrictive_response_content_type(request, config)
|
|
413
|
+
# When specific formats are configured, this is a "resitrctive" action. Here we want to
|
|
414
|
+
# match against the configured accept types only, and work back from those to the
|
|
415
|
+
# configured format, so we can use its canonical media type for the content type.
|
|
416
|
+
|
|
417
|
+
accept_types_to_formats = config.formats.accepted_formats(FORMATS)
|
|
418
|
+
.each_with_object({}) { |(_, format), hsh|
|
|
419
|
+
format.accept_types.each { hsh[_1] = format }
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
accept_type = best_q_match(request.accept, accept_types_to_formats.keys)
|
|
423
|
+
accept_types_to_formats[accept_type].media_type if accept_type
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
# @api private
|
|
427
|
+
def format_to_media_type(format, config)
|
|
428
|
+
configured_type = config.formats.media_type_for(format)
|
|
429
|
+
return configured_type if configured_type
|
|
430
|
+
|
|
431
|
+
FORMATS
|
|
432
|
+
.fetch(format) { raise Hanami::Action::UnknownFormatError.new(format) }
|
|
433
|
+
.media_type
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
end
|