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

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

matcher[R]

@api private

parent[RW]

@api private

path[R]

@api private

Public Class Methods

Controller(matcher) click to toggle source

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
action(name, only: [], skip: [], &block) click to toggle source
Calls superclass method
# 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
default(&block) click to toggle source

Create a default route. Shorthand for +get “/”+.

@see get

# File lib/pakyow/routing/controller.rb, line 538
def default(&block)
  get :default, "/", &block
end
endpoints() click to toggle source
# 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
expand(name, *args, **options, &block) click to toggle source

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
expand_within(name, **options, &block) click to toggle source

@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
group(name = nil, **kwargs, &block) click to toggle source

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
make(*args, **kwargs, &block) click to toggle source

@api private

Calls superclass method
# 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
make_child(*args, **kwargs, &block) click to toggle source

@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
method_missing(name, *args, &block) click to toggle source

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
Calls superclass method
# 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
name_of_self() click to toggle source
# 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
namespace(*args, **kwargs, &block) click to toggle source

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
new(app) click to toggle source

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
path_to_self() click to toggle source
# 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
respond_to_missing?(method_name, include_private = false) click to toggle source
Calls superclass method
# File lib/pakyow/routing/controller.rb, line 740
def respond_to_missing?(method_name, include_private = false)
  templates.include?(method_name) || super
end
skip(name, only: []) click to toggle source
# 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
template(name, &template_block) click to toggle source

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
use_pipeline(*) click to toggle source
Calls superclass method
# File lib/pakyow/routing/controller.rb, line 515
def use_pipeline(*)
  super

  @limit_by_route = {}
end

Private Class Methods

build_route(method, *args, &block) click to toggle source
# 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
finalize_matcher(matcher) click to toggle source
# 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
parse_name_and_matcher_from_args(name_or_matcher = nil, matcher_or_name = nil) click to toggle source
# 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
path_from_matcher(matcher) click to toggle source
# 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

call(connection, request_path = connection.path) click to toggle source
# 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
call_route(connection, route) click to toggle source

@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
dispatch() click to toggle source

@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
halt(body = nil, status: nil) click to toggle source

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
redirect(location, as: 302, trusted: false, **params) click to toggle source

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
reject() click to toggle source

Rejects the request, calling the next matching route.

# File lib/pakyow/routing/controller.rb, line 464
def reject
  throw :reject
end
reroute(location, method: connection.method, as: nil, **params) click to toggle source

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
respond_to(format) { || ... } click to toggle source

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
send(file_or_data, type: nil, name: nil) click to toggle source

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