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`.
#