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,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This file is synced from hanakai-rb/repo-sync. To update it, edit repo-sync.yml.
4
+
5
+ lib = File.expand_path("lib", __dir__)
6
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
7
+ require "hanami/action/version"
8
+
9
+ Gem::Specification.new do |spec|
10
+ spec.name = "hanami-action"
11
+ spec.authors = ["Hanakai team"]
12
+ spec.email = ["info@hanakai.org"]
13
+ spec.license = "MIT"
14
+ spec.version = Hanami::Action::VERSION.dup
15
+
16
+ spec.summary = "Complete, fast and testable actions for Rack and Hanami"
17
+ spec.description = spec.summary
18
+ spec.homepage = "https://hanamirb.org"
19
+ spec.files = Dir["CHANGELOG.md", "LICENSE", "README.md", "hanami-action.gemspec", "lib/**/*"]
20
+ spec.bindir = "exe"
21
+ spec.executables = Dir["exe/*"].map { |f| File.basename(f) }
22
+ spec.require_paths = ["lib"]
23
+
24
+ spec.extra_rdoc_files = ["README.md", "CHANGELOG.md", "LICENSE"]
25
+
26
+ spec.metadata["changelog_uri"] = "https://github.com/hanami/hanami-action/blob/main/CHANGELOG.md"
27
+ spec.metadata["source_code_uri"] = "https://github.com/hanami/hanami-action"
28
+ spec.metadata["bug_tracker_uri"] = "https://github.com/hanami/hanami-action/issues"
29
+ spec.metadata["funding_uri"] = "https://github.com/sponsors/hanami"
30
+
31
+ spec.required_ruby_version = ">= 3.3"
32
+
33
+ spec.add_runtime_dependency "rack", ">= 2.2.16"
34
+ spec.add_runtime_dependency "hanami-utils", "~> 3.0.0.rc"
35
+ spec.add_runtime_dependency "dry-configurable", "~> 1.4"
36
+ spec.add_runtime_dependency "dry-core", "~> 1.0"
37
+ spec.add_runtime_dependency "zeitwerk", "~> 2.6"
38
+ end
39
+
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Hanami
6
+ class Action
7
+ module BodyParser
8
+ # Body parser for JSON request bodies.
9
+ #
10
+ # @api private
11
+ module JSON
12
+ def self.call(body, _env)
13
+ ::JSON.parse(body)
14
+ rescue ::JSON::ParserError => exception
15
+ raise BodyParsingError, exception.message
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack/multipart"
4
+
5
+ module Hanami
6
+ class Action
7
+ module BodyParser
8
+ # Body parser for multipart form data (file uploads).
9
+ #
10
+ # @api private
11
+ module MultipartForm
12
+ def self.call(_body, env)
13
+ # Rack's `parse_multipart` reads the input from the env. We've already rewound this input
14
+ # in BodyParser, before this parser is called.
15
+ ::Rack::Multipart.parse_multipart(env)
16
+ rescue StandardError => exception
17
+ raise BodyParsingError, exception.message
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack"
4
+ require "hanami/utils/hash"
5
+
6
+ module Hanami
7
+ class Action
8
+ # Parses request bodies based on the action's accepted formats.
9
+ #
10
+ # @api private
11
+ module BodyParser
12
+ FALLBACK_KEY = :_
13
+
14
+ class << self
15
+ # rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity
16
+
17
+ # Parses the request body if applicable
18
+ #
19
+ # @param env [Hash] Rack environment
20
+ # @param config [Hanami::Action::Config] action configuration
21
+ #
22
+ # @return [void]
23
+ def parse(env, config)
24
+ # If the router has already parsed the body, derive our own keys from it.
25
+ if env.key?(ROUTER_PARSED_BODY)
26
+ parsed = env[ROUTER_PARSED_BODY]
27
+ env[ACTION_PARSED_BODY] = parsed
28
+ env[ACTION_BODY_PARAMS] = symbolize_body(parsed)
29
+ return
30
+ end
31
+
32
+ return if env.key?(ACTION_PARSED_BODY)
33
+
34
+ input = env[::Rack::RACK_INPUT]
35
+ return unless input
36
+
37
+ media_type = Mime.extract_media_type(env[CONTENT_TYPE])
38
+ return unless media_type
39
+
40
+ if config.formats.empty?
41
+ # When no format is explicity configured, parse multipart/form-data bodies as a sensible
42
+ # default. These kinds of form submissions are a standard part of standard web behavior,
43
+ # and users expect them to work out of the box.
44
+ return unless media_type == "multipart/form-data"
45
+ else
46
+ return unless Mime.accepted_content_type?(media_type, config)
47
+ end
48
+
49
+ parser = config.formats.body_parser_for(media_type)
50
+ return unless parser
51
+
52
+ input = ensure_rewindable_input(env)
53
+ body = read_body(input)
54
+ return if body.nil? || body.empty?
55
+
56
+ # Pass both the body string and the Rack env to the parser. Most parsers should only need
57
+ # the body, but the env is there in case access to headers or calling Rack APIs is
58
+ # required.
59
+ parsed = parser.call(body, env)
60
+
61
+ env[ACTION_PARSED_BODY] = parsed
62
+ env[ACTION_BODY_PARAMS] = symbolize_body(parsed)
63
+ end
64
+
65
+ # rubocop:enable Metrics/AbcSize, Metrics/PerceivedComplexity
66
+
67
+ private
68
+
69
+ # Ensures the input in the Rack env is rewindable (for Rack 3 compatibility).
70
+ def ensure_rewindable_input(env)
71
+ input = env[::Rack::RACK_INPUT]
72
+ return input if input.respond_to?(:rewind)
73
+
74
+ env[::Rack::RACK_INPUT] = ::Rack::RewindableInput.new(input)
75
+ end
76
+
77
+ # Reads and rewinds the body.
78
+ def read_body(input)
79
+ input.rewind
80
+ body = input.read
81
+ input.rewind
82
+
83
+ body
84
+ end
85
+
86
+ # Symbolizes the parsed body, wrapping non-hash values in a fallback key.
87
+ def symbolize_body(parsed)
88
+ if parsed.is_a?(::Hash)
89
+ deep_symbolize(parsed)
90
+ else
91
+ {FALLBACK_KEY => deep_symbolize(parsed)}
92
+ end
93
+ end
94
+
95
+ # Recursively symbolizes hash keys within any structure (arrays or hashes).
96
+ def deep_symbolize(value)
97
+ case value
98
+ when ::Hash
99
+ Utils::Hash.deep_symbolize(value)
100
+ when ::Array
101
+ value.map { deep_symbolize(_1) }
102
+ else
103
+ value
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hanami
4
+ class Action
5
+ module Cache
6
+ # Module with Cache-Control logic
7
+ #
8
+ # @since 0.3.0
9
+ # @api private
10
+ module CacheControl
11
+ # @since 0.3.0
12
+ # @api private
13
+ def self.included(base)
14
+ base.class_eval do
15
+ extend ClassMethods
16
+
17
+ @cache_control_directives = nil
18
+ end
19
+ end
20
+
21
+ # @since 0.3.0
22
+ # @api private
23
+ module ClassMethods
24
+ # @since 0.3.0
25
+ # @api private
26
+ def cache_control(*values)
27
+ @cache_control_directives ||= Directives.new(*values)
28
+ end
29
+
30
+ # @since 0.3.0
31
+ # @api private
32
+ def cache_control_directives
33
+ @cache_control_directives || Object.new.tap do |null_object|
34
+ def null_object.headers
35
+ {}
36
+ end
37
+ end
38
+ end
39
+ end
40
+
41
+ # Finalize the response including default cache headers into the response
42
+ #
43
+ # @since 0.3.0
44
+ # @api private
45
+ #
46
+ # @see Hanami::Action#finish
47
+ def finish(_, res, _)
48
+ unless res.headers.include?(Action::CACHE_CONTROL)
49
+ res.headers.merge!(self.class.cache_control_directives.headers)
50
+ end
51
+
52
+ super
53
+ end
54
+
55
+ # Class which stores CacheControl values
56
+ #
57
+ # @since 0.3.0
58
+ # @api private
59
+ class Directives
60
+ # @since 2.0.0
61
+ # @api private
62
+ SEPARATOR = ", "
63
+ private_constant :SEPARATOR
64
+
65
+ # @since 0.3.0
66
+ # @api private
67
+ def initialize(*values)
68
+ @directives = Hanami::Action::Cache::Directives.new(*values)
69
+ end
70
+
71
+ # @since 0.3.0
72
+ # @api private
73
+ def headers
74
+ if @directives.any?
75
+ {Action::CACHE_CONTROL => @directives.join(SEPARATOR)}
76
+ else
77
+ {}
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "hanami/utils/blank"
4
+
5
+ module Hanami
6
+ class Action
7
+ module Cache
8
+ # ETag value object
9
+ #
10
+ # @since 0.3.0
11
+ # @api private
12
+ class ETag
13
+ # @since 0.3.0
14
+ # @api private
15
+ def initialize(env, value)
16
+ @env, @value = env, value
17
+ end
18
+
19
+ # @since 0.3.0
20
+ # @api private
21
+ def fresh?
22
+ none_match && @value == none_match
23
+ end
24
+
25
+ # @since 0.3.0
26
+ # @api private
27
+ def header
28
+ {Action::ETAG => @value} if @value
29
+ end
30
+
31
+ private
32
+
33
+ # @since 0.3.0
34
+ # @api private
35
+ def none_match
36
+ @env[Action::IF_NONE_MATCH]
37
+ end
38
+ end
39
+
40
+ # LastModified value object
41
+ #
42
+ # @since 0.3.0
43
+ # @api private
44
+ class LastModified
45
+ # @since 0.3.0
46
+ # @api private
47
+ def initialize(env, value)
48
+ @env, @value = env, value
49
+ end
50
+
51
+ # @since 0.3.0
52
+ # @api private
53
+ def fresh?
54
+ return false if Hanami::Utils::Blank.blank?(modified_since)
55
+ return false if Hanami::Utils::Blank.blank?(@value)
56
+
57
+ Time.httpdate(modified_since).to_i >= @value.to_time.to_i
58
+ end
59
+
60
+ # @since 0.3.0
61
+ # @api private
62
+ def header
63
+ {Action::LAST_MODIFIED => @value.httpdate} if @value.respond_to?(:httpdate)
64
+ end
65
+
66
+ private
67
+
68
+ # @since 0.3.0
69
+ # @api private
70
+ def modified_since
71
+ @env[Action::IF_MODIFIED_SINCE]
72
+ end
73
+ end
74
+
75
+ # Class responsible to determine if a given request is fresh
76
+ # based on IF_NONE_MATCH and IF_MODIFIED_SINCE headers
77
+ #
78
+ # @since 0.3.0
79
+ # @api private
80
+ class ConditionalGet
81
+ # @since 0.3.0
82
+ # @api private
83
+ def initialize(env, options)
84
+ @validations = [ETag.new(env, options[:etag]), LastModified.new(env, options[:last_modified])]
85
+ end
86
+
87
+ # @since 0.3.0
88
+ # @api private
89
+ def fresh?
90
+ yield if @validations.any?(&:fresh?)
91
+ end
92
+
93
+ # @since 0.3.0
94
+ # @api private
95
+ def headers
96
+ @validations.map(&:header).compact.reduce({}, :merge)
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hanami
4
+ class Action
5
+ module Cache
6
+ # Cache-Control directives which have values
7
+ #
8
+ # @since 0.3.0
9
+ # @api private
10
+ VALUE_DIRECTIVES = %i[max_age s_maxage min_fresh max_stale].freeze
11
+
12
+ # Cache-Control directives which are implicitly true
13
+ #
14
+ # @since 0.3.0
15
+ # @api private
16
+ NON_VALUE_DIRECTIVES = %i[public private no_cache no_store no_transform must_revalidate proxy_revalidate].freeze
17
+
18
+ # Class representing value directives
19
+ #
20
+ # ex: max-age=600
21
+ #
22
+ # @since 0.3.0
23
+ # @api private
24
+ class ValueDirective
25
+ # @since 0.3.0
26
+ # @api private
27
+ attr_reader :name
28
+
29
+ # @since 0.3.0
30
+ # @api private
31
+ def initialize(name, value)
32
+ @name, @value = name, value
33
+ end
34
+
35
+ # @since 0.3.0
36
+ # @api private
37
+ def to_str
38
+ "#{@name.to_s.tr('_', '-')}=#{@value.to_i}"
39
+ end
40
+
41
+ # @since 0.3.0
42
+ # @api private
43
+ def valid?
44
+ VALUE_DIRECTIVES.include? @name
45
+ end
46
+ end
47
+
48
+ # Class representing non value directives
49
+ #
50
+ # ex: no-cache
51
+ #
52
+ # @since 0.3.0
53
+ # @api private
54
+ class NonValueDirective
55
+ # @since 0.3.0
56
+ # @api private
57
+ attr_reader :name
58
+
59
+ # @since 0.3.0
60
+ # @api private
61
+ def initialize(name)
62
+ @name = name
63
+ end
64
+
65
+ # @since 0.3.0
66
+ # @api private
67
+ def to_str
68
+ @name.to_s.tr("_", "-")
69
+ end
70
+
71
+ # @since 0.3.0
72
+ # @api private
73
+ def valid?
74
+ NON_VALUE_DIRECTIVES.include? @name
75
+ end
76
+ end
77
+
78
+ # Collection of value and non value directives
79
+ #
80
+ # @since 0.3.0
81
+ # @api private
82
+ class Directives
83
+ include Enumerable
84
+
85
+ # @since 0.3.0
86
+ # @api private
87
+ def initialize(*values)
88
+ @directives = []
89
+ values.each do |directive_key|
90
+ if directive_key.is_a? Hash
91
+ directive_key.each { |name, value| self << ValueDirective.new(name, value) }
92
+ else
93
+ self << NonValueDirective.new(directive_key)
94
+ end
95
+ end
96
+ end
97
+
98
+ # @since 0.3.0
99
+ # @api private
100
+ def each(&block)
101
+ @directives.each(&block)
102
+ end
103
+
104
+ # @since 0.3.0
105
+ # @api private
106
+ def <<(directive)
107
+ @directives << directive if directive.valid?
108
+ end
109
+
110
+ # @since 0.3.0
111
+ # @api private
112
+ def values
113
+ @directives.delete_if do |directive|
114
+ directive.name == :public && @directives.map(&:name).include?(:private)
115
+ end
116
+ end
117
+
118
+ # @since 0.3.0
119
+ # @api private
120
+ def join(separator)
121
+ values.join(separator)
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hanami
4
+ class Action
5
+ module Cache
6
+ # Module with Expires logic
7
+ #
8
+ # @since 0.3.0
9
+ # @api private
10
+ module Expires
11
+ # @since 0.3.0
12
+ # @api private
13
+ def self.included(base)
14
+ base.class_eval do
15
+ extend ClassMethods
16
+
17
+ @expires_directives = nil
18
+ end
19
+ end
20
+
21
+ # @since 0.3.0
22
+ # @api private
23
+ module ClassMethods
24
+ # @since 0.3.0
25
+ # @api private
26
+ def expires(amount, *values)
27
+ @expires_directives ||= Directives.new(amount, *values)
28
+ end
29
+
30
+ # @since 0.3.0
31
+ # @api private
32
+ def expires_directives
33
+ @expires_directives || Object.new.tap do |null_object|
34
+ def null_object.headers
35
+ {}
36
+ end
37
+ end
38
+ end
39
+ end
40
+
41
+ # Finalize the response including default cache headers into the response
42
+ #
43
+ # @since 0.3.0
44
+ # @api private
45
+ #
46
+ # @see Hanami::Action#finish
47
+ def finish(_, res, _)
48
+ unless res.headers.include?(Action::EXPIRES)
49
+ res.headers.merge!(self.class.expires_directives.headers)
50
+ end
51
+
52
+ super
53
+ end
54
+
55
+ # Class which stores Expires directives
56
+ #
57
+ # @since 0.3.0
58
+ # @api private
59
+ class Directives
60
+ # @since 0.3.0
61
+ # @api private
62
+ def initialize(amount, *values)
63
+ @amount = amount
64
+ @cache_control = Hanami::Action::Cache::CacheControl::Directives.new(*(values << {max_age: amount}))
65
+ end
66
+
67
+ # @since 0.3.0
68
+ # @api private
69
+ def headers
70
+ {Action::EXPIRES => time.httpdate}.merge(@cache_control.headers)
71
+ end
72
+
73
+ private
74
+
75
+ # @since 0.3.0
76
+ # @api private
77
+ def time
78
+ Time.now + @amount.to_i
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hanami
4
+ class Action
5
+ # Cache type API
6
+ #
7
+ # @since 0.3.0
8
+ #
9
+ # @see Hanami::Action::Cache::ClassMethods#cache_control
10
+ # @see Hanami::Action::Cache::ClassMethods#expires
11
+ # @see Hanami::Action::Cache::ClassMethods#fresh
12
+ module Cache
13
+ # Override Ruby's hook for modules.
14
+ # It includes exposures logic
15
+ #
16
+ # @param base [Class] the target action
17
+ #
18
+ # @since 0.3.0
19
+ # @api private
20
+ #
21
+ # @see http://www.ruby-doc.org/core/Module.html#method-i-included
22
+ def self.included(base)
23
+ base.class_eval do
24
+ include CacheControl, Expires
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end