pakyow-routing 1.0.0.rc1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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