# frozen-string-literal: true
#
class Roda
module RodaPlugins
# The path plugin adds support for named paths. Using the +path+ class method, you can
# easily create <tt>*_path</tt> instance methods for each named path. Those instance
# methods can then be called if you need to get the path for a form action, link,
# redirect, or anything else.
#
# Additionally, you can call the +path+ class method with a class and a block, and it will register
# the class. You can then call the +path+ instance method with an instance of that class, and it will
# execute the block in the context of the route block scope with the arguments provided to path. You
# can call the +url+ instance method with the same arguments as the +path+ method to get the full URL.
#
# Example:
#
# plugin :path
# path :foo, '/foo'
# path :bar do |bar|
# "/bar/#{bar.id}"
# end
# path Baz do |baz, *paths|
# "/baz/#{baz.id}/#{paths.join('/')}"
# end
# path Quux do |quux, path|
# "/quux/#{quux.id}/#{path}"
# end
# path 'FooBar', class_name: true do |foobar|
# "/foobar/#{foobar.id}"
# end
#
# route do |r|
# r.post 'foo' do
# r.redirect foo_path # /foo
# end
#
# r.post 'bar' do
# bar_params = r.params['bar']
# if bar_params.is_a?(Hash)
# bar = Bar.create(bar_params)
# r.redirect bar_path(bar) # /bar/1
# end
# end
#
# r.post 'baz' do
# baz = Baz[1]
# r.redirect path(baz, 'c', 'd') # /baz/1/c/d
# end
#
# r.post 'quux' do
# quux = Quux[1]
# r.redirect url(quux, '/bar') # http://example.com/quux/1/bar
# end
# end
#
# The path class method accepts the following options when not called with a class:
#
# :add_script_name :: Prefix the path generated with SCRIPT_NAME. This defaults to the app's
# :add_script_name option.
# :name :: Provide a different name for the method, instead of using <tt>*_path</tt>.
# :relative :: Generate paths relative to the current request instead of absolute paths by prepending
# an appropriate prefix. This implies :add_script_name.
# :url :: Create a url method in addition to the path method, which will prefix the string generated
# with the appropriate scheme, host, and port. If true, creates a <tt>*_url</tt>
# method. If a Symbol or String, uses the value as the url method name.
# :url_only :: Do not create a path method, just a url method.
#
# Note that if :add_script_name, :relative, :url, or :url_only is used, the path method will also create a
# <tt>_*_path</tt> private method.
#
# If the path class method is passed a string or symbol as the first argument, and the second argument
# is a hash with the :class_name option passed, the symbol/string is treated as a class name.
# This enables the use of class-based paths without forcing autoloads for the related
# classes. If the plugin is not registering classes by name, this will use the symbol or
# string to find the related class.
module Path
DEFAULT_PORTS = {'http' => 80, 'https' => 443}.freeze
# Regexp for valid constant names, to prevent code execution.
VALID_CONSTANT_NAME_REGEXP = /\A(?:::)?([A-Z]\w*(?:::[A-Z]\w*)*)\z/.freeze
# Initialize the path classes when loading the plugin. Options:
# :by_name :: Register classes by name, which is friendlier when reloading code (defaults to
# true in development mode)
def self.configure(app, opts=OPTS)
app.instance_eval do
self.opts[:path_class_by_name] = opts.fetch(:by_name, ENV['RACK_ENV'] == 'development')
self.opts[:path_classes] ||= {}
self.opts[:path_class_methods] ||= {}
unless path_block(String)
path(String){|str| str}
end
end
end
module ClassMethods
# Hash of recognizes classes for path instance method. Keys are classes, values are procs.
def path_classes
opts[:path_classes]
end
# Freeze the path classes when freezing the app.
def freeze
path_classes.freeze
opts[:path_classes_methods].freeze
super
end
# Create a new instance method for the named path. See plugin module documentation for options.
def path(name, path=nil, opts=OPTS, &block)
if name.is_a?(Class) || (path.is_a?(Hash) && (class_name = path[:class_name]))
raise RodaError, "can't provide path when calling path with a class" if path && !class_name
raise RodaError, "can't provide options when calling path with a class" unless opts.empty?
raise RodaError, "must provide a block when calling path with a class" unless block
if self.opts[:path_class_by_name]
if class_name
name = name.to_s
else
name = name.name
end
elsif class_name
name = name.to_s
raise RodaError, "invalid class name passed when using class_name option" unless VALID_CONSTANT_NAME_REGEXP =~ name
name = Object.class_eval(name, __FILE__, __LINE__)
end
path_classes[name] = block
self.opts[:path_class_methods][name] = define_roda_method("path_#{name}", :any, &block)
return
end
if path.is_a?(Hash)
raise RodaError, "cannot provide two option hashses to Roda.path" unless opts.empty?
opts = path
path = nil
end
raise RodaError, "cannot provide both path and block to Roda.path" if path && block
raise RodaError, "must provide either path or block to Roda.path" unless path || block
if path
path = path.dup.freeze
block = lambda{path}
end
meth = opts[:name] || "#{name}_path"
url = opts[:url]
url_only = opts[:url_only]
relative = opts[:relative]
add_script_name = opts.fetch(:add_script_name, self.opts[:add_script_name])
if relative
if (url || url_only)
raise RodaError, "cannot provide :url or :url_only option if using :relative option"
end
add_script_name = true
plugin :relative_path
end
if add_script_name || url || url_only || relative
_meth = "_#{meth}"
define_method(_meth, &block)
private _meth
end
unless url_only
if relative
define_method(meth) do |*a, &blk|
# Allow calling private _method to get path
relative_path(request.script_name.to_s + send(_meth, *a, &blk))
end
# :nocov:
ruby2_keywords(meth) if respond_to?(:ruby2_keywords, true)
# :nocov:
elsif add_script_name
define_method(meth) do |*a, &blk|
# Allow calling private _method to get path
request.script_name.to_s + send(_meth, *a, &blk)
end
# :nocov:
ruby2_keywords(meth) if respond_to?(:ruby2_keywords, true)
# :nocov:
else
define_method(meth, &block)
end
end
if url || url_only
url_meth = if url.is_a?(String) || url.is_a?(Symbol)
url
else
"#{name}_url"
end
url_block = lambda do |*a, &blk|
# Allow calling private _method to get path
"#{_base_url}#{request.script_name if add_script_name}#{send(_meth, *a, &blk)}"
end
define_method(url_meth, &url_block)
# :nocov:
ruby2_keywords(url_meth) if respond_to?(:ruby2_keywords, true)
# :nocov:
end
nil
end
# Return the block related to the given class, or nil if there is no block.
def path_block(klass)
# RODA4: Remove
if opts[:path_class_by_name]
klass = klass.name
end
path_classes[klass]
end
end
module InstanceMethods
# Return a path based on the class of the object. The object passed must have
# had its class previously registered with the application. If the app's
# :add_script_name option is true, this prepends the SCRIPT_NAME to the path.
def path(obj, *args, &block)
app = self.class
opts = app.opts
klass = opts[:path_class_by_name] ? obj.class.name : obj.class
unless meth = opts[:path_class_methods][klass]
raise RodaError, "unrecognized object given to Roda#path: #{obj.inspect}"
end
path = send(meth, obj, *args, &block)
path = request.script_name.to_s + path if opts[:add_script_name]
path
end
# Similar to #path, but returns a complete URL.
def url(*args, &block)
"#{_base_url}#{path(*args, &block)}"
end
private
# The string to prepend to the path to make the path a URL.
def _base_url
r = @_request
scheme = r.scheme
port = r.port
"#{scheme}://#{r.host}#{":#{port}" unless DEFAULT_PORTS[scheme] == port}"
end
end
end
register_plugin(:path, Path)
end
end