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,872 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "forwardable"
4
+ require "uri"
5
+
6
+ require "pakyow/support/aargv"
7
+ require "pakyow/support/hookable"
8
+ require "pakyow/support/makeable"
9
+ require "pakyow/support/pipeline"
10
+ require "pakyow/support/core_refinements/string/normalization"
11
+
12
+ require "pakyow/connection/statuses"
13
+
14
+ require "pakyow/security/errors"
15
+
16
+ require "pakyow/routing/route"
17
+
18
+ require "pakyow/routing/controller/behavior/error_handling"
19
+ require "pakyow/routing/controller/behavior/param_verification"
20
+
21
+ module Pakyow
22
+ # Executes code for particular requests. For example:
23
+ #
24
+ # Pakyow::App.controller do
25
+ # get "/" do
26
+ # # called for GET / requests
27
+ # end
28
+ # end
29
+ #
30
+ # A +Class+ is created dynamically for each defined controller. When matched, a route is called in
31
+ # context of its controller. This means that any method defined in a controller is available to be
32
+ # called from within a route. For example:
33
+ #
34
+ # Pakyow::App.controller do
35
+ # def foo
36
+ # end
37
+ #
38
+ # get :foo, "/foo" do
39
+ # foo
40
+ # end
41
+ # end
42
+ #
43
+ # Including modules works as expected:
44
+ #
45
+ # module AuthHelpers
46
+ # def current_user
47
+ # end
48
+ # end
49
+ #
50
+ # Pakyow::App.controller do
51
+ # include AuthHelpers
52
+ #
53
+ # get :foo, "/foo" do
54
+ # current_user
55
+ # end
56
+ # end
57
+ #
58
+ # See {App.controller} for more details on defining controllers.
59
+ #
60
+ # = Supported HTTP methods
61
+ #
62
+ # - +GET+
63
+ # - +POST+
64
+ # - +PUT+
65
+ # - +PATCH+
66
+ # - +DELETE+
67
+ #
68
+ # See {get}, {post}, {put}, {patch}, and {delete}.
69
+ #
70
+ # +HEAD+ requests are handled automatically via {Rack::Head}.
71
+ #
72
+ # = Building paths for named routes
73
+ #
74
+ # Path building is supported via {Controller#path} and {Controller#path_to}.
75
+ #
76
+ # = Reusing logic with actions
77
+ #
78
+ # Methods can be defined as additional actions for a route. For example:
79
+ #
80
+ # Pakyow::App.controller do
81
+ # action :called_before
82
+ #
83
+ # def called_before
84
+ # ...
85
+ # end
86
+ #
87
+ # get :foo, "/foo" do
88
+ # ...
89
+ # end
90
+ # end
91
+ #
92
+ # = Extending controllers
93
+ #
94
+ # Extensions can be defined and used to add shared routes to one or more controllers.
95
+ # See {Routing::Extension}.
96
+ #
97
+ # = Other routing features
98
+ #
99
+ # More advanced route features are available, including groups, namespaces, and templates. See
100
+ # {group}, {namespace}, and {template}.
101
+ #
102
+ # = Controller subclasses
103
+ #
104
+ # It's possible to work with controllers outside of Pakyow's DSL. For example:
105
+ #
106
+ # class FooController < Pakyow::Controller("/foo")
107
+ # default do
108
+ # # available at GET /foo
109
+ # end
110
+ # end
111
+ #
112
+ # Pakyow::App.controller << FooController
113
+ #
114
+ # = Custom matchers
115
+ #
116
+ # Controllers and routes can be defined with a matcher rather than a path. The matcher could be a
117
+ # +Regexp+ or any custom object that implements +match?+. For example:
118
+ #
119
+ # class CustomMatcher
120
+ # def match?(path)
121
+ # path == "/custom"
122
+ # end
123
+ # end
124
+ #
125
+ # Pakyow::App.controller CustomMatcher.new do
126
+ # end
127
+ #
128
+ # Custom matchers can also make data available in +params+ by implementing +match+ and returning
129
+ # an object that implements +named_captures+. For example:
130
+ #
131
+ # class CustomMatcher
132
+ # def match?(path)
133
+ # path == "/custom"
134
+ # end
135
+ #
136
+ # def match(path)
137
+ # return self if match?(path)
138
+ # end
139
+ #
140
+ # def named_captures
141
+ # { foo: "bar" }
142
+ # end
143
+ # end
144
+ #
145
+ # Pakyow::App.controller CustomMatcher.new do
146
+ # end
147
+ #
148
+ class Controller
149
+ using Support::DeepDup
150
+ extend Support::Makeable
151
+ extend Support::ClassState
152
+
153
+ include Support::Hookable
154
+ events :dispatch
155
+
156
+ include Routing::Behavior::ErrorHandling
157
+ include Routing::Behavior::ParamVerification
158
+
159
+ include Support::Pipeline
160
+
161
+ using Support::Refinements::String::Normalization
162
+
163
+ controller = self
164
+ Pakyow.singleton_class.class_eval do
165
+ define_method :Controller do |path|
166
+ controller.Controller(path)
167
+ end
168
+ end
169
+
170
+ METHOD_GET = :get
171
+ METHOD_HEAD = :head
172
+ METHOD_POST = :post
173
+ METHOD_PUT = :put
174
+ METHOD_PATCH = :patch
175
+ METHOD_DELETE = :delete
176
+
177
+ DEFINABLE_HTTP_METHODS = [
178
+ METHOD_GET,
179
+ METHOD_POST,
180
+ METHOD_PUT,
181
+ METHOD_PATCH,
182
+ METHOD_DELETE
183
+ ].freeze
184
+
185
+ CONTENT_DISPOSITION = "Content-Disposition".freeze
186
+
187
+ require "pakyow/routing/expansion"
188
+
189
+ # Controllers must be initialized with an argument, even though the argument
190
+ # isn't actually used. This is a side-effect of allowing templates to define
191
+ # a route named "new". In the expansion, we differentiate between expanding
192
+ # and initializing by whether an argument is present, which works because
193
+ # arguments aren't passed for expansions.
194
+ #
195
+ def initialize(app)
196
+ @children = self.class.children.map { |child|
197
+ child.new(app)
198
+ }
199
+
200
+ self.class.routes.values.flatten.each do |route|
201
+ route.pipeline = self.class.__pipeline.dup
202
+
203
+ self.class.limit_by_route[route.name].to_a.reverse.each do |limit|
204
+ if index = route.pipeline.actions.index(limit[:after])
205
+ route.pipeline.actions.insert(index + 1, limit[:insert])
206
+ else
207
+ route.pipeline.actions << limit[:insert]
208
+ end
209
+ end
210
+
211
+ route.pipeline.actions.delete_if do |action|
212
+ self.class.global_skips.to_a.include?(action.name) ||
213
+ self.class.skips_by_route[route.name].to_a.include?(action.name)
214
+ end
215
+
216
+ route.pipeline.actions << Support::Pipeline::Action.new(:dispatch)
217
+ end
218
+ end
219
+
220
+ def call(connection, request_path = connection.path)
221
+ request_method = connection.method
222
+ if request_method == METHOD_HEAD
223
+ request_method = METHOD_GET
224
+ end
225
+
226
+ matcher = self.class.matcher
227
+ if match = matcher.match(request_path)
228
+ match_data = match.named_captures
229
+ connection.params.merge!(match_data)
230
+
231
+ if matcher.is_a?(Regexp)
232
+ request_path = String.normalize_path(request_path.sub(matcher, ""))
233
+ end
234
+
235
+ @children.each do |child_controller|
236
+ child_controller.call(connection, request_path)
237
+ break if connection.halted?
238
+ end
239
+
240
+ unless connection.halted?
241
+ self.class.routes[request_method].to_a.each do |route|
242
+ catch :reject do
243
+ if route_match = route.match(request_path)
244
+ connection.params.merge!(route_match.named_captures)
245
+
246
+ connection.set(
247
+ :__endpoint_path,
248
+ String.normalize_path(
249
+ File.join(
250
+ self.class.path_to_self.to_s, route.path.to_s
251
+ )
252
+ )
253
+ )
254
+
255
+ connection.set(:__endpoint_name, route.name)
256
+
257
+ dup.call_route(connection, route)
258
+ end
259
+ end
260
+
261
+ break if connection.halted?
262
+ end
263
+ end
264
+ end
265
+ end
266
+
267
+ def call_route(connection, route)
268
+ @connection, @route = connection, route
269
+ @route.pipeline.callable(self).call(connection); halt
270
+ rescue StandardError => error
271
+ @connection.logger.houston(error)
272
+ handle_error(error)
273
+ end
274
+
275
+ def dispatch
276
+ halted = false
277
+ performing :dispatch do
278
+ halted = catch :halt do
279
+ @route.call(self)
280
+ end
281
+ end
282
+
283
+ # Catching the halt then re-halting lets us call after dispatch hooks in non-error cases.
284
+ #
285
+ halt if halted
286
+ end
287
+
288
+ # Redirects to +location+ and immediately halts request processing.
289
+ #
290
+ # @param location [String] what url the request should be redirected to
291
+ # @param as [Integer, Symbol] the status to redirect with
292
+ # @param trusted [Boolean] whether or not the location is trusted
293
+ #
294
+ # @example Redirecting:
295
+ # Pakyow::App.controller do
296
+ # default do
297
+ # redirect "/foo"
298
+ # end
299
+ # end
300
+ #
301
+ # @example Redirecting with a status code:
302
+ # Pakyow::App.controller do
303
+ # default do
304
+ # redirect "/foo", as: 301
305
+ # end
306
+ # end
307
+ #
308
+ # @example Redirecting to a remote location:
309
+ # Pakyow::App.controller do
310
+ # default do
311
+ # redirect "http://foo.com/bar", trusted: true
312
+ # end
313
+ # end
314
+ #
315
+ def redirect(location, as: 302, trusted: false, **params)
316
+ location = case location
317
+ when Symbol
318
+ app.endpoints.path(location, **params)
319
+ else
320
+ location
321
+ end
322
+
323
+ if trusted || URI(location).host.nil?
324
+ @connection.status = Connection::Statuses.code(as)
325
+ @connection.set_header("location", location)
326
+ halt
327
+ else
328
+ raise Security::InsecureRedirect.new_with_message(
329
+ location: location
330
+ )
331
+ end
332
+ end
333
+
334
+ # Reroutes the request to a different location. Instead of an http redirect, the request will
335
+ # continued to be handled in the current request lifecycle.
336
+ #
337
+ # @param location [String] what url the request should be rerouted to
338
+ # @param method [Symbol] the http method to reroute as
339
+ #
340
+ # @example
341
+ # Pakyow::App.resource :posts, "/posts" do
342
+ # edit do
343
+ # @post ||= find_post_by_id(params[:post_id])
344
+ #
345
+ # # render the form for @post
346
+ # end
347
+ #
348
+ # update do
349
+ # if post_fails_to_create
350
+ # @post = failed_post_object
351
+ # reroute path(:posts_edit, post_id: @post.id), method: :get
352
+ # end
353
+ # end
354
+ # end
355
+ #
356
+ def reroute(location, method: connection.method, as: nil, **params)
357
+ connection = @connection.__getobj__
358
+
359
+ # Make sure the endpoint is set.
360
+ #
361
+ connection.endpoint
362
+
363
+ connection.instance_variable_set(:@method, method)
364
+ connection.instance_variable_set(:@path, location.is_a?(Symbol) ? app.endpoints.path(location, **params) : location)
365
+
366
+ # Change the response status, if set.
367
+ #
368
+ connection.status = Connection::Statuses.code(as) if as
369
+
370
+ @connection.set(:__endpoint_path, nil)
371
+ @connection.set(:__endpoint_name, nil)
372
+
373
+ app.perform(@connection); halt
374
+ end
375
+
376
+ # Responds to a specific request format.
377
+ #
378
+ # The +Content-Type+ header will be set on the response based on the format that is being
379
+ # responded to.
380
+ #
381
+ # After yielding, request processing will be halted.
382
+ #
383
+ # @example
384
+ # Pakyow::App.controller do
385
+ # get "/foo.txt|html" do
386
+ # respond_to :txt do
387
+ # send "foo"
388
+ # end
389
+ #
390
+ # # do something for html format
391
+ # end
392
+ # end
393
+ #
394
+ def respond_to(format)
395
+ return unless @connection.format == format.to_sym
396
+ @connection.format = format
397
+ yield
398
+ halt
399
+ end
400
+
401
+ DEFAULT_SEND_TYPE = "application/octet-stream".freeze
402
+
403
+ # Sends a file or other data in the response.
404
+ #
405
+ # Accepts data as a +String+ or +IO+ object. When passed a +File+ object, the mime type will be
406
+ # determined automatically. The type can be set explicitly with the +type+ option.
407
+ #
408
+ # Passing +name+ sets the +Content-Disposition+ header to "attachment". Otherwise, the
409
+ # disposition will be set to "inline".
410
+ #
411
+ # @example Sending data:
412
+ # Pakyow::App.controller do
413
+ # default do
414
+ # send "foo", type: "text/plain"
415
+ # end
416
+ # end
417
+ #
418
+ # @example Sending a file:
419
+ # Pakyow::App.controller do
420
+ # default do
421
+ # filename = "foo.txt"
422
+ # send File.open(filename), name: filename
423
+ # end
424
+ # end
425
+ #
426
+ def send(file_or_data, type: nil, name: nil)
427
+ if file_or_data.is_a?(IO) || file_or_data.is_a?(StringIO)
428
+ data = file_or_data
429
+
430
+ if file_or_data.is_a?(File)
431
+ @connection.set_header(Rack::CONTENT_LENGTH, file_or_data.size)
432
+ type ||= Rack::Mime.mime_type(File.extname(file_or_data.path))
433
+ end
434
+
435
+ @connection.set_header(Rack::CONTENT_TYPE, type || DEFAULT_SEND_TYPE)
436
+ elsif file_or_data.is_a?(String)
437
+ @connection.set_header(Rack::CONTENT_LENGTH, file_or_data.bytesize)
438
+ @connection.set_header(Rack::CONTENT_TYPE, type) if type
439
+ data = StringIO.new(file_or_data)
440
+ else
441
+ raise ArgumentError, "expected an IO or String object"
442
+ end
443
+
444
+ @connection.set_header(CONTENT_DISPOSITION, name ? "attachment; filename=#{name}" : "inline")
445
+ halt(data)
446
+ end
447
+
448
+ # Halts request processing, immediately returning the response.
449
+ #
450
+ # The response body will be set to +body+ prior to halting (if it's a non-nil value).
451
+ #
452
+ def halt(body = nil, status: nil)
453
+ @connection.body = body if body
454
+ @connection.status = Connection::Statuses.code(status) if status
455
+ @connection.halt
456
+ end
457
+
458
+ # Rejects the request, calling the next matching route.
459
+ #
460
+ def reject
461
+ throw :reject
462
+ end
463
+
464
+ class_state :children, default: [], inheritable: false
465
+ class_state :templates, default: {}, inheritable: true
466
+ class_state :expansions, default: [], inheritable: false
467
+ class_state :routes, default: DEFINABLE_HTTP_METHODS.each_with_object({}) { |supported_method, routes_hash|
468
+ routes_hash[supported_method] = []
469
+ }, inheritable: false
470
+
471
+ class_state :limit_by_route, default: {}, inheritable: false
472
+ class_state :skips_by_route, default: {}, inheritable: false
473
+
474
+ # Global rules should be inherited by children, but route-specific rules
475
+ # shouldn't be since they refer to a specific route in the current context.
476
+ #
477
+ class_state :global_skips, default: [], inheritable: true
478
+
479
+ class << self
480
+ def action(name, only: [], skip: [], &block)
481
+ @__pipeline.actions.delete_if do |action|
482
+ action.name == name
483
+ end
484
+
485
+ if only.any?
486
+ only.each do |route_name|
487
+ (@limit_by_route[route_name] ||= []) << {
488
+ insert: Support::Pipeline::Action.new(name, &block),
489
+ after: @__pipeline.actions.last
490
+ }
491
+ end
492
+ else
493
+ super(name, &block)
494
+ end
495
+
496
+ skip.each do |route_name|
497
+ (@skips_by_route[route_name] ||= []) << name
498
+ end
499
+ end
500
+
501
+ def skip(name, only: [])
502
+ if only.empty?
503
+ @global_skips << name
504
+ else
505
+ only.each do |route_name|
506
+ (@skips_by_route[route_name] ||= []) << name
507
+ end
508
+ end
509
+ end
510
+
511
+ def use_pipeline(*)
512
+ super
513
+
514
+ @limit_by_route = {}
515
+ end
516
+
517
+ # Conveniently define defaults when subclassing +Pakyow::Controller+.
518
+ #
519
+ # @example
520
+ # class MyController < Pakyow::Controller("/foo")
521
+ # # more routes here
522
+ # end
523
+ #
524
+ # rubocop:disable Naming/MethodName
525
+ def Controller(matcher)
526
+ make(matcher)
527
+ end
528
+ # rubocop:enabled Naming/MethodName
529
+
530
+ # Create a default route. Shorthand for +get "/"+.
531
+ #
532
+ # @see get
533
+ #
534
+ def default(&block)
535
+ get :default, "/", &block
536
+ end
537
+
538
+ # @!method get
539
+ # Create a route that matches +GET+ requests at +path+. For example:
540
+ #
541
+ # Pakyow::App.controller do
542
+ # get "/foo" do
543
+ # # do something
544
+ # end
545
+ # end
546
+ #
547
+ # Routes can be named, making them available for path building via {Controller#path}. For
548
+ # example:
549
+ #
550
+ # Pakyow::App.controller do
551
+ # get :foo, "/foo" do
552
+ # # do something
553
+ # end
554
+ # end
555
+ #
556
+ # @!method post
557
+ # Create a route that matches +POST+ requests at +path+.
558
+ #
559
+ # @see get
560
+ #
561
+ # @!method put
562
+ # Create a route that matches +PUT+ requests at +path+.
563
+ #
564
+ # @see get
565
+ #
566
+ # @!method patch
567
+ # Create a route that matches +PATCH+ requests at +path+.
568
+ #
569
+ # @see get
570
+ #
571
+ # @!method delete
572
+ # Create a route that matches +DELETE+ requests at +path+.
573
+ #
574
+ # @see get
575
+ #
576
+ DEFINABLE_HTTP_METHODS.each do |http_method|
577
+ define_method http_method.downcase.to_sym do |name_or_matcher = nil, matcher_or_name = nil, &block|
578
+ build_route(http_method, name_or_matcher, matcher_or_name, &block)
579
+ end
580
+ end
581
+
582
+ # Creates a nested group of routes, with an optional name.
583
+ #
584
+ # Named groups make the routes available for path building. Paths to routes defined in unnamed
585
+ # groups are referenced by the most direct parent group that is named.
586
+ #
587
+ # @example Defining a group:
588
+ # Pakyow::App.controller do
589
+ #
590
+ # def foo
591
+ # logger.info "foo"
592
+ # end
593
+ #
594
+ # group :foo do
595
+ # action :foo
596
+ # action :bar
597
+ #
598
+ # def bar
599
+ # logger.info "bar"
600
+ # end
601
+ #
602
+ # get :bar, "/bar" do
603
+ # # "foo" and "bar" have both been logged
604
+ # send "foo.bar"
605
+ # end
606
+ # end
607
+ #
608
+ # group do
609
+ # action :foo
610
+ #
611
+ # get :baz, "/baz" do
612
+ # # "foo" has been logged
613
+ # send "baz"
614
+ # end
615
+ # end
616
+ # end
617
+ #
618
+ # @example Building a path to a route within a named group:
619
+ # path :foo_bar
620
+ # # => "/foo/bar"
621
+ #
622
+ # @example Building a path to a route within an unnamed group:
623
+ # path :foo_baz
624
+ # # => nil
625
+ #
626
+ # path :baz
627
+ # # => "/baz"
628
+ #
629
+ def group(name = nil, **kwargs, &block)
630
+ make_child(name, nil, **kwargs, &block)
631
+ end
632
+
633
+ # Creates a group of routes and mounts them at a path, with an optional name. A namespace
634
+ # behaves just like a group with regard to path lookup and action inheritance.
635
+ #
636
+ # @example Defining a namespace:
637
+ # Pakyow::App.controller do
638
+ # namespace :api, "/api" do
639
+ # def auth
640
+ # handle 401 unless authed?
641
+ # end
642
+ #
643
+ # namespace :project, "/projects" do
644
+ # get :list, "/" do
645
+ # # route is accessible via 'GET /api/projects'
646
+ # send projects.to_json
647
+ # end
648
+ # end
649
+ # end
650
+ # end
651
+ #
652
+ def namespace(*args, **kwargs, &block)
653
+ name, matcher = parse_name_and_matcher_from_args(*args)
654
+ make_child(name, matcher, **kwargs, &block)
655
+ end
656
+
657
+ # Creates a route template with a name and block. The block is evaluated within a
658
+ # {Routing::Expansion} instance when / if it is later expanded at some endpoint (creating a
659
+ # namespace).
660
+ #
661
+ # Route templates are used to define a scaffold of default routes that will later be expanded
662
+ # at some path. During expansion, the scaffolded routes are also mapped to routing logic.
663
+ #
664
+ # Because routes can be referenced by name during expansion, route templates provide a way to
665
+ # create a domain-specific-language, or DSL, around a routing concern. This is used within
666
+ # Pakyow itself to define the resource template ({Routing::Extension::Resource}).
667
+ #
668
+ # @example Defining a template:
669
+ # Pakyow::App.controller do
670
+ # template :talkback do
671
+ # get :hello, "/hello"
672
+ # get :goodbye, "/goodbye"
673
+ # end
674
+ # end
675
+ #
676
+ # @example Expanding a template:
677
+ #
678
+ # Pakyow::App.controller do
679
+ # talkback :en, "/en" do
680
+ # hello do
681
+ # send "hello"
682
+ # end
683
+ #
684
+ # goodbye do
685
+ # send "goodbye"
686
+ # end
687
+ #
688
+ # # we can also extend the expansion
689
+ # # for our particular use-case
690
+ # get "/thanks" do
691
+ # send "thanks"
692
+ # end
693
+ # end
694
+ #
695
+ # talkback :fr, "/fr" do
696
+ # hello do
697
+ # send "bonjour"
698
+ # end
699
+ #
700
+ # # `goodbye` will not be an endpoint
701
+ # # since we did not expand it here
702
+ # end
703
+ # end
704
+ #
705
+ def template(name, &template_block)
706
+ templates[name] = template_block
707
+ end
708
+
709
+ # Expands a defined route template, or raises +NameError+.
710
+ #
711
+ # @see template
712
+ #
713
+ def expand(name, *args, **options, &block)
714
+ make_child(*args).expand_within(name, **options, &block)
715
+ end
716
+
717
+ # Attempts to find and expand a template, avoiding the need to call {expand} explicitly. For
718
+ # example, these calls are identical:
719
+ #
720
+ # Pakyow::App.controller do
721
+ # resource :posts, "/posts" do
722
+ # end
723
+ #
724
+ # expand :resource, :posts, "/posts" do
725
+ # end
726
+ # end
727
+ #
728
+ def method_missing(name, *args, &block)
729
+ if templates.include?(name)
730
+ expand(name, *args, &block)
731
+ else
732
+ super
733
+ end
734
+ end
735
+
736
+ def respond_to_missing?(method_name, include_private = false)
737
+ templates.include?(method_name) || super
738
+ end
739
+
740
+ # @api private
741
+ attr_reader :path, :matcher
742
+
743
+ # @api private
744
+ attr_accessor :parent
745
+
746
+ def path_to_self
747
+ return path unless parent
748
+ File.join(parent.path_to_self.to_s, path.to_s)
749
+ end
750
+
751
+ def name_of_self
752
+ return __object_name.name unless parent
753
+ [parent.name_of_self.to_s, __object_name.name.to_s].join("_").to_sym
754
+ end
755
+
756
+ def endpoints
757
+ self_name = __object_name&.name
758
+
759
+ # Ignore member and collection namespaces for endpoint building.
760
+ #
761
+ self_name = nil if self_name == :member || self_name == :collection
762
+
763
+ [].tap do |endpoints|
764
+ @routes.values.flatten.each do |route|
765
+ if route.name == :default && self_name
766
+ # Register the endpoint without the default name for easier lookup.
767
+ #
768
+ endpoints << Endpoint.new(
769
+ name: self_name,
770
+ method: route.method,
771
+ builder: Routing::Route::EndpointBuilder.new(
772
+ route: route, path: path_to_self
773
+ )
774
+ )
775
+ end
776
+
777
+ endpoints << Endpoint.new(
778
+ name: [self_name, route.name.to_s].compact.join("_"),
779
+ method: route.method,
780
+ builder: Routing::Route::EndpointBuilder.new(
781
+ route: route, path: path_to_self
782
+ )
783
+ )
784
+ end
785
+
786
+ children.flat_map(&:endpoints).each do |child_endpoint|
787
+ endpoints << Endpoint.new(
788
+ name: [self_name, child_endpoint.name].compact.join("_"),
789
+ method: child_endpoint.method,
790
+ builder: child_endpoint.builder
791
+ )
792
+ end
793
+ end
794
+ end
795
+
796
+ def make(*args, **kwargs, &block)
797
+ name, matcher = parse_name_and_matcher_from_args(*args)
798
+
799
+ path = path_from_matcher(matcher)
800
+ matcher = finalize_matcher(matcher || "/")
801
+
802
+ super(name, path: path, matcher: matcher, **kwargs, &block)
803
+ end
804
+
805
+ # @api private
806
+ def make_child(*args, **kwargs, &block)
807
+ name, matcher = parse_name_and_matcher_from_args(*args)
808
+
809
+ if name && name.is_a?(Symbol) && child = children.find { |possible_child| possible_child.__object_name.name == name }
810
+ if block_given?
811
+ child.instance_exec(&block)
812
+ end
813
+
814
+ child
815
+ else
816
+ if name && name.is_a?(Symbol) && __object_name
817
+ name = __object_name.isolated(name)
818
+ end
819
+
820
+ make(name, matcher, parent: self, **kwargs, &block).tap do |controller|
821
+ children << controller
822
+ end
823
+ end
824
+ end
825
+
826
+ # @api private
827
+ def expand_within(name, **options, &block)
828
+ raise NameError, "unknown template `#{name}'" unless template = templates[name]
829
+ Routing::Expansion.new(name, self, options, &template)
830
+ class_eval(&block)
831
+ self
832
+ end
833
+
834
+ protected
835
+
836
+ def parse_name_and_matcher_from_args(name_or_matcher = nil, matcher_or_name = nil)
837
+ Support::Aargv.normalize([name_or_matcher, matcher_or_name].compact, name: [Symbol, Support::ObjectName], matcher: Object).values_at(:name, :matcher)
838
+ end
839
+
840
+ def finalize_matcher(matcher)
841
+ if matcher.is_a?(String)
842
+ converted_matcher = String.normalize_path(matcher.split("/").map { |segment|
843
+ if segment.include?(":")
844
+ "(?<#{segment[1..-1]}>(\\w|[-.~:@!$\\'\\(\\)\\*\\+,;])+)"
845
+ else
846
+ segment
847
+ end
848
+ }.join("/"))
849
+
850
+ Regexp.new("^#{String.normalize_path(converted_matcher)}")
851
+ else
852
+ matcher
853
+ end
854
+ end
855
+
856
+ def path_from_matcher(matcher)
857
+ if matcher.is_a?(String)
858
+ matcher
859
+ else
860
+ nil
861
+ end
862
+ end
863
+
864
+ def build_route(method, *args, &block)
865
+ name, matcher = parse_name_and_matcher_from_args(*args)
866
+ Routing::Route.new(matcher, name: name, method: method, &block).tap do |route|
867
+ routes[method] << route
868
+ end
869
+ end
870
+ end
871
+ end
872
+ end