diff --git a/README.rdoc b/README.rdoc index de8ba3f..a5919da 100644 --- a/README.rdoc +++ b/README.rdoc @@ -1,9 +1,15 @@ = rack-conneg -Description goes here. +Negotiate the best static file for a request based on client preferences. + +== Usage + +Usage is the same as Rack::Static, eg: + + use Rack::Conneg::Static, :urls => %w[ /css /images ], :root => 'public' == Note on Patches/Pull Requests - + * Fork the project. * Make your feature addition or bug fix. * Add tests for it. This is important so I don't break it in a diff --git a/Rakefile b/Rakefile index 84d7c87..9dedddd 100644 --- a/Rakefile +++ b/Rakefile @@ -9,8 +9,8 @@ begin Jeweler::Tasks.new do |gem| gem.name = 'rack-conneg' - gem.summary = %Q{TODO: one-line summary of your gem} - gem.description = %Q{TODO: longer description of your gem} + gem.summary = 'Rack middleware for static file content negotiation' + gem.description = 'Negotiate the best static file for a request based on client preferences' gem.email = 'dan.kubb@gmail.com' gem.homepage = 'http://github.com/dkubb/rack-conneg' gem.authors = [ 'Dan Kubb' ] diff --git a/lib/rack/conneg/file.rb b/lib/rack/conneg/file.rb index e8800a1..3de183c 100644 --- a/lib/rack/conneg/file.rb +++ b/lib/rack/conneg/file.rb @@ -1,10 +1,29 @@ module Rack class Conneg class File + + # Initialize a new File + # + # @param [#call] app + # the Rack app to handle static requests + # + # @return [File] + # returns a new File instance + # + # @api private def initialize(file_server) @file_server = file_server end + # Negotiate a response using the request headers + # + # @param [Hash] env + # the Rack request environment + # + # @return [Array(Integer, Hash, #each)] + # returns a Rack response + # + # @api private def call(env) Negotiator.negotiate(@file_server, env) end diff --git a/lib/rack/conneg/negotiator.rb b/lib/rack/conneg/negotiator.rb index 243f3b6..384f746 100644 --- a/lib/rack/conneg/negotiator.rb +++ b/lib/rack/conneg/negotiator.rb @@ -7,56 +7,142 @@ class Negotiator "406 Not Acceptable\n", ].freeze + # Negotiate a response using the request headers + # + # @param [#call] app + # the Rack app to handle static requests + # @param [Hash] env + # the Rack request environment + # + # @return [Array(Integer, Hash, #each)] + # returns a Rack response + # + # @api private def self.negotiate(app, env) negotiator = new(app, env) negotiator.pass? ? negotiator.pass : negotiator.call end + # Initialize a new Negotiator + # + # @param [#call] app + # the Rack app to handle static requests + # @param [Hash] env + # the Rack request environment + # + # @return [Negotiator] + # returns a new Negotiator instance + # + # @api private def initialize(app, env) @app = app - @env = env - @request = Acceptable::Request.new(@env) + @request = Acceptable::Request.new(env) @path = Path.new(app.root, @request.path_info) end + # Test if the request should be passed to the next Rack app + # + # @return [Boolean] + # true if the request should be passed through + # + # @api private def pass? not @path.variants? end - def pass(env = @env) + # The response from the next Rack app + # + # @param [Hash] env + # optional Rack request environment + # + # @return [Array(Integer, Hash, #each)] + # returns a Rack response + # + # @api private + def pass(env = @request.env) @app.call(env) end + # Negotiate the optimal response based on client preferences + # + # @return [Array(Integer, Hash, #each)] + # returns a negotiated Rack response + # + # @api private def call variant = select_variant - response = variant ? serve_variant(variant.path_info) : NOT_ACCEPTABLE + response = variant ? serve_path(variant) : NOT_ACCEPTABLE self.class.append_vary_header(response[1], 'Accept') response end private + # Select the best variant based on client preferences + # + # @return [Path, nil] + # the negotiated variant, nil if none available + # + # @api private def select_variant variants = @path.variants variants[@request.preferred_media_from(*variants.keys)] end - def serve_variant(path_info) - status, headers, body = pass(@env.merge('PATH_INFO' => path_info)) + # Serve the path to the client + # + # @param [Path] path + # the path to serve + # + # @return [Array(Integer, Hash, #each)] + # returns a negotiated Rack response + # + # @api private + def serve_path(path) + path_info = path.path_info + status, headers, body = pass(@request.env.merge('PATH_INFO' => path_info)) headers['Content-Location'] = path_info if (200..299).include?(status) [ status, headers, body ] end + # Append client header names used to negotiate the response to Vary + # + # @param [Hash] headers + # the Rack response headers + # @param [Array] *names + # the client headers used to negotiate the response + # + # @return [undefined] + # + # @api private def self.append_vary_header(headers, *names) vary = split_header(headers['Vary']) return if vary.include?('*') headers['Vary'] = join_header(vary | names) end + # Split the header value into an Array + # + # @param [#to_s] header + # the header value to split + # + # @return [Array] + # the header values + # + # @api private def self.split_header(header) header.to_s.delete(' ').split(',') end + # Join the header values into a String + # + # @param [Array] values + # the header values to join + # + # @return [String] + # the header value + # + # @api private def self.join_header(values) values.join(',') end diff --git a/lib/rack/conneg/path.rb b/lib/rack/conneg/path.rb index 7ea1cd7..827f644 100644 --- a/lib/rack/conneg/path.rb +++ b/lib/rack/conneg/path.rb @@ -5,26 +5,57 @@ class Conneg class Path EXTENSION_REGEXP = Regexp.union(*Mime::MIME_TYPES.keys.map { |ext| ext[1..-1] }).freeze + # Return the request path + # + # @return [String] + # the request path + # + # @api private attr_reader :path_info + # Initialize a new Path + # + # @param [String, Pathname] root + # the server document root + # @param [String] path_info + # the request path + # + # @return [Path] + # returns a new Path instance + # + # @api private def initialize(root, path_info) @root = Pathname(root) @path_info = self.class.normalize_path_info(path_info) @path = @root + @path_info[1..-1] end + # Return the mime type for the request path + # + # @return [String, nil] + # the mime type if known + # + # @api private def mime_type Mime.mime_type(extname, nil) end - def exist? - @path.exist? - end - + # Test if there are variants for the request path + # + # @return [Boolean] + # true if there are variants + # + # @api private def variants? variants.any? end + # Return the Hash of media types and variants + # + # @return [Hash] + # the variants for the request path + # + # @api private def variants return @variants if @variants variants = {} @@ -34,34 +65,79 @@ def variants private + # Return the variant paths + # + # @return [Array] + # the variant paths + # + # @api private def variant_paths - paths.select { |path| variant?(path) && path.exist? } - rescue SystemCallError - [] + paths.select { |path| variant?(path) } end + # Return all the paths in the request path directory + # + # @return [Array] + # the paths in the current directory + # + # @api private def paths directory.map do |path| self.class.new(@root, path.relative_path_from(@root)) end end + # Return all the children in the request path directory + # + # @return [Array] + # the children in the current directory + # + # @api private def directory @path.dirname.children + rescue SystemCallError + [] end + # Return the request path extension + # + # @return [String, nil] + # the request path extension + # + # @api private def extname @path.extname end + # Test if the other Path is a variant + # + # @param [Path] other + # the other Path to test + # + # @return [Boolean] + # true if the other Path is a variant + # + # @api private def variant?(other) pattern === other.path_info end + # Return a Regexp to match the request path and known extensions + # + # @return [Regexp] + # match the request path + # + # @api private def pattern @pattern ||= /\A#{Regexp.escape(path_info)}\.#{EXTENSION_REGEXP}\z/.freeze end + # Normalize the request path info + # + # @return [String] + # the request path + # + # @api private def self.normalize_path_info(path_info) "/#{Utils.unescape(path_info.to_s).gsub(/\A\//, '')}" end diff --git a/lib/rack/conneg/static.rb b/lib/rack/conneg/static.rb index 432139b..cc02cf2 100644 --- a/lib/rack/conneg/static.rb +++ b/lib/rack/conneg/static.rb @@ -1,7 +1,22 @@ module Rack class Conneg class Static < Rack::Static - def initialize(*) + + # Initialize middleware for static file content negotiation + # + # @example + # use Rack::Conneg::Static, :urls => %w[ /css /images ], :root => 'public' + # + # @param [#call] app + # the Rack app to handle static requests + # @param [Hash] options + # optional middleware configuration + # + # @return [Static] + # returns a new Static instance + # + # @api public + def initialize(app, options = {}) super @file_server = File.new(@file_server) end