/
routable.cr
521 lines (455 loc) · 15.7 KB
/
routable.cr
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
# Methods for routing HTTP requests and their parameters to actions.
module Lucky::Routable
macro included
ROUTE_SETTINGS = {prefix: ""}
macro included
inherit_route_settings
end
macro inherited
ROUTE_SETTINGS = {prefix: ""}
inherit_route_settings
end
end
macro inherit_route_settings
\{% for k, v in @type.ancestors.first.constant :ROUTE_SETTINGS %}
\{% ROUTE_SETTINGS[k] = v %}
\{% end %}
end
macro fallback
Lucky::RouteNotFoundHandler.fallback_action = {{ @type.name.id }}
setup_call_method({{ yield }})
end
# Sets the prefix for all routes defined by the match
# and http method (get, put, post, etc..) macros
macro route_prefix(prefix)
{% unless prefix.starts_with?("/") %}
{% prefix.raise "Prefix must start with a slash. Example: '/#{prefix}'" %}
{% end %}
{% ROUTE_SETTINGS[:prefix] = prefix %}
end
{% for http_method in [:get, :put, :post, :patch, :trace, :delete] %}
# Define a route that responds to a {{ http_method.id.upcase }} request
#
# Use these methods if you need a custom path or are using a non-restful
# route. For example:
#
# ```
# class Profile::ImageUpload
# {{ http_method.id }} "/profile/image/:id" do
# # action code here
# end
# end
# ```
#
# will respond to an `HTTP {{ http_method.id.upcase }}` request.
#
# **See also** our guides for more information and examples:
# * [Routing](https://luckyframework.org/guides/http-and-routing/routing-and-params#routing)
macro {{ http_method.id }}(path)
match(:{{ http_method.id }}, \{{ path }}) do
\{{ yield }}
end
end
{% end %}
# Define a route with a custom HTTP method.
#
# Use this method if you need to match a route with a custom HTTP method (verb).
# For example:
#
# ```
# class Profile::Show
# match :options, "/profile" do
# # action code here
# end
# end
#
# Will respond to an `HTTP OPTIONS` request.
macro match(method, path)
{% unless path.starts_with?("/") %}
{% path.raise "Path must start with a slash. Example: '/#{path}'" %}
{% end %}
{% unless method == method.downcase %}
{% method.raise "HTTP methods should be lower-case symbols. Use #{method.downcase} instead of #{method}." %}
{% end %}
add_route({{method}}, {{ path }}, {{ @type.name.id }})
setup_call_method({{ yield }})
end
# :nodoc:
macro setup_call_method(body)
def call
# Ensure clients_desired_format is cached by calling it
clients_desired_format
%pipe_result = run_before_pipes
%response = if %pipe_result.is_a?(Lucky::Response)
%pipe_result
else
{{ body }}
end
%pipe_result = run_after_pipes
if %pipe_result.is_a?(Lucky::Response)
%pipe_result
else
%response
end
end
end
# :nodoc:
@[Deprecated("`nested_route` deprecated. Define your routes manually instead.")]
macro nested_route
infer_nested_route
setup_call_method({{ yield }})
end
# :nodoc:
@[Deprecated("`route` deprecated. Define your routes manually instead.")]
macro route
infer_route
setup_call_method({{ yield }})
end
# :nodoc:
macro infer_nested_route
infer_route(has_parent: true)
end
# :nodoc:
macro infer_route(has_parent = false)
{{ run "../run_macros/infer_route", @type.name, has_parent }}
end
# Implement this macro in your action to check the path for a particular style.
#
# By default Lucky ships with a `Lucky::EnforceUnderscoredRoute` that is included
# in your `BrowserAction` and `ApiAction` (as of Lucky 0.28)
#
# See the docs for `Lucky::EnforceUnderscoredRoute` to learn how to use it or disable it.
macro enforce_route_style(path, action)
# no-op by default
end
private NORMALIZED_ROUTES = {} of _ => _
# :nodoc:
macro enforce_route_uniqueness(method, original_path)
# Regex for capturing the param part for normalization
#
# So "/users/:user_id" is changed to "/users/:normalized"
{% normalized_path = original_path.gsub(/(\:\w*)/, ":normalized") %}
{% normalized_key = "#{method.id} #{normalized_path.id}" %}
{% if already_used_route = NORMALIZED_ROUTES[normalized_key] %}
{% raise <<-ERROR
#{original_path} in '#{@type.name}' collides with the path in '#{already_used_route[:action]}'
Try this...
▸ Change the paths in one of the actions to something unique
Or, skip the uniqueness check
# Only do this if you are sure the route is unique
class #{@type.name}
+ include Lucky::SkipUniqueRouteCheck
end
ERROR
%}
{% else %}
{% NORMALIZED_ROUTES[normalized_key] = {
normalized_path: normalized_path,
original_path: original_path,
method: method,
action: @type.name,
} %}
{% end %}
end
# :nodoc:
macro add_route(method, path, action)
{% path = ROUTE_SETTINGS[:prefix] + path %}
enforce_route_style({{ path }}, {{ @type.name.id }})
enforce_route_uniqueness({{method}}, {{ path }})
Lucky::Router.add({{ method }}, {{ path }}, {{ @type.name.id }})
{% path_parts = path.split('/').reject(&.empty?) %}
{% path_params = path_parts.select(&.starts_with?(':')) %}
{% optional_path_params = path_parts.select(&.starts_with?("?:")) %}
{% glob_param = path_parts.select(&.starts_with?("*")) %}
{% if glob_param.size > 1 %}
{% glob_param.raise "Only one glob can be in a path, but found more than one." %}
{% end %}
{% glob_param = glob_param.first %}
{% if glob_param && path_parts.last != glob_param %}
{% glob_param.raise "Glob param must be defined at the end of the path." %}
{% end %}
{% for param in path_params %}
{% if param.includes?("-") %}
{% param.raise "Path variables must only use underscores. Use #{param.gsub(/-/, "_")} instead of #{param}." %}
{% end %}
{% part = param.gsub(/:/, "").id %}
def {{ part }} : String
params.get(:{{ part }})
end
{% end %}
{% for param in optional_path_params %}
{% if param.includes?("-") %}
{% param.raise "Optional path variables must only use underscores. Use #{param.gsub(/-/, "_")} instead of #{param}." %}
{% end %}
{% part = param.gsub(/^\?:/, "").id %}
def {{ part }} : String?
params.get?(:{{ part }})
end
{% end %}
{% if glob_param %}
{% if glob_param.includes?("-") %}
{% glob_param.raise "Named globs must only use underscores. Use #{glob_param.gsub(/-/, "_")} instead of #{glob_param}." %}
{% end %}
{% part = nil %}
{% if glob_param.starts_with?("*:") %}
{% part = glob_param.gsub(/\*:/, "") %}
{% elsif glob_param == "*" %}
{% part = "glob" %}
{% else %}
{% glob_param.raise "Invalid glob format #{glob_param}." %}
{% end %}
def {{ part.id }} : String?
params.get?({{ part.id.symbolize }})
end
{% end %}
def self.path(*args, **named_args) : String
route(*args, **named_args).path
end
def self.url(*args, **named_args) : String
route(*args, **named_args).url
end
def self.url_without_query_params(
{% for param in path_params %}
{{ param.gsub(/:/, "").id }},
{% end %}
{% for param in optional_path_params %}
{{ param.gsub(/^\?:/, "").id }} = nil,
{% end %}
)
path = path_from_parts(
{% for param in path_params %}
{{ param.gsub(/:/, "").id }},
{% end %}
{% for param in optional_path_params %}
{{ param.gsub(/^\?:/, "").id }},
{% end %}
)
Lucky::RouteHelper.new({{ method }}, path).url
end
def self.path_without_query_params(
{% for param in path_params %}
{{ param.gsub(/:/, "").id }},
{% end %}
{% for param in optional_path_params %}
{{ param.gsub(/^\?:/, "").id }} = nil,
{% end %}
)
path = path_from_parts(
{% for param in path_params %}
{{ param.gsub(/:/, "").id }},
{% end %}
{% for param in optional_path_params %}
{{ param.gsub(/^\?:/, "").id }},
{% end %}
)
Lucky::RouteHelper.new({{ method }}, path).path
end
{% params_with_defaults = PARAM_DECLARATIONS.select do |decl|
!decl.value.is_a?(Nop) || decl.type.is_a?(Union) && decl.type.types.last.id == Nil.id
end %}
{% params_without_defaults = PARAM_DECLARATIONS.reject do |decl|
params_with_defaults.includes? decl
end %}
def self.route(
# required path variables
{% for param in path_params %}
{{ param.gsub(/:/, "").id }},
{% end %}
# required params
{% for param in params_without_defaults %}
{{ param }},
{% end %}
# params with a default value set are always nilable
{% for param in params_with_defaults %}
{{ param.var }} = nil,
{% end %}
# optional path variables are nilable
{% for param in optional_path_params %}
{{ param.gsub(/^\?:/, "").id }} : String? = nil,
{% end %}
anchor : String? = nil
) : Lucky::RouteHelper
path = path_from_parts(
{% for param in path_params %}
{{ param.gsub(/:/, "").id }},
{% end %}
{% for param in optional_path_params %}
{{ param.gsub(/^\?:/, "").id }},
{% end %}
)
query_params = {} of String => String
{% for param in PARAM_DECLARATIONS %}
# add query param if given and not nil
query_params["{{ param.var }}"] = {{ param.var }}.to_s unless {{ param.var }}.nil?
{% end %}
unless query_params.empty?
path += "?#{HTTP::Params.encode(query_params)}"
end
anchor.try do |value|
path += "#"
path += URI.encode_www_form(value)
end
Lucky::RouteHelper.new {{ method }}, path
end
def self.route(*_args, **_named_args) : Lucky::RouteHelper
{% requireds = path_params.map { |param| "#{param.gsub(/:/, "").id}" } %}
{% params_without_defaults.each { |param| requireds << "#{param.var}" } %}
{% optionals = optional_path_params.map { |param| "#{param.gsub(/^\?:/, "").id}" } %}
{% params_with_defaults.each { |param| optionals << "#{param.var}" } %}
\{% raise <<-ERROR
Invalid call to {{ @type }}.route
{% if !requireds.empty? %}
Required arguments:
{% for req in requireds %}\n- {{ req.id }}{% end %}
{% end %}{% if !optionals.empty? %}
Optional arguments:
{% for opts in optionals %}\n- {{ opts.id }}{% end %}
{% end %}
For more information, refer to https://luckyframework.org/guides/http-and-routing/link-generation.
ERROR
%}
end
def self.with(*args, **named_args) : Lucky::RouteHelper
route(*args, **named_args)
end
def self.with
\{% raise "Use `route` instead of `with` if the route doesn't need params" %}
end
private def self.path_from_parts(
{% for param in path_params %}
{{ param.gsub(/:/, "").id }},
{% end %}
{% for param in optional_path_params %}
{{ param.gsub(/^\?:/, "").id }},
{% end %}
)
path = String.build do |path|
{% for part in path_parts %}
{% if part.starts_with?("?:") %}
if {{ part.gsub(/^\?:/, "").id }}
path << "/"
path << URI.encode_www_form({{ part.gsub(/^\?:/, "").id }}.to_param)
end
{% elsif part.starts_with?(':') %}
path << "/"
path << URI.encode_www_form({{ part.gsub(/:/, "").id }}.to_param)
{% else %}
path << "/"
path << URI.encode_www_form({{ part }})
{% end %}
{% end %}
end
is_root_path = path == ""
path = "/" if is_root_path
path
end
end
macro included
PARAM_DECLARATIONS = [] of Crystal::Macros::TypeDeclaration
@@query_param_declarations : Array(String) = [] of String
class_getter query_param_declarations : Array(String)
macro inherited
inherit_param_declarations
end
end
# :nodoc:
macro inherit_param_declarations
PARAM_DECLARATIONS = [] of Crystal::Macros::TypeDeclaration
\{% for param_declaration in @type.ancestors.first.constant :PARAM_DECLARATIONS %}
\{% PARAM_DECLARATIONS << param_declaration %}
\{% end %}
end
# Access query and POST parameters
#
# When a query parameter or POST data is passed to an action, it is stored in
# the params object. But accessing the param directly from the params object
# isn't type safe. Enter `param`. It checks the given param's type and makes
# it easily available inside the action.
#
# ```
# class Posts::Index < BrowserAction
# param page : Int32?
#
# get "/posts" do
# plain_text "Posts - Page #{page || 1}"
# end
# end
# ```
#
# To generate a link with a param, use the `with` method:
# `Posts::Index.with(10).path` which will generate `/posts?page=10`. Visiting
# that path would render the above action like this:
#
# ```text
# Posts - Page 10
# ```
#
# This works behind the scenes by creating a `page` method in the action to
# access the parameter.
#
# **Note:** Params can also have a default, but then their routes will not
# include the parameter in the query string. Using the `with(10)` method for a
# param like this:
# `param page : Int32 = 1` will only generate `/posts`.
#
# These parameters are also typed. The path `/posts?page=ten` will raise a
# `Lucky::InvalidParamError` error because `ten` is a String not an
# Int32.
#
# Additionally, if the param is non-optional it will raise the
# `Lucky::MissingParamError` error if the required param is absent
# when making a request:
#
# ```
# class UserConfirmations::New < BrowserAction
# param token : String # this param is required!
#
# get "/user_confirmations/new" do
# # confirm the user with their `token`
# end
# end
# ```
#
# When visiting this page, the path _must_ contain the token parameter:
# `/user_confirmations/new?token=abc123`
macro param(type_declaration)
{% unless type_declaration.is_a?(TypeDeclaration) %}
{% raise "'param' expects a type declaration like 'name : String', instead got: '#{type_declaration}'" %}
{% end %}
{% PARAM_DECLARATIONS << type_declaration %}
@@query_param_declarations << "{{ type_declaration.var }} : {{ type_declaration.type }}"
getter {{ type_declaration.var }} : {{ type_declaration.type }} do
{% is_nilable_type = type_declaration.type.resolve.nilable? %}
{% base_type = is_nilable_type ? type_declaration.type.types.first : type_declaration.type %}
{% is_array = base_type.is_a?(Generic) %}
{% type = is_array ? base_type.type_vars.first : base_type %}
{% if is_array %}
val = params.get_all?(:{{ type_declaration.var.id }})
{% else %}
val = params.get?(:{{ type_declaration.var.id }})
{% end %}
if val.nil?
default_or_nil = {{ type_declaration.value.is_a?(Nop) ? nil : type_declaration.value }}
{% if is_nilable_type %}
return default_or_nil
{% else %}
if default_or_nil.nil?
raise Lucky::MissingParamError.new("{{ type_declaration.var.id }}")
else
return default_or_nil
end
{% end %}
end
result = {{ base_type }}.adapter.parse(val)
if result.is_a? Avram::Type::SuccessfulCast
result.value
else
raise Lucky::InvalidParamError.new(
param_name: "{{ type_declaration.var.id }}",
param_value: val.to_s,
param_type: "{{ type }}"
)
end
end
end
end