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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +137 -0
- data/LICENSE +4 -0
- data/README.md +33 -0
- data/lib/pakyow/behavior/definition.rb +35 -0
- data/lib/pakyow/routing/actions/respond_missing.rb +13 -0
- data/lib/pakyow/routing/controller/behavior/error_handling.rb +149 -0
- data/lib/pakyow/routing/controller/behavior/param_verification.rb +76 -0
- data/lib/pakyow/routing/controller.rb +872 -0
- data/lib/pakyow/routing/expansion.rb +104 -0
- data/lib/pakyow/routing/extensions/resource.rb +158 -0
- data/lib/pakyow/routing/extensions.rb +3 -0
- data/lib/pakyow/routing/framework.rb +82 -0
- data/lib/pakyow/routing/helpers/exposures.rb +25 -0
- data/lib/pakyow/routing/route.rb +85 -0
- data/lib/pakyow/routing.rb +10 -0
- data/lib/pakyow/security/base.rb +47 -0
- data/lib/pakyow/security/behavior/config.rb +34 -0
- data/lib/pakyow/security/behavior/disabling.rb +37 -0
- data/lib/pakyow/security/behavior/helpers.rb +19 -0
- data/lib/pakyow/security/behavior/insecure.rb +21 -0
- data/lib/pakyow/security/behavior/pipeline.rb +21 -0
- data/lib/pakyow/security/csrf/verify_authenticity_token.rb +26 -0
- data/lib/pakyow/security/csrf/verify_same_origin.rb +73 -0
- data/lib/pakyow/security/errors.rb +19 -0
- data/lib/pakyow/security/helpers/csrf.rb +15 -0
- data/lib/pakyow/security/pipelines/csrf.rb +24 -0
- metadata +98 -0
@@ -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
|