module Nyara
Controller = Struct.new :request
class Controller
module ClassMethods
# #### Call-seq
#
# http :get, '/' do
# send_string 'hello world'
# end
#
def http method, path, &blk
# special treatment: '/' also maps ''
if path == '/'
http method, '', &blk
end
@routes ||= []
@used_ids = {}
method = method.to_s.upcase
action = Route.new
unless action.http_method = HTTP_METHODS[method]
raise ArgumentError, "missing http method: #{method.inspect}"
end
action.path = path
action.set_accept_exts @formats
action.id = @curr_id if @curr_id
action.classes = @curr_classes if @curr_classes
# todo validate arity of blk (before filters also needs arity validation)
action.blk = blk
@routes << action
if @curr_id
raise ArgumentError, "action id #{@curr_id} already in use" if @used_ids[@curr_id]
@used_ids[@curr_id] = true
@curr_id = nil
@meta_exist = nil
end
@formats = nil
end
# Set meta data for next action
def meta tag=nil, opts=nil
if @meta_exist
raise 'contiguous meta data descriptors, should be followed by an action'
end
if tag.nil? and opts.nil?
raise ArgumentError, 'expect tag or options'
end
if opts.nil? and tag.is_a?(Hash)
opts = tag
tag = nil
end
if tag
selectors = tag.scan(/[\#\.]\w++(?:\-\w++)*/).to_a
@curr_id = selectors.find{|s| s.start_with?('#') }
@curr_id = @curr_id.to_sym if @curr_id
@curr_classes = selectors.select{|s| s.start_with?('.') }
end
if opts
# todo add opts: strong param, etag, cache-control
@formats = opts[:formats]
end
@meta_exist = true
end
eval %w[GET POST PUT DELETE PATCH OPTIONS].map{|meth|
<<-RUBY
def #{meth.downcase} path, &blk
http '#{meth}', path, &blk
end
RUBY
}.join "\n"
# Add *before* processor, invoke order is the same as definition order
#
# #### Call-seq
#
# before '.foo', '.bar:post', ':get' do
# require_login
# end
#
def before *selectors, &p
raise ArgumentError, "need a block" unless p
@before_filters ||= {}
selectors.each do |selector|
selector = Route.canonicalize_callback_selector selector
(@before_filters[selector] ||= []) << p
end
end
# Set default layout
def set_default_layout l
@default_layout = l
end
attr_reader :default_layout
# Set controller name, so you can use a shorter name to reference the controller in path helper
def set_controller_name n
@controller_name = n
end
attr_reader :controller_name
# @private
def nyara_compile_routes scope # :nodoc:
raise "#{self}: no action defined" unless @routes
curr_id = :'#0'
next_id = proc{
while @used_ids[curr_id]
curr_id = curr_id.succ
end
@used_ids[curr_id] = true
curr_id
}
next_id[]
@path_templates = {}
@routes.each do |e|
e.id = next_id[] if e.id.empty?
before_actions = e.matched_lifecycle_callbacks @before_filters
senders = []
before_actions.each_with_index do |blk, idx|
method_name = "#{e.id}\##{idx}"
senders << "send #{method_name.inspect}\n"
define_method method_name, &blk
end
method_name = "#{e.id}\##{before_actions.size}"
senders << "send #{method_name.inspect}, *xs\n"
define_method method_name, e.blk
class_eval <<-RUBY
def __nyara_tmp_action *xs
#{senders.join}
end
alias :#{e.id.inspect} __nyara_tmp_action
undef __nyara_tmp_action
RUBY
e.compile self, scope
e.validate
@path_templates[e.id] = [e.path_template, e.http_method_override]
end
@routes
end
attr_accessor :path_templates
end
def self.inherited klass
# note: klass will also have this inherited method
unless klass.name.end_with?('Controller')
raise "class #{klass.name} < Nyara::Controller -- class name must end with `Controller`"
end
klass.extend ClassMethods
[:@used_ids, :@default_layout, :@before_filters, :@routes].each do |iv|
if value = klass.superclass.instance_variable_get(iv)
if value.is_a? Array
value = value.map &:dup
end
klass.instance_variable_set iv, value.dup
end
end
end
def self.process_reload request, l
if request.http_method == 'POST' and request.path =~ /\A\/reload:([\w-]+)\z/
ty = $1
files = request.param['files']
case ty
when 'views-modified'
files.each do |f|
if l
l.info "modified: #{f}"
end
View.on_removed f
View.on_modified f
end
when 'views-removed'
files.each do |f|
if l
l.info "removed: #{f}"
end
View.on_removed f
end
when 'app-modified'
files.each do |f|
if l
l.info "modified: #{f}"
end
Reload.load_file f
end
else
return false
end
true
end
end
def self.dispatch request, instance, args
if cookie_str = request.header._aref('Cookie')
ParamHash.parse_cookie request.cookie, cookie_str
end
request.flash = Flash.new(
request.session = Session.decode(request.cookie)
)
l = Nyara.logger
if instance
if l
l.info "#{request.http_method} #{request.path} => #{instance.class}"
if %W"POST PUT PATCH".include?(request.http_method)
l.info " params: #{instance.params.inspect}"
end
end
instance.send *args
return
elsif request.http_method == 'GET' and Config['public']
path = Config.public_path request.path
if File.file?(path)
if l
l.info "GET #{request.path} => public 200"
end
instance = Controller.new request
instance.send_file path
return
end
elsif Config.development?
if process_reload(request, l)
Ext.request_send_data request, "HTTP/1.1 200 OK\r\n\r\n"
return
end
end
if l
l.info "#{request.http_method} #{request.path} => 404"
end
Ext.request_send_data request, "HTTP/1.1 404 Not Found\r\nConnection: close\r\nContent-Length: 0\r\n\r\n"
Fiber.yield :term_close
rescue Exception
instance.handle_error($!) if instance
end
# Path helper
def path_to id, *args
if args.last.is_a?(Hash)
opts = args.pop
end
template, meth = self.class.path_templates[id.to_s]
if template.blank? && meth.blank?
raise ArgumentError, "#{id} route not found."
end
r = template % args
if opts
format = opts.delete :format
r << ".#{format}" if format
if meth and !opts.key?(:_method) and !opts.key?('_method')
opts['_method'] = meth
end
elsif meth
opts = {'_method' => meth}
end
if opts
r << '?' << opts.to_query unless opts.empty?
end
r
end
# Url helper
# NOTE: host string can include port number
# TODO: user and password?
def url_to id, *args, scheme: nil, host: nil, **opts
scheme = scheme ? scheme.sub(/\:?$/, '://') : '//'
host ||= request.host_with_port
path = path_to id, *args, opts
scheme << host << path
end
# Redirect to a url or path, terminates action
# `status` can be one of:
#
# - 300 - multiple choices (e.g. offer different languages)
# - 301 - moved permanently
# - 302 - found (default)
# - 303 - see other (e.g. for results of cgi-scripts)
# - 307 - temporary redirect
#
# Caveats: there's no content in a redirect response yet, if you want one, you can configure nginx to add it
def redirect url_or_path, status=302
status = status.to_i
raise "unsupported redirect status: #{status}" unless HTTP_REDIRECT_STATUS.include?(status)
r = request
header = r.response_header
self.status status
uri = URI.parse url_or_path
if uri.host.nil?
uri.host = request.domain
uri.port = request.port
end
uri.scheme = r.ssl? ? 'https' : 'http'
header['Location'] = uri.to_s
# similar to send_header, but without content-type
Ext.request_send_data r, HTTP_STATUS_FIRST_LINES[r.status]
data = header.serialize
data.concat r.response_header_extra_lines
data << Session.encode_set_cookie(r.session, r.ssl?)
data << "\r\n"
Ext.request_send_data r, data.join
Fiber.yield :term_close
end
# Shortcut for `redirect url_to *xs`
def redirect_to identifier, *xs
if identifier !~ /\A\w*#\w++(?:\-\w++)*\z/
raise ArgumentError, "not action identifier: #{identifier.inspect}, did you mean `redirect`?"
end
redirect url_to(identifier, *xs)
end
# Stop processing and close connection
# Calling `halt` closes the connection at once, you may usually need to set status code and send header before halt.
#
# #### Example
#
# status 500
# send_header
# halt
#
def halt
Fiber.yield :term_close
end
# Request extension or generated by `Accept`
def format
request.format
end
# Request header
# NOTE to change response header, use `set_header`
def header
request.header
end
alias headers header
# Set response header
def set_header field, value
request.response_header[field] = value
end
# Append an extra line in reponse header
#
# #### Call-seq
#
# add_header_line "X-Myheader: here we are"
#
def add_header_line h
raise 'can not modify sent header' if request.response_header.frozen?
h = h.sub /(?
# NOTE: often you should call send_header before doing this.
def send_data data
Ext.request_send_data request, data.to_s
end
# Send a data chunk, it can send_header first if header is not sent.
#
# #### Call-seq
#
# send_chunk 'hello world!'
#
def send_chunk data
send_header unless request.response_header.frozen?
Ext.request_send_chunk request, data.to_s
end
alias send_string send_chunk
# Set aproppriate headers and send the file
#
# #### Call-seq
#
# send_file '/home/www/no-virus-inside.exe', disposition: 'attachment'
#
# #### Options
#
# * `disposition` - `'inline'` by default, if set to `'attachment'`, the file is presented as a download item in browser.
# * `x_send_file` - if not false/nil, it is considered to be behind a web server.
# Then the app sends file with only header configures,
# which proxies the actual action to the web server,
# which can take the advantage of system calls and reduce transfered data,
# thus faster.
# * `filename` - name for the downloaded file, will use basename of `file` if not set.
# * `content_type` - defaults to the MIME type matching `file` or `filename`.
#
# To configure for lighttpd and apache2 mod_xsendfile (https://tn123.org/mod_xsendfile/):
#
# configure do
# set :x_send_file, 'X-Sendfile'
# end
#
# To configure for nginx (http://wiki.nginx.org/XSendfile):
#
# configure do
# set :x_send_file, 'X-Accel-Redirect'
# end
#
# To disable `x_send_file` while it is enabled globally:
#
# send_file '/some/file', x_send_file: false
#
# To enable `x_send_file` while it is disabled globally:
#
# send_file '/some/file', x_send_file: 'X-Sendfile'
#
def send_file file, disposition: 'inline', x_send_file: Config['x_send_file'], filename: nil, content_type: nil
header = request.response_header
unless header['Content-Type']
unless content_type
extname = File.extname(file)
extname = File.extname(filename) if extname.blank? and filename
extname.gsub!(".","")
content_type = MIME_TYPES[extname] || 'application/octet-stream'
end
header['Content-Type'] = content_type
end
disposition = disposition.to_s
if disposition != 'inline'
if disposition != 'attachment'
raise ArgumentError, "disposition should be inline or attachment, but got #{disposition.inspect}"
end
end
filename ||= File.basename file
header['Content-Disposition'] = "#{disposition}; filename=#{Ext.escape filename, true}"
header['Transfer-Encoding'] = '' # delete it
if x_send_file
header[x_send_file] = file # todo escape name?
send_header unless request.response_header.frozen?
else
# todo nonblock read file?
data = File.binread file
header['Content-Length'] = data.bytesize
send_header unless request.response_header.frozen?
Ext.request_send_data request, data
end
Fiber.yield :term_close
end
# Resume action after `seconds`
def sleep seconds
seconds = seconds.to_f
raise ArgumentError, 'bad sleep seconds' if seconds < 0
# NOTE request_wake requires request as param, so this method can not be generalized to Fiber.sleep
Ext.request_sleep request # place sleep actions before wake
Thread.new do
Kernel.sleep seconds
Ext.request_wakeup request
end
Fiber.yield :sleep # see event.c for the handler
end
# Render a template as string
def partial view_path, locals: nil
view = View.new self, view_path, nil, locals, {}
view.partial
end
# One shot render, and terminate the action.
#
# #### Call-seq
#
# # render a template, engine determined by extension
# render 'user/index', locals: {}
#
# # with template source, set content type to +text/html+ if not given
# render erb: "<%= 1 + 1 %>"
#
# # layout can be string or array
# render 'index', ['inner_layout', 'outer_layout']
#
# For steam rendering, see #stream
def render view_path=nil, layout: self.class.default_layout, locals: nil, **opts
view = View.new self, view_path, layout, locals, opts
unless request.response_header.frozen?
send_header view.deduced_content_type
end
view.render
end
# Stream rendering
#
# #### Call-seq
#
# view = stream erb: "<% 5.times do |i| %>i<% Fiber.yield %><% end %>"
# view.resume # sends "0"
# view.resume # sends "1"
# view.resume # sends "2"
# view.end # sends "34" and closes connection
#
def stream view_path=nil, layout: self.class.default_layout, locals: nil, **opts
view = View.new self, view_path, layout, locals, opts
unless request.response_header.frozen?
send_header view.deduced_content_type
end
view.stream
end
# Handle error, the default is just log it.
# You may custom your error handler by re-defining `handle_error`.
# But remember if this fails, the whole program exits.
#
# #### Customization Example
#
# def handle_error e
# case e
# when ActiveRecord::RecordNotFound
# # if we are lucky that header has not been sent yet
# # we can manage to change response status
# status 404
# send_header rescue nil
# else
# super
# end
# end
#
def handle_error e
if l = Nyara.logger
l.error "#{e.class}: #{e.message}"
l.error e.backtrace.join "\n"
end
status 500
send_header rescue nil
# todo send body without Fiber.yield :term_close
end
end
end