lib/agouti/rack/package_limiter.rb in agouti-0.0.1 vs lib/agouti/rack/package_limiter.rb in agouti-0.0.2

- old
+ new

@@ -1,82 +1,131 @@ -module Agouti +require 'rack' +module Agouti module Rack + # Public: rack middleware that truncates the gzipped response. + # Useful for testing critical rendering path optimization. class PackageLimiter ENABLE_HEADER = 'X-Agouti-Enable' LIMIT_HEADER = 'X-Agouti-Limit' + # Public: Default limit of bytes. DEFAULT_LIMIT = 14000 - ## - # Creates Agouti::Rack::PackageLimiter middleware. + # Public: Constructor. # - # [app] rack app instance - # [options] hash of package limiter options, i.e. - # 'if' - a lambda enabling / disabling deflation based on returned boolean value - # e.g use Rack::Deflater, :if => lambda { |env, status, headers, body| body.length > 512 } - def initialize(app, options = {}) + # app - rack app instance + # + # Returns an instance of Agouti::Rack::PackageLimiter middleware. + def initialize(app) @app = app - @condition = options[:if] - @limit = options[:limit] || 14000 end - def get_http_header env, header - env["HTTP_#{header.upcase.gsub('-', '_')}"] - end - + # Public: Apply middleware to request. + # + # env - environment. + # + # Raises Agouti::Rack::PackageLimiter::InvalidHeaderException if headers are not valid. The following values are accepted: + # X-Agouti-Enable: + # - header not present or set with value 0(disabled). + # - header set with value 1 (enabled). + # X-Agouti-Limit: a positive integer. + # + # The response body is gzipped only when the following conditions are met: + # Header X-Agouti-Enable set with value 1 and header Content-Type with value 'text/html'. + # If header X-Agouti-Limit is set, response body will be truncated to the given number of bytes. + # Otherwise, body will be truncated to the default limit, which is 14000 bytes. + # + # If header X-Agouti-Enable is enabled but header Content-Type does not have value 'text/html', + # the middleware will return a response with status code 204 and empty body. + # + # If header X-Agouti-Enable has value 0 or is empty, the response will not be modified. def call(env) + raise InvalidHeaderException unless valid?(env) + status, headers, body = @app.call(env) set_limit(env) if enabled?(env) - # Just execute for html - # TODO: find a better way of doing it - unless (headers.has_key? 'Content-Type' and headers['Content-Type'].include? 'text/html') - # Returns empty responses for requests that are not html + unless headers['Content-Type'] == 'text/html' return [204, {}, []] end headers = ::Rack::Utils::HeaderHash.new(headers) - headers['Content-Encoding'] = "gzip" + headers['Content-Encoding'] = 'gzip' headers.delete('Content-Length') - mtime = headers.key?("Last-Modified") ? Time.httpdate(headers["Last-Modified"]) : Time.now + mtime = headers.key?('Last-Modified') ? Time.httpdate(headers['Last-Modified']) : Time.now [status, headers, GzipTruncatedStream.new(body, mtime, @limit)] else [status, headers, body] end end private + def get_http_header env, header + env["HTTP_#{header.upcase.gsub('-', '_')}"] + end + def enabled? env - get_http_header(env, ENABLE_HEADER) and get_http_header(env, ENABLE_HEADER) == '1' + get_http_header(env, ENABLE_HEADER) && get_http_header(env, ENABLE_HEADER) == 1 end def set_limit env - @limit = (get_http_header(env, LIMIT_HEADER)) ? get_http_header(env, LIMIT_HEADER).to_i : DEFAULT_LIMIT + @limit = (get_http_header(env, LIMIT_HEADER)) ? get_http_header(env, LIMIT_HEADER).to_i : DEFAULT_LIMIT end + def valid_enable_header? env + header = get_http_header(env, ENABLE_HEADER) + + (0..1).include?(header) || header.nil? + end + + def valid_limit_header? env + header = get_http_header(env, LIMIT_HEADER) + + (header.kind_of?(Integer) && header > 0) || header.nil? + end + + def valid? env + valid_enable_header?(env) && valid_limit_header?(env) + end + + # Public: class responsible for truncating the gzip stream to a given number of bytes. class GzipTruncatedStream < ::Rack::Deflater::GzipStream + # Public: Constructor. + # + # body - response body. + # mtime - last-modified time. + # byte_limit - byte limit. + # + # Returns an instance of Agouti::Rack::PackageLimiter::GzipTruncatedStream. def initialize body, mtime, byte_limit super body, mtime @byte_limit = byte_limit @total_sent_bytes = 0 end + # Public: Writes data to stream. + # + # data - data. + # + # If total sent bytes reaches bytes limit, data is sliced. def write(data) - # slices data if total sent bytes reaches byte limit if @total_sent_bytes + data.bytesize > @byte_limit data = data.byteslice(0, @byte_limit - @total_sent_bytes) end @total_sent_bytes += data.bytesize @writer.call(data) end end + + # Public: custom exception class for invalid headers. + class InvalidHeaderException < Exception; end; end end -end \ No newline at end of file +end