class Pakyow::Routing::Controller
Executes code for particular requests. For example:
Pakyow::Application.controller do get "/" do # called for GET / requests end end
A Class
is created dynamically for each defined controller. When matched, a route is called in context of its controller. This means that any method defined in a controller is available to be called from within a route. For example:
Pakyow::Application.controller do def foo end get :foo, "/foo" do foo end end
Including modules works as expected:
module AuthHelpers def current_user end end Pakyow::Application.controller do include AuthHelpers get :foo, "/foo" do current_user end end
See {Application.controller} for more details on defining controllers.
Supported HTTP methods¶ ↑
-
GET
-
POST
-
PUT
-
PATCH
-
DELETE
See {get}, {post}, {put}, {patch}, and {delete}.
HEAD
requests are handled automatically via {Rack::Head}.
Building paths for named routes¶ ↑
Path building is supported via {Controller#path} and {Controller#path_to}.
Reusing logic with actions¶ ↑
Methods can be defined as additional actions for a route. For example:
Pakyow::Application.controller do action :called_before def called_before ... end get :foo, "/foo" do ... end end
Extending controllers¶ ↑
Extensions can be defined and used to add shared routes to one or more controllers. See {Routing::Extension}.
Other routing features¶ ↑
More advanced route features are available, including groups, namespaces, and templates. See {group}, {namespace}, and {template}.
Controller
subclasses¶ ↑
It's possible to work with controllers outside of Pakyow's DSL. For example:
class FooController < Pakyow::Routing::Controller("/foo") default do # available at GET /foo end end Pakyow::Application.controller << FooController
Custom matchers¶ ↑
Controllers and routes can be defined with a matcher rather than a path. The matcher could be a Regexp
or any custom object that implements match?
. For example:
class CustomMatcher def match?(path) path == "/custom" end end Pakyow::Application.controller CustomMatcher.new do end
Custom matchers can also make data available in params
by implementing match
and returning an object that implements named_captures
. For example:
class CustomMatcher def match?(path) path == "/custom" end def match(path) return self if match?(path) end def named_captures { foo: "bar" } end end Pakyow::Application.controller CustomMatcher.new do end
Constants
- CONTENT_DISPOSITION
- DEFAULT_SEND_TYPE
- DEFINABLE_HTTP_METHODS
- METHOD_DELETE
- METHOD_GET
- METHOD_HEAD
- METHOD_PATCH
- METHOD_POST
- METHOD_PUT
Attributes
@api private
@api private
@api private
Public Class Methods
Conveniently define defaults when subclassing Pakyow::Routing::Controller
.
@example
class MyController < Pakyow::Routing::Controller("/foo") # more routes here end
rubocop:disable Naming/MethodName
# File lib/pakyow/routing/controller.rb, line 529 def Controller(matcher) make(matcher) end
# File lib/pakyow/routing/controller.rb, line 484 def action(name, only: [], skip: [], &block) @__pipeline.actions.delete_if do |action| action.name == name end if only.any? only.each do |route_name| (@limit_by_route[route_name] ||= []) << { insert: Support::Pipeline::Action.new(name, &block), after: @__pipeline.actions.last } end else super(name, &block) end skip.each do |route_name| (@skips_by_route[route_name] ||= []) << name end end
Create a default route. Shorthand for +get “/”+.
@see get
# File lib/pakyow/routing/controller.rb, line 538 def default(&block) get :default, "/", &block end
# File lib/pakyow/routing/controller.rb, line 760 def endpoints self_name = __object_name&.name # Ignore member and collection namespaces for endpoint building. # self_name = nil if self_name == :member || self_name == :collection [].tap do |endpoints| @routes.values.flatten.each do |route| if route.name == :default && self_name # Register the endpoint without the default name for easier lookup. # endpoints << Endpoint.new( name: self_name, method: route.method, builder: Routing::Route::EndpointBuilder.new( route: route, path: path_to_self ) ) end endpoints << Endpoint.new( name: [self_name, route.name.to_s].compact.join("_"), method: route.method, builder: Routing::Route::EndpointBuilder.new( route: route, path: path_to_self ) ) end children.flat_map(&:endpoints).each do |child_endpoint| endpoints << Endpoint.new( name: [self_name, child_endpoint.name].compact.join("_"), method: child_endpoint.method, builder: child_endpoint.builder ) end end end
Expands a defined route template, or raises NameError
.
@see template
# File lib/pakyow/routing/controller.rb, line 717 def expand(name, *args, **options, &block) make_child(*args).expand_within(name, **options, &block) end
@api private
# File lib/pakyow/routing/controller.rb, line 832 def expand_within(name, **options, &block) raise NameError, "unknown template `#{name}'" unless template = templates[name] Routing::Expansion.new(name, self, options, &template) class_eval(&block) self end
Creates a nested group of routes, with an optional name.
Named groups make the routes available for path building. Paths to routes defined in unnamed groups are referenced by the most direct parent group that is named.
@example Defining a group:
Pakyow::Application.controller do def foo logger.info "foo" end group :foo do action :foo action :bar def bar logger.info "bar" end get :bar, "/bar" do # "foo" and "bar" have both been logged send "foo.bar" end end group do action :foo get :baz, "/baz" do # "foo" has been logged send "baz" end end end
@example Building a path to a route within a named group:
path :foo_bar # => "/foo/bar"
@example Building a path to a route within an unnamed group:
path :foo_baz # => nil path :baz # => "/baz"
# File lib/pakyow/routing/controller.rb, line 633 def group(name = nil, **kwargs, &block) make_child(name, nil, **kwargs, &block) end
@api private
# File lib/pakyow/routing/controller.rb, line 801 def make(*args, **kwargs, &block) name, matcher = parse_name_and_matcher_from_args(*args) path = path_from_matcher(matcher) matcher = finalize_matcher(matcher || "/") super(name, path: path, matcher: matcher, **kwargs, &block) end
@api private
# File lib/pakyow/routing/controller.rb, line 811 def make_child(*args, **kwargs, &block) name, matcher = parse_name_and_matcher_from_args(*args) if name && name.is_a?(Symbol) && child = children.find { |possible_child| possible_child.__object_name.name == name } if block_given? child.instance_exec(&block) end child else if name && name.is_a?(Symbol) && __object_name name = __object_name.isolated(name) end make(name, matcher, parent: self, **kwargs, &block).tap do |controller| children << controller end end end
Attempts to find and expand a template, avoiding the need to call {expand} explicitly. For example, these calls are identical:
Pakyow::Application.controller do resource :posts, "/posts" do end expand :resource, :posts, "/posts" do end end
# File lib/pakyow/routing/controller.rb, line 732 def method_missing(name, *args, &block) if templates.include?(name) expand(name, *args, &block) else super end end
# File lib/pakyow/routing/controller.rb, line 755 def name_of_self return __object_name.name unless parent [parent.name_of_self.to_s, __object_name.name.to_s].join("_").to_sym end
Creates a group of routes and mounts them at a path, with an optional name. A namespace behaves just like a group with regard to path lookup and action inheritance.
@example Defining a namespace:
Pakyow::Application.controller do namespace :api, "/api" do def auth handle 401 unless authed? end namespace :project, "/projects" do get :list, "/" do # route is accessible via 'GET /api/projects' send projects.to_json end end end end
# File lib/pakyow/routing/controller.rb, line 656 def namespace(*args, **kwargs, &block) name, matcher = parse_name_and_matcher_from_args(*args) make_child(name, matcher, **kwargs, &block) end
Controllers must be initialized with an argument, even though the argument isn't actually used. This is a side-effect of allowing templates to define a route named “new”. In the expansion, we differentiate between expanding and initializing by whether an argument is present, which works because arguments aren't passed for expansions.
# File lib/pakyow/routing/controller.rb, line 196 def initialize(app) @children = self.class.children.map { |child| child.new(app) } self.class.routes.values.flatten.each do |route| route.pipeline = self.class.__pipeline.dup self.class.limit_by_route[route.name].to_a.reverse.each do |limit| if index = route.pipeline.actions.index(limit[:after]) route.pipeline.actions.insert(index + 1, limit[:insert]) else route.pipeline.actions << limit[:insert] end end route.pipeline.actions.delete_if do |action| self.class.global_skips.to_a.include?(action.name) || self.class.skips_by_route[route.name].to_a.include?(action.name) end route.pipeline.actions << Support::Pipeline::Action.new(:dispatch) end end
# File lib/pakyow/routing/controller.rb, line 750 def path_to_self return path unless parent File.join(parent.path_to_self.to_s, path.to_s) end
# File lib/pakyow/routing/controller.rb, line 740 def respond_to_missing?(method_name, include_private = false) templates.include?(method_name) || super end
# File lib/pakyow/routing/controller.rb, line 505 def skip(name, only: []) if only.empty? @global_skips << name else only.each do |route_name| (@skips_by_route[route_name] ||= []) << name end end end
Creates a route template with a name and block. The block is evaluated within a {Routing::Expansion} instance when / if it is later expanded at some endpoint (creating a namespace).
Route
templates are used to define a scaffold of default routes that will later be expanded at some path. During expansion, the scaffolded routes are also mapped to routing logic.
Because routes can be referenced by name during expansion, route templates provide a way to create a domain-specific-language, or DSL, around a routing concern. This is used within Pakyow
itself to define the resource template ({Routing::Extension::Resource}).
@example Defining a template:
Pakyow::Application.controller do template :talkback do get :hello, "/hello" get :goodbye, "/goodbye" end end
@example Expanding a template:
Pakyow::Application.controller do talkback :en, "/en" do hello do send "hello" end goodbye do send "goodbye" end # we can also extend the expansion # for our particular use-case get "/thanks" do send "thanks" end end talkback :fr, "/fr" do hello do send "bonjour" end # `goodbye` will not be an endpoint # since we did not expand it here end end
# File lib/pakyow/routing/controller.rb, line 709 def template(name, &template_block) templates[name] = template_block end
# File lib/pakyow/routing/controller.rb, line 515 def use_pipeline(*) super @limit_by_route = {} end
Private Class Methods
# File lib/pakyow/routing/controller.rb, line 869 def build_route(method, *args, &block) name, matcher = parse_name_and_matcher_from_args(*args) Routing::Route.new(matcher, name: name, method: method, &block).tap do |route| routes[method] << route end end
# File lib/pakyow/routing/controller.rb, line 845 def finalize_matcher(matcher) if matcher.is_a?(String) converted_matcher = String.normalize_path(matcher.split("/").map { |segment| if segment.include?(":") "(?<#{segment[1..-1]}>(\\w|[-.~:@!$\\'\\(\\)\\*\\+,;])+)" else segment end }.join("/")) Regexp.new("^#{String.normalize_path(converted_matcher)}") else matcher end end
# File lib/pakyow/routing/controller.rb, line 841 def parse_name_and_matcher_from_args(name_or_matcher = nil, matcher_or_name = nil) Support::Aargv.normalize([name_or_matcher, matcher_or_name].compact, name: [Symbol, Support::ObjectName], matcher: Object).values_at(:name, :matcher) end
# File lib/pakyow/routing/controller.rb, line 861 def path_from_matcher(matcher) if matcher.is_a?(String) matcher else nil end end
Public Instance Methods
# File lib/pakyow/routing/controller.rb, line 221 def call(connection, request_path = connection.path) request_method = connection.method if request_method == METHOD_HEAD request_method = METHOD_GET end matcher = self.class.matcher if match = matcher.match(request_path) match_data = match.named_captures connection.params.merge!(match_data) if matcher.is_a?(Regexp) request_path = String.normalize_path(request_path.sub(matcher, "")) end @children.each do |child_controller| child_controller.call(connection, request_path) break if connection.halted? end unless connection.halted? self.class.routes[request_method].to_a.each do |route| catch :reject do if route_match = route.match(request_path) connection.params.merge!(route_match.named_captures) connection.set( :__endpoint_path, String.normalize_path( File.join( self.class.path_to_self.to_s, route.path.to_s ) ) ) connection.set(:__endpoint_name, route.name) dup.call_route(connection, route) end end break if connection.halted? end end end end
@api private
# File lib/pakyow/routing/controller.rb, line 269 def call_route(connection, route) @connection, @route = connection, route @route.pipeline.callable(self).call(connection); halt rescue StandardError => error @connection.logger.houston(error) handle_error(error) end
@api private
# File lib/pakyow/routing/controller.rb, line 278 def dispatch halted = false performing :dispatch do halted = catch :halt do @route.call(self) end end # Catching the halt then re-halting lets us call after dispatch hooks in non-error cases. # halt if halted end
Halts request processing, immediately returning the response.
The response body will be set to body
prior to halting (if it's a non-nil value).
# File lib/pakyow/routing/controller.rb, line 456 def halt(body = nil, status: nil) @connection.body = body if body @connection.status = Connection::Statuses.code(status) if status @connection.halt end
Redirects to location
and immediately halts request processing.
@param location [String] what url the request should be redirected to @param as [Integer, Symbol] the status to redirect with @param trusted [Boolean] whether or not the location is trusted
@example Redirecting:
Pakyow::Application.controller do default do redirect "/foo" end end
@example Redirecting with a status code:
Pakyow::Application.controller do default do redirect "/foo", as: 301 end end
@example Redirecting to a remote location:
Pakyow::Application.controller do default do redirect "http://foo.com/bar", trusted: true end end
# File lib/pakyow/routing/controller.rb, line 318 def redirect(location, as: 302, trusted: false, **params) location = case location when Symbol app.endpoints.path(location, **params) else location end if trusted || URI(location).host.nil? @connection.status = Connection::Statuses.code(as) @connection.set_header("location", location) halt else raise Security::InsecureRedirect.new_with_message( location: location ) end end
Rejects the request, calling the next matching route.
# File lib/pakyow/routing/controller.rb, line 464 def reject throw :reject end
Reroutes the request to a different location. Instead of an http redirect, the request will continued to be handled in the current request lifecycle.
@param location [String] what url the request should be rerouted to @param method [Symbol] the http method to reroute as
@example
Pakyow::Application.resource :posts, "/posts" do edit do @post ||= find_post_by_id(params[:post_id]) # render the form for @post end update do if post_fails_to_create @post = failed_post_object reroute path(:posts_edit, post_id: @post.id), method: :get end end end
# File lib/pakyow/routing/controller.rb, line 359 def reroute(location, method: connection.method, as: nil, **params) connection = @connection.__getobj__ # Make sure the endpoint is set. # connection.endpoint connection.instance_variable_set(:@method, method) connection.instance_variable_set(:@path, location.is_a?(Symbol) ? app.endpoints.path(location, **params) : location) # Change the response status, if set. # connection.status = Connection::Statuses.code(as) if as @connection.set(:__endpoint_path, nil) @connection.set(:__endpoint_name, nil) @connection.remove_instance_variable(:@path) app.perform(@connection); halt end
Responds to a specific request format.
The Content-Type
header will be set on the response based on the format that is being responded to.
After yielding, request processing will be halted.
@example
Pakyow::Application.controller do get "/foo.txt|html" do respond_to :txt do send "foo" end # do something for html format end end
# File lib/pakyow/routing/controller.rb, line 398 def respond_to(format) return unless @connection.format == format.to_sym @connection.format = format yield halt end
Sends a file or other data in the response.
Accepts data as a String
or IO
object. When passed a File
object, the mime type will be determined automatically. The type can be set explicitly with the type
option.
Passing name
sets the Content-Disposition
header to “attachment”. Otherwise, the disposition will be set to “inline”.
@example Sending data:
Pakyow::Application.controller do default do send "foo", type: "text/plain" end end
@example Sending a file:
Pakyow::Application.controller do default do filename = "foo.txt" send File.open(filename), name: filename end end
# File lib/pakyow/routing/controller.rb, line 430 def send(file_or_data, type: nil, name: nil) if file_or_data.is_a?(IO) || file_or_data.is_a?(StringIO) data = file_or_data if file_or_data.is_a?(File) @connection.set_header("content-length", file_or_data.size) type ||= MiniMime.lookup_by_filename(file_or_data.path)&.content_type.to_s end @connection.set_header("content-type", type || DEFAULT_SEND_TYPE) elsif file_or_data.is_a?(String) @connection.set_header("content-length", file_or_data.bytesize) @connection.set_header("content-type", type) if type data = StringIO.new(file_or_data) else raise ArgumentError, "expected an IO or String object" end @connection.set_header(CONTENT_DISPOSITION, name ? "attachment; filename=#{name}" : "inline") halt(data) end