lib/rack/linkeddata/conneg.rb in rack-linkeddata-2.2.3 vs lib/rack/linkeddata/conneg.rb in rack-linkeddata-3.0.0

- old
+ new

@@ -15,10 +15,11 @@ # use Rack::LinkedData::ContentNegotation, :format => :ttl # use Rack::LinkedData::ContentNegotiation, :format => RDF::NTriples::Format # use Rack::LinkedData::ContentNegotiation, :default => 'application/rdf+xml' # # @see http://www4.wiwiss.fu-berlin.de/bizer/pub/LinkedDataTutorial/ + # @see https://www.rubydoc.info/github/rack/rack/master/file/SPEC class ContentNegotiation DEFAULT_CONTENT_TYPE = "application/n-triples" # N-Triples VARY = {'Vary' => 'Accept'}.freeze # @return [#call] @@ -43,11 +44,11 @@ # Parses Accept header to find appropriate mime-type and sets content_type accordingly. # # Inserts ordered content types into the environment as `ORDERED_CONTENT_TYPES` if an Accept header is present # # @param [Hash{String => String}] env - # @return [Array(Integer, Hash, #each)] + # @return [Array(Integer, Hash, #each)] Status, Headers and Body # @see http://rack.rubyforge.org/doc/SPEC.html def call(env) env['ORDERED_CONTENT_TYPES'] = parse_accept_header(env['HTTP_ACCEPT']) if env.has_key?('HTTP_ACCEPT') response = app.call(env) body = response[2].respond_to?(:body) ? response[2].body : response[2] @@ -65,86 +66,114 @@ # # @param [Hash{String => String}] env # @param [Integer] status # @param [Hash{String => Object}] headers # @param [RDF::Enumerable] body - # @return [Array(Integer, Hash, #each)] + # @return [Array(Integer, Hash, #each)] Status, Headers and Body def serialize(env, status, headers, body) - begin - writer, content_type = find_writer(env, headers) - if writer - # FIXME: don't overwrite existing Vary headers - headers = headers.merge(VARY).merge('Content-Type' => content_type) - [status, headers, [writer.dump(body, nil, @options)]] - else - not_acceptable + result, content_type = nil, nil + find_writer(env, headers) do |writer, ct, accept_params = {}| + begin + # Passes content_type as writer option to allow parameters to be extracted. + result, content_type = writer.dump(body, nil, @options.merge(accept_params: accept_params)), ct.split(';').first + break + rescue RDF::WriterError + # Continue to next writer + ct + rescue + ct end - rescue RDF::WriterError => e + end + + if result + headers = headers.merge(VARY).merge('Content-Type' => content_type) + [status, headers, [result]] + else not_acceptable end end + protected ## - # Returns an `RDF::Writer` class for the given `env`. + # Yields an `RDF::Writer` class for the given `env`. # # If options contain a `:format` key, it identifies the specific format to use; # otherwise, if the environment has an HTTP_ACCEPT header, use it to find a writer; # otherwise, use the default content type # # @param [Hash{String => String}] env # @param [Hash{String => Object}] headers - # @return [Array(Class, String)] + # @yield |writer, content_type| + # @yield_param [RDF::Writer] writer + # @yield_param [String] content_type from accept media-range without parameters + # @yield_param [Hash{Symbol => String}] accept_params from accept media-range # @see http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html def find_writer(env, headers) if @options[:format] format = @options[:format] - writer = RDF::Writer.for(format.to_sym) unless format.is_a?(RDF::Format) - return [writer, writer.format.content_type.first] if writer + writer = RDF::Writer.for(format.to_sym) + yield(writer, writer.format.content_type.first) if writer elsif env.has_key?('HTTP_ACCEPT') content_types = parse_accept_header(env['HTTP_ACCEPT']) content_types.each do |content_type| - writer, content_type = find_writer_for_content_type(content_type) - return [writer, content_type] if writer + find_writer_for_content_type(content_type) do |writer, ct, accept_params| + # Yields content type with parameters + yield(writer, ct, accept_params) + end end - return nil else # HTTP/1.1 §14.1: "If no Accept header field is present, then it is # assumed that the client accepts all media types" - find_writer_for_content_type(options[:default]) + find_writer_for_content_type(options[:default]) do |writer, ct| + # Yields content type with parameters + yield(writer, ct) + end end end ## - # Returns an `RDF::Writer` class for the given `content_type`. + # Yields an `RDF::Writer` class for the given `content_type`. # + # Calls `Writer#accept?(content_type)` for matched content type to allow writers to further discriminate on how if to accept content-type with specified parameters. + # # @param [String, #to_s] content_type - # @return [Array(Class, String)] + # @yield |writer, content_type| + # @yield_param [RDF::Writer] writer + # @yield_param [String] content_type (including media-type parameters) def find_writer_for_content_type(content_type) - writer = RDF::Writer.for(content_type: content_type) if content_type - writer ? [writer, content_type] : nil + ct, *params = content_type.split(';').map(&:strip) + accept_params = params.inject({}) do |memo, pv| + p, v = pv.split('=').map(&:strip) + memo.merge(p.downcase.to_sym => v.sub(/^["']?([^"']*)["']?$/, '\1')) + end + formats = RDF::Format.each(content_type: ct, has_writer: true).to_a.reverse + formats.each do |format| + yield format.writer, (ct || format.content_type.first), accept_params if + format.writer.accept?(accept_params) + end end - protected - ## # Parses an HTTP `Accept` header, returning an array of MIME content # types ordered by the precedence rules defined in HTTP/1.1 §14.1. # # @param [String, #to_s] header # @return [Array<String>] # @see http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.1 def parse_accept_header(header) entries = header.to_s.split(',') entries = entries.map { |e| accept_entry(e) }.sort_by(&:last).map(&:first) - entries.map { |e| find_content_type_for_media_range(e) } + entries.map { |e| find_content_type_for_media_range(e) }.flatten end + # Returns pair of content_type (including non-'q' parameters) + # and array of quality, number of '*' in content-type, and number of non-'q' parameters def accept_entry(entry) - type, *options = entry.delete(' ').split(';') + type, *options = entry.split(';').map(&:strip) quality = 0 # we sort smallest first options.delete_if { |e| quality = 1 - e[2..-1].to_f if e.start_with? 'q=' } - [type, [quality, type.count('*'), 1 - options.size]] + [options.unshift(type).join(';'), [quality, type.count('*'), 1 - options.size]] end ## # Returns a content type appropriate for the given `media_range`, # returns `nil` if `media_range` contains a wildcard subtype @@ -187,10 +216,10 @@ # @param [String, #to_s] message # @param [Hash{String => String}] headers # @return [Array(Integer, Hash, #each)] def http_error(code, message = nil, headers = {}) message = http_status(code) + (message.nil? ? "\n" : " (#{message})\n") - [code, {'Content-Type' => "#{DEFAULT_CONTENT_TYPE}; charset=utf-8"}.merge(headers), [message]] + [code, {'Content-Type' => "text/plain"}.merge(headers), [message]] end ## # Returns the standard HTTP status message for the given status `code`. #