# Kiss - A web application framework for Ruby # Copyright (C) 2005-2010 MultiWidget LLC. # See LICENSE for details. # Author:: Shawn Van Ittersum, MultiWidget LLC # Copyright:: Copyright (c) 2005-2010 MultiWidget LLC. # License:: MIT X11 License require 'rubygems' require 'yaml' require 'rack' require 'rack/request' require 'erubis' # Sequel may not be required anymore for string inflectors (singular, plural, titlecase, etc.), # if database functionality is not used, since inflectors are now in sequel/extensions/inflector. require 'sequel' require 'sequel/extensions/inflector' require 'tzinfo' require 'kiss/ext/core' require 'kiss/ext/rack' require 'kiss/accessors/controller' require 'kiss/accessors/request' require 'kiss/accessors/template' require 'kiss/action' # Kiss - An MVC web application framework for Ruby, built on: # * Erubis template engine # * Sequel database ORM library # * Rack web server abstraction class Kiss autoload :Bench, 'kiss/bench' autoload :Debug, 'kiss/debug' autoload :ExceptionReport, 'kiss/exception_report' autoload :Form, 'kiss/form' autoload :Format, 'kiss/format' autoload :Iterator, 'kiss/iterator' autoload :Login, 'kiss/login' autoload :Mailer, 'kiss/mailer' autoload :Request, 'kiss/request' autoload :SequelDatabase, 'kiss/ext/sequel_database' autoload :SequelMySQLDataset, 'kiss/ext/sequel_mysql_dataset' autoload :SequelSession, 'kiss/sequel_session' autoload :StaticFile, 'kiss/static_file' autoload :Template, 'kiss/template' # Exceptions classes. class FileNotFoundError < RuntimeError class InvalidAction < self; end class Action < self; end class Template < self; end class Page < self; end class Object < self; end end # These supplement the MIME types defined by Rack. MIME_TYPES = { 'rhtml' => 'text/html' } @@default_action = 'index' @@default_cookie_name = 'Kiss' @@default_project_dir = '.' cattr_accessor :default_project_dir # application-wide attributes _attr_reader :action_dir, :template_dir, :email_template_dir, :model_dir, :upload_dir, :evolution_dir, :asset_host, :asset_uri, :asset_dir, :public_dir, :environment, :rack_file, :default_action, :exception_log_file, :session_class, :cookie_name, :authenticate_all, :authenticate_exclude, :exception_handlers, :project_dir, :config, :mailer_config, :mailer_override, :exception_mailer_config _attr_accessor :session_setup # Registry of classes by file path. @_classes = {} ### Class Methods class << self alias_method :load, :new def rack(config = {}) self.new(config).rack end def run(config = {}) self.new(config).run end # Converts passed-in filename to absolute path if it does not start with '/'. def absolute_path(filename) ( filename[0,1] == '/' ) ? filename : "#{Dir.pwd}/#{filename}" end # Returns MIME type corresponding to passed-in extension. def mime_type(extension) extension = extension.to_s rack_mime_types = Rack::Mime::MIME_TYPES rescue Rack::File::MIME_TYPES rack_mime_types[extension] || rack_mime_types['.' + extension] || Kiss::MIME_TYPES[extension] end # Register a class by its file path, to enable context_class to work. def register_class_path(klass, path) @_classes[path] = klass end # Finds the class defined by the file path of the execution context. def context_class caller.each do |frame| if klass = @_classes[frame.sub(/\:.*/, '')] return klass end end end end # class methods # Creates a new application controller instance, and also configures the # application from config file options and any passed-in options. def initialize(options = {}) # init config @_config = { :layout => '/_layout', :file_cache_reload => true } @_lib_dirs = ['lib'] @_gem_dirs = ['gems'] @_require = [] @_authenticate_exclude = ['/login', '/logout'] @_mailer_config = {} @_mailer_override = {} @_exception_handlers = {} @_exception_mailer_config = {} # store for cached files and directories @_file_cache = {} @_directory_cache = {} @_file_cache_time = {} # If options is string, then it specifies an environment # (else it should be a hash of config options) options = { :environment => options } if options.is_a?(String) # project dir # all other files and directories are relative to the project dir Dir.chdir(options[:project_dir] || options[:root_dir] || @@default_project_dir) # save current path to force return there in case an action changes directory @_project_dir = Dir.pwd # directory containing the config files @_config_dir = options[:config_dir] || 'config' # get environment name from options or config/environment @_environment = options[:environment] || if File.file?(env_file = @_config_dir + '/environment') File.read(env_file).sub(/\s+\Z/, '') end # read common (shared) config merge_config_file(@_config_dir + '/common.yml') # read environment config merge_config_file(@_config_dir + "/environments/#{@_environment}.yml") if @_environment # merge options passed in to override config files merge_config( options ) # set app instance variables from config data and defaults @_action_dir = @_config[:action_dir] || 'actions' @_template_dir = (@_config[:template_dir] ? @_config[:template_dir] : @_action_dir) @_model_dir = @_config[:model_dir] || 'models' @_evolution_dir = @_config[:evolution_dir] || 'evolutions' @_asset_dir = @_public_dir = @_config[:asset_dir] || @_config[:public_dir] || 'public_html' @_email_template_dir = @_config[:email_template_dir] || 'email_templates' @_upload_dir = @_config[:upload_dir] || 'uploads' @_cookie_name = @_config[:cookie_name] || @@default_cookie_name @_default_action = @_config[:default_action] || @@default_action # exception log @_exception_log_file = @_config[:exception_log] ? ::File.open(@_config[:exception_log], 'a') : nil # authenticate all actions? @_authenticate_all = @_config[:authenticate_all] # don't require authentication on exception actions @_authenticate_exclude << @_config.exception_action if @_config.exception_action @_authenticate_exclude << @_config.file_not_found_action if @_config.file_not_found_action # app host: default hostname of application @_app_host = @_config[:app_host] # app uri: default URI of application @_app_uri = @_config[:app_uri] # asset host: hostname of static assets @_asset_host = @_config[:asset_host] # public_uri: URI of requests to serve from public_dir @_asset_uri = @_config[:asset_uri] || @_config[:public_uri] || nil @_rack_file = Rack::File.new(@_asset_dir) if @_asset_uri # add lib dirs to load path $LOAD_PATH.unshift(*( @_lib_dirs.flatten.select {|dir| File.directory?(dir) } )) # add gem dirs to rubygems search path Gem.path.unshift(*( @_gem_dirs.flatten.select {|dir| File.directory?(dir) } )) # require specified libs @_require.flatten.each {|lib| require lib } # session class @_session_class = @_config[:session_class] @_session_class = @_session_class.to_const if @_session_class && !@_session_class.is_a?(Class) # database @_database_config = @_config[:database] @_database_pool = [] self end private # TODO: Turn exception handlers into a new class; # move exception handling methods from Request to the new class def prepare_exception_handler(value) # start with defaults { :send_email => true }.merge( # add data from the handler config value if value.is_a?(Hash) value elsif value.is_a?(Array) value.hash_with_keys(:action, :send_email) elsif value.is_a?(String) { :action => value } else raise 'Invalid exception handler config setting.' end ) end def merge_config_file(filename) if File.file?(filename) merge_config( YAML::load(File.read(filename)) ) end end # Merges specified config options into previously defined/merged Kiss config. def merge_config(new_config) if new_config if env_vars = new_config.delete(:ENV) env_vars.each_pair {|k, v| ENV[k] = v } end if lib_dirs = new_config.delete(:lib_dirs) @_lib_dirs += lib_dirs end if gem_dirs = new_config.delete(:gem_dirs) @_gem_dirs += gem_dirs end if require_libs = new_config.delete(:require) @_require += require_libs end if auth_exclude = new_config.delete(:authenticate_exclude) auth_exclude = auth_exclude.map {|action| (action[0,1] == '/') ? action : '/' + action } @_authenticate_exclude += auth_exclude end if exception_handler = new_config.delete(:exception_handler) exception_handlers[Exception] = prepare_exception_handler(value) end if exception_handlers = new_config.delete(:exception_handlers) exception_handlers.each_pair do |key, value| @_exception_handlers[key == :generic ? Exception : key.to_s.to_const] = prepare_exception_handler(value) end end if mailer_config = new_config.delete(:mailer) @_mailer_override.merge!(mailer_config.delete(:override) || {}) @_mailer_config.merge!(mailer_config) end if mailer_override = new_config.delete(:mailer_override) @_mailer_override.merge!(mailer_override) end if email_errors = new_config.delete(:email_errors) @_exception_mailer_config.merge!(email_errors) end if exception_mailer = new_config.delete(:exception_mailer) @_exception_mailer_config.merge!(exception_mailer) end @_config.merge!( new_config ) end end public def rack(config = nil) merge_config(config) app = self builder_options = @_config[:rack_builder] || [] rack = Rack::Builder.new do builder_options.each do |builder_option| if builder_option.is_a?(Array) builder_args = builder_option builder_option = builder_args.shift else builder_args = [] end unless builder_option.is_a?(Class) builder_option = Rack.const_get(builder_option.to_s) end use(builder_option, *builder_args) end run app end.to_app end # Runs Kiss application found at project_dir (default: '..'), with config # read from config files plus additional options if passed in. def run(options = nil) merge_config(options) handler = @_config[:rack_handler] || Rack::Handler::WEBrick handler = Rack::Handler.const_get(handler.to_s) unless handler.is_a?(Class) handler.run(rack, @_config[:rack_handler_options] || {:Port => 4000}) end # Creates new controller instance to handle Rack request. def call(env) Kiss::Request.new(env, self, @_config).call(env) end def new_database_connection(database_config) db = Sequel.connect database_config load_db_class_extensions(db.class) db end # Acquires and returns a database connection object from the connection pool, # opening a new connection if the pool is empty. def database @_database_pool.shift || begin raise 'database config missing' unless @_database_config # open database connection db = new_database_connection(@_database_config) # create model cache for this database connection db.kiss_controller = self db.kiss_model_cache = Kiss::ModelCache.new(db, @_model_dir) db end end alias_method :db, :database def load_db_class_extensions(db_class) @_db_class_extensions_loaded ||= {} @_db_class_extensions_loaded[db_class] ||= begin db_class.class_eval { include Kiss::SequelDatabase } if db_class.name == 'Sequel::MySQL::Database' # add fetch_arrays, all_arrays methods Sequel::MySQL::Dataset.class_eval { include Kiss::SequelMySQLDataset } # turn off convert_tinyint_to_bool, unless app config says otherwise Sequel::MySQL.convert_tinyint_to_bool = false unless @_config[:convert_tinyint_to_bool] end require 'kiss/model' true end end # Kiss Model cache, used to invoke and store Kiss database models. # # Example: # models[:users] == database model for `users' table # # Tip: `dbm' (stands for `database models') is a shorthand alias for `models'. def models # make sure we have a database connection # create new model cache unless exists already db.kiss_model_cache end alias_method :dbm, :models def return_database(db) @_database_pool.push(db) end # Gets the number of the last file in the evolution dir, or 0 if the directory # does not exist. def last_evolution_file_number version = 0 if directory_exists?(@_evolution_dir) digits = 1 while ( entries = Dir.glob("#{@_evolution_dir}/#{'[0-9]' * digits}*") ).size > 0 version = entries.sort.last.sub(/.*\//, '').sub(/\D.*/, '').to_i digits += 1 end end version end # Returns an array of evolution filenames (relative to project dir) matching # evolution number specified by index. def evolution_file(index) # find files matching ev_dir/.*next_version_number files = Dir.glob("#{@_evolution_dir}/*#{index}[^0-9]*"). # make sure we have a match for ev_dir/0*next_version_number select {|f| f =~ /\/0*#{index}[_\.][^\/]*\Z/ } raise "multiple evolution files for evolution number #{index}" if files.size > 1 files[0] end # Returns true if specified path is a directory. # Always check filesystem if file_cache_reload option is set; otherwise, cache result. def directory_exists?(dir) @_config[:file_cache_reload] ? File.directory?(dir) : ( @_directory_cache.has_key?(dir) ? @_directory_cache[dir] : @_directory_cache[dir] = File.directory?(dir) ) end # TODO: Move file and directory caching to new class(es) # TODO: File cache should store hashes of file info, keyed by path, # instead of using separate hashes (cache time, contents, etc) # # TODO: FIX BUG: The file cache keeps old classes after they are removed from # the action/model class hierarchies. Will the class hierarchies force reload # these paths, or get the old cached versions? Answer: They reload the classes # because they only cache the source text in the file cache. They cache the # classes in the hierarchy. # # TODO: Need a generic class for Action/Template/Model class hierarchy caches. # # Given a file path, caches or returns the file's contents or the return value of # the passed block applied to the file's contents. # If file is not found, the file's contents are nil. def file_cache(path = nil, return_changed_state = false) return @_file_cache unless path cache_changed = false if @_file_cache.has_key?(path) # we've loaded this path before if @_config[:file_cache_reload] # check to see if there's been a change that needs to be reloaded if !File.file?(path) if @_file_cache_time[path] # file cached as existing but has been removed; update cache to show no file cache_changed = true contents = nil end elsif !@_file_cache_time[path] || @_file_cache_time[path] < File.mtime(path) || ( File.symlink?(path) && (@_file_cache_time[path] < File.lstat(path).mtime) ) # cache shows file missing, or file has been modified since cached cache_changed = true contents = File.read(path) end end else # haven't loaded this path yet cache_changed = true if !File.file?(path) # nil path, of file doesn't exist contents = nil else # file exists; mark cache time and read file contents = File.read(path) end end if cache_changed @_file_cache_time[path] = contents ? Time.now : nil @_file_cache[path] = block_given? ? yield(contents) : contents end return_changed_state ? [@_file_cache[path], cache_changed] : @_file_cache[path] end # Returns new Kiss::Mailer object using specified options. def new_email(options = {}) Kiss::Mailer.new({ :controller => self, :request => self }.merge(options)) end def send_email(options = {}) new_email(options).send end # Returns URL/URI of app root (corresponding to top level of action_dir). # Part of Kiss class to be available to kiss irb. def app_url(options = {}) # cache return values by unique options input @_app_url_cache ||= {} @_app_url_cache[options.inspect] ||= begin url_settings = { :protocol => @_protocol || 'http', :host => @_app_host, :uri => @_app_uri }.merge(options) raise 'host missing' unless url_settings[:host] "#{url_settings[:protocol]}://#{url_settings[:host]}#{url_settings[:uri]}" end end def login {} end def debug(obj, *args) gdebug obj end end