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