pakyow-routing 1.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.
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "forwardable"
4
+
5
+ module Pakyow
6
+ module Routing
7
+ # Expands a route template.
8
+ #
9
+ # @api private
10
+ class Expansion
11
+ attr_reader :expander, :controller, :name
12
+
13
+ extend Forwardable
14
+ def_delegators :@expander, *%i(default template).concat(Controller::DEFINABLE_HTTP_METHODS)
15
+ def_delegators :@controller, :action
16
+
17
+ def initialize(template_name, controller, options, &template_block)
18
+ @controller = controller
19
+
20
+ # Create the controller that stores available routes, groups, and namespaces.
21
+ #
22
+ @expander = Controller.make(set_const: false)
23
+
24
+ # Evaluate the template to define available routes, groups, and namespaces.
25
+ #
26
+ instance_exec(**options, &template_block)
27
+
28
+ # Define helper methods for routes
29
+ #
30
+ local_expander = @expander
31
+ @expander.routes.each do |method, routes|
32
+ routes.each do |route|
33
+ unless @controller.singleton_class.instance_methods(false).include?(route.name)
34
+ @controller.define_singleton_method route.name do |*args, &block|
35
+ # Handle template parts named `new` by determining if we're calling `new` to expand
36
+ # part of a template, or if we're intending to create a new controller instance.
37
+ #
38
+ # If args are empty we can be sure that we're creating a route.
39
+ #
40
+ if args.any?
41
+ super(*args)
42
+ else
43
+ build_route(method, route.name, route.path || route.matcher, &block).tap do
44
+ # Make sure the route was inserted in the same order as found in the template.
45
+ #
46
+ index_of_last_insert = local_expander.routes[method].index { |expander_route|
47
+ expander_route.name == @routes[method].last.name
48
+ }
49
+
50
+ insert_before_this_index = @routes[method].select { |each_route|
51
+ local_expander.routes[method].any? { |expander_route|
52
+ each_route.name == expander_route.name
53
+ }
54
+ }.map { |each_route|
55
+ local_expander.routes[method].index { |expander_route|
56
+ expander_route.name == each_route.name
57
+ }
58
+ }.select { |index|
59
+ index > index_of_last_insert
60
+ }.first
61
+
62
+ if insert_before_this_index
63
+ @routes[method].insert(
64
+ @routes[method].index { |each_route|
65
+ each_route.name == local_expander.routes[method][insert_before_this_index].name
66
+ }, @routes[method].delete_at(index_of_last_insert)
67
+ )
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+
76
+ # Define helper methods for groups and namespaces
77
+ #
78
+ @expander.children.each do |child|
79
+ unless @controller.singleton_class.instance_methods(false).include?(child.__object_name.name)
80
+ @controller.define_singleton_method child.__object_name.name do |&block|
81
+ if child.path.nil?
82
+ group(child.__object_name.name, &block)
83
+ else
84
+ namespace(child.__object_name.name, child.path || child.matcher, &block)
85
+ end
86
+ end
87
+ end
88
+ end
89
+
90
+ # Set the expansion on the controller.
91
+ #
92
+ @controller.expansions << template_name
93
+ end
94
+
95
+ def group(*args, **kwargs, &block)
96
+ @expander.send(:group, *args, set_const: false, **kwargs, &block)
97
+ end
98
+
99
+ def namespace(*args, **kwargs, &block)
100
+ @expander.send(:namespace, *args, set_const: false, **kwargs, &block)
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pakyow/support/extension"
4
+
5
+ module Pakyow
6
+ module Routing
7
+ module Extension
8
+ # An extension for defining RESTful Resources. For example:
9
+ #
10
+ # resource :posts, "/posts" do
11
+ # list do
12
+ # # list the posts
13
+ # end
14
+ # end
15
+ #
16
+ # +Resource+ is available in all controllers by default.
17
+ #
18
+ # = Supported Actions
19
+ #
20
+ # These actions are supported:
21
+ #
22
+ # - +list+ -- +GET /+
23
+ # - +new+ -- +GET /new+
24
+ # - +create+ -- +POST /+
25
+ # - +edit+ -- +GET /:resource_id/edit+
26
+ # - +update+ -- +PATCH /:resource_id+
27
+ # - +replace+ -- +PUT /:resource_id+
28
+ # - +delete+ -- +DELETE /:resource_id+
29
+ # - +show+ -- +GET /:resource_id+
30
+ #
31
+ # = Nested Resources
32
+ #
33
+ # Resources can be nested. For example:
34
+ #
35
+ # resource :posts, "/posts" do
36
+ # resource :comments, "/comments" do
37
+ # list do
38
+ # # available at GET /posts/:post_id/comments
39
+ # end
40
+ # end
41
+ # end
42
+ #
43
+ # = Collection Routes
44
+ #
45
+ # Routes can be defined for the collection. For example:
46
+ #
47
+ # resource :posts, "/posts" do
48
+ # collection do
49
+ # get "/foo" do
50
+ # # available at GET /posts/foo
51
+ # end
52
+ # end
53
+ # end
54
+ #
55
+ # = Member Routes
56
+ #
57
+ # Routes can be defined as members. For example:
58
+ #
59
+ # resource :posts, "/posts" do
60
+ # member do
61
+ # get "/foo" do
62
+ # # available at GET /posts/:post_id/foo
63
+ # end
64
+ # end
65
+ # end
66
+ #
67
+ module Resource
68
+ extend Support::Extension
69
+ restrict_extension Controller
70
+
71
+ DEFAULT_PARAM = :id
72
+
73
+ apply_extension do
74
+ template :resource do |param: DEFAULT_PARAM|
75
+ resource_id = ":#{param}"
76
+ nested_param = "#{Support.inflector.singularize(controller.__object_name.name)}_#{param}".to_sym
77
+ nested_resource_id = ":#{nested_param}"
78
+
79
+ action :update_request_path_for_show, only: [:show] do
80
+ connection.get(:__endpoint_path).gsub!(resource_id, "show")
81
+ end
82
+
83
+ controller.class_eval do
84
+ allow_params param
85
+
86
+ unless singleton_class.instance_methods(false).include?(:param)
87
+ define_singleton_method :param do
88
+ param
89
+ end
90
+ end
91
+
92
+ unless singleton_class.instance_methods(false).include?(:nested_param)
93
+ define_singleton_method :nested_param do
94
+ nested_param
95
+ end
96
+ end
97
+
98
+ unless instance_methods(false).include?(:update_request_path_for_show)
99
+ define_method :update_request_path_for_show do
100
+ connection.get(:__endpoint_path).gsub!(resource_id, "show")
101
+ end
102
+ end
103
+
104
+ NestedResource.define(self, nested_resource_id, nested_param)
105
+ end
106
+
107
+ get :list, "/"
108
+ get :new, "/new"
109
+ post :create, "/"
110
+ get :edit, "/#{resource_id}/edit"
111
+ patch :update, "/#{resource_id}"
112
+ put :replace, "/#{resource_id}"
113
+ delete :delete, "/#{resource_id}"
114
+ get :show, "/#{resource_id}"
115
+
116
+ group :collection
117
+ namespace :member, nested_resource_id
118
+ end
119
+ end
120
+
121
+ module NestedResource
122
+ # Nest resources as members of the current resource.
123
+ #
124
+ def self.define(controller, nested_resource_id, nested_param)
125
+ unless controller.singleton_class.instance_methods(false).include?(:namespace)
126
+ controller.define_singleton_method :namespace do |*args, &block|
127
+ super(*args, &block).tap do |namespace|
128
+ namespace.allow_params nested_param
129
+ namespace.action :update_request_path_for_parent do
130
+ connection.get(:__endpoint_path).gsub!("/#{nested_resource_id}", "")
131
+ end
132
+ end
133
+ end
134
+ end
135
+
136
+ unless controller.singleton_class.instance_methods(false).include?(:resource)
137
+ controller.define_singleton_method :resource do |name, matcher, param: DEFAULT_PARAM, &block|
138
+ if existing_resource = children.find { |child| child.expansions.include?(:resource) && child.__object_name.name == name }
139
+ existing_resource.instance_exec(&block); existing_resource
140
+ else
141
+ expand(:resource, name, File.join(nested_resource_id, matcher), param: param) do
142
+ allow_params nested_param
143
+
144
+ action :update_request_path_for_parent do
145
+ connection.get(:__endpoint_path).gsub!("/#{nested_resource_id}", "")
146
+ end
147
+
148
+ class_eval(&block)
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pakyow/routing/extensions/resource"
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pakyow/framework"
4
+
5
+ require "pakyow/routing/controller"
6
+ require "pakyow/routing/extensions"
7
+ require "pakyow/routing/helpers/exposures"
8
+
9
+ require "pakyow/behavior/definition"
10
+
11
+ require "pakyow/security/behavior/config"
12
+ require "pakyow/security/behavior/disabling"
13
+ require "pakyow/security/behavior/helpers"
14
+ require "pakyow/security/behavior/insecure"
15
+ require "pakyow/security/behavior/pipeline"
16
+
17
+ module Pakyow
18
+ module Routing
19
+ class Framework < Pakyow::Framework(:routing)
20
+ def boot
21
+ object.class_eval do
22
+ include Pakyow::Behavior::Definition
23
+
24
+ isolate Controller do
25
+ include Extension::Resource
26
+ end
27
+
28
+ # Make controllers definable on the app.
29
+ #
30
+ stateful :controller, isolated(:Controller) do |args, _opts|
31
+ if self.ancestors.include?(Plugin)
32
+ # When using plugins, prefix controller paths with the mount path.
33
+ #
34
+ name, matcher = Controller.send(:parse_name_and_matcher_from_args, *args)
35
+ path = File.join(@mount_path, Controller.send(:path_from_matcher, matcher).to_s)
36
+ args.replace([name, path])
37
+ end
38
+ end
39
+
40
+ # Load controllers for the app.
41
+ #
42
+ aspect :controllers
43
+
44
+ # Load resources for the app.
45
+ #
46
+ aspect :resources
47
+
48
+ register_helper :active, Helpers::Exposures
49
+
50
+ # Include helpers into the controller class.
51
+ #
52
+ on "load" do
53
+ self.class.include_helpers :active, isolated(:Controller)
54
+ end
55
+
56
+ # Create the global controller instance.
57
+ #
58
+ after "initialize" do
59
+ @global_controller = isolated(:Controller).new(self)
60
+ end
61
+
62
+ # Expose the global controller for handling errors from other frameworks.
63
+ #
64
+ def controller_for_connection(connection)
65
+ @global_controller.dup.tap do |controller|
66
+ controller.instance_variable_set(:@connection, connection)
67
+ end
68
+ end
69
+
70
+ require "pakyow/support/message_verifier"
71
+ handle Support::MessageVerifier::TamperedMessage, as: :forbidden
72
+
73
+ include Security::Behavior::Config
74
+ include Security::Behavior::Disabling
75
+ include Security::Behavior::Helpers
76
+ include Security::Behavior::Insecure
77
+ include Security::Behavior::Pipeline
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pakyow
4
+ module Routing
5
+ module Helpers
6
+ module Exposures
7
+ # Expose a value by name.
8
+ #
9
+ def expose(name, default_value = default_omitted = true, &block)
10
+ value = if block_given?
11
+ yield
12
+ elsif default_omitted
13
+ __send__(name)
14
+ end
15
+
16
+ unless default_omitted
17
+ value ||= default_value
18
+ end
19
+
20
+ @connection.set(name, value)
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pakyow/support/aargv"
4
+ require "pakyow/support/core_refinements/string/normalization"
5
+
6
+ module Pakyow
7
+ module Routing
8
+ # A {Controller} endpoint.
9
+ #
10
+ class Route
11
+ using Support::Refinements::String::Normalization
12
+
13
+ attr_reader :path, :method, :name, :block
14
+
15
+ # @api private
16
+ attr_accessor :pipeline
17
+
18
+ def initialize(path_or_matcher, name:, method:, &block)
19
+ @name, @method, @block = name, method, block
20
+
21
+ if path_or_matcher.is_a?(String)
22
+ @path = path_or_matcher.to_s
23
+ @matcher = create_matcher_from_path(@path)
24
+ else
25
+ @path = ""
26
+ @matcher = path_or_matcher
27
+ end
28
+ end
29
+
30
+ def match(path_to_match)
31
+ @matcher.match(path_to_match)
32
+ end
33
+
34
+ def call(context)
35
+ context.instance_exec(&@block) if @block
36
+ end
37
+
38
+ def build_path(path_to_self, **params)
39
+ working_path = String.normalize_path(File.join(path_to_self.to_s, @path))
40
+
41
+ params.each do |key, value|
42
+ working_path.sub!(":#{key}", value.to_s)
43
+ end
44
+
45
+ working_path.sub!("/#", "#")
46
+ working_path
47
+ end
48
+
49
+ private
50
+
51
+ def create_matcher_from_path(path)
52
+ converted_path = String.normalize_path(path.split("/").map { |segment|
53
+ if segment.include?(":")
54
+ "(?<#{segment[(segment.index(":") + 1)..-1]}>(\\w|[-~:@!$\\'\\(\\)\\*\\+,;])+)"
55
+ else
56
+ segment
57
+ end
58
+ }.join("/"))
59
+
60
+ Regexp.new("^#{converted_path}$")
61
+ end
62
+
63
+ class EndpointBuilder
64
+ attr_reader :params
65
+
66
+ def initialize(route:, path:)
67
+ @route, @path = route, path
68
+ @params = String.normalize_path(File.join(@path.to_s, @route.path)).split("/").select { |segment|
69
+ segment.start_with?(":")
70
+ }.map { |segment|
71
+ segment[1..-1].to_sym
72
+ }
73
+ end
74
+
75
+ def call(**params)
76
+ @route.build_path(@path, params)
77
+ end
78
+
79
+ def source_location
80
+ @route.block&.source_location || []
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pakyow"
4
+ require "pakyow/support"
5
+
6
+ require "pakyow/validations"
7
+
8
+ require "pakyow/routing/framework"
9
+
10
+ require "pakyow/routing/actions/respond_missing"
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pakyow/support/hookable"
4
+
5
+ require "pakyow/security/errors"
6
+
7
+ module Pakyow
8
+ module Security
9
+ class Base
10
+ include Support::Hookable
11
+ events :reject
12
+
13
+ SAFE_HTTP_METHODS = %i(get head options trace).freeze
14
+
15
+ def initialize(config)
16
+ @config = config
17
+ end
18
+
19
+ def call(connection)
20
+ unless safe?(connection) || allowed?(connection)
21
+ reject(connection)
22
+ end
23
+
24
+ connection
25
+ end
26
+
27
+ def reject(connection)
28
+ performing :reject do
29
+ connection.logger.warn "Request rejected by #{self.class}; connection: #{connection.inspect}"
30
+
31
+ connection.status = 403
32
+ connection.body = StringIO.new("Forbidden")
33
+
34
+ raise InsecureRequest
35
+ end
36
+ end
37
+
38
+ def safe?(connection)
39
+ SAFE_HTTP_METHODS.include? connection.method
40
+ end
41
+
42
+ def allowed?(_)
43
+ false
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pakyow/support/extension"
4
+
5
+ module Pakyow
6
+ module Security
7
+ module Behavior
8
+ module Config
9
+ extend Support::Extension
10
+
11
+ apply_extension do
12
+ configurable :security do
13
+ configurable :csrf do
14
+ setting :protection, {}
15
+ setting :origin_whitelist, []
16
+ setting :param, :authenticity_token
17
+ end
18
+ end
19
+
20
+ require "pakyow/security/csrf/verify_same_origin"
21
+ require "pakyow/security/csrf/verify_authenticity_token"
22
+
23
+ config.security.csrf.protection = {
24
+ origin: CSRF::VerifySameOrigin.new(
25
+ origin_whitelist: config.security.csrf.origin_whitelist
26
+ ),
27
+
28
+ authenticity: CSRF::VerifyAuthenticityToken.new({}),
29
+ }
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pakyow/support/extension"
4
+
5
+ module Pakyow
6
+ module Security
7
+ module Behavior
8
+ module Disabling
9
+ extend Support::Extension
10
+
11
+ apply_extension do
12
+ isolated :Controller do
13
+ def self.disable_protection(type, only: [], except: [])
14
+ if type.to_sym == :csrf
15
+ if only.any? || except.any?
16
+ Pipelines::CSRF.__pipeline.actions.each do |action|
17
+ if only.any?
18
+ skip action.target, only: only
19
+ end
20
+
21
+ if except.any?
22
+ action action.target, only: except
23
+ end
24
+ end
25
+ else
26
+ exclude_pipeline Pipelines::CSRF
27
+ end
28
+ else
29
+ raise ArgumentError, "Unknown protection type `#{type}'"
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pakyow/support/extension"
4
+
5
+ require "pakyow/security/helpers/csrf"
6
+
7
+ module Pakyow
8
+ module Security
9
+ module Behavior
10
+ module Helpers
11
+ extend Support::Extension
12
+
13
+ apply_extension do
14
+ register_helper :passive, Security::Helpers::CSRF
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pakyow/support/extension"
4
+
5
+ require "pakyow/security/errors"
6
+
7
+ module Pakyow
8
+ module Security
9
+ module Behavior
10
+ module Insecure
11
+ extend Support::Extension
12
+
13
+ apply_extension do
14
+ handle InsecureRequest, as: 403 do
15
+ trigger(403)
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pakyow/support/extension"
4
+
5
+ module Pakyow
6
+ module Security
7
+ module Behavior
8
+ module Pipeline
9
+ extend Support::Extension
10
+
11
+ apply_extension do
12
+ require "pakyow/security/pipelines/csrf"
13
+
14
+ isolated :Controller do
15
+ include_pipeline Pipelines::CSRF
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pakyow/support/message_verifier"
4
+
5
+ require "pakyow/security/base"
6
+
7
+ module Pakyow
8
+ module Security
9
+ module CSRF
10
+ # Protects against Cross-Site Forgery Requests (CSRF).
11
+ # https://www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)_Prevention_Cheat_Sheet
12
+ #
13
+ # Requires a valid token be passed as a request parameter. The token consists
14
+ # of a client id (unique to the request) and a digest generated from the
15
+ # client id and the server id stored in the session.
16
+ #
17
+ # @see Pakyow::Support::MessageVerifier
18
+ #
19
+ class VerifyAuthenticityToken < Base
20
+ def allowed?(connection)
21
+ connection.verifier.verify(connection.params[connection.app.config.security.csrf.param])
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end