Class: Rack::RDF::ContentNegotiation

Inherits:
Object
  • Object
show all
Defined in:
lib/rack/rdf/conneg.rb

Overview

Rack middleware for Linked Data content negotiation.

Uses HTTP Content Negotiation to find an appropriate RDF format to serialize any result with a body being RDF::Enumerable.

Override content negotiation by setting the :format option to #initialize.

Add a :default option to set a content type to use when nothing else is found.

Examples:

use Rack::RDF::ContentNegotation, :format => :ttl
use Rack::RDF::ContentNegotiation, :format => RDF::NTriples::Format
use Rack::RDF::ContentNegotiation, :default => 'application/rdf+xml'

See Also:

Constant Summary collapse

DEFAULT_CONTENT_TYPE =

N-Triples

"application/n-triples"
VARY =
{'Vary' => 'Accept'}.freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(app, options) ⇒ ContentNegotiation

Returns a new instance of ContentNegotiation.

Parameters:

  • app (#call)
  • options (Hash{Symbol => Object})

    Other options passed to writer.

Options Hash (options):

  • :default (String) — default: DEFAULT_CONTENT_TYPE

    Specific content type

  • :format (RDF::Format, #to_sym)

    Specific RDF writer format to use



37
38
39
40
# File 'lib/rack/rdf/conneg.rb', line 37

def initialize(app, options)
  @app, @options = app, options
  @options[:default] = (@options[:default] || DEFAULT_CONTENT_TYPE).to_s
end

Instance Attribute Details

#app#call (readonly)

Returns:



26
27
28
# File 'lib/rack/rdf/conneg.rb', line 26

def app
  @app
end

#optionsHash{Symbol => Object} (readonly)

Returns:

  • (Hash{Symbol => Object})


29
30
31
# File 'lib/rack/rdf/conneg.rb', line 29

def options
  @options
end

Instance Method Details

#accept_entry(entry) ⇒ Object (protected)

Returns pair of content_type (including non-‘q’ parameters) and array of quality, number of ‘*’ in content-type, and number of non-‘q’ parameters



176
177
178
179
180
181
# File 'lib/rack/rdf/conneg.rb', line 176

def accept_entry(entry)
  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=' }
  [options.unshift(type).join(';'), [quality, type.count('*'), 1 - options.size]]
end

#call(env) ⇒ Array(Integer, Hash, #each)

Handles a Rack protocol request. 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

Parameters:

  • env (Hash{String => String})

Returns:

  • (Array(Integer, Hash, #each))

    Status, Headers and Body

See Also:



51
52
53
54
55
56
57
58
59
60
61
# File 'lib/rack/rdf/conneg.rb', line 51

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]
  case body
    when ::RDF::Enumerable
      response[2] = body  # Put it back in the response, it might have been a proxy
      serialize(env, *response)
    else response
  end
end

#find_content_type_for_media_range(media_range) ⇒ String? (protected)

Returns a content type appropriate for the given media_range, returns nil if media_range contains a wildcard subtype that is not mapped.

Parameters:

  • media_range (String, #to_s)

Returns:

  • (String, nil)


190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
# File 'lib/rack/rdf/conneg.rb', line 190

def find_content_type_for_media_range(media_range)
  case media_range.to_s
  when '*/*'
    options[:default]
  when 'text/*'
    'text/turtle'
  when 'application/*'
    'application/ld+json'
  when 'application/json'
    'application/ld+json'
  when 'application/xml'
    'application/rdf+xml'
  when /^([^\/]+)\/\*$/
    nil
  else
    media_range.to_s
  end
end

#find_writer(env, headers) {|writer, content_type, accept_params| ... } ⇒ Object (protected)

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

Parameters:

  • env (Hash{String => String})
  • headers (Hash{String => Object})

Yields:

  • |writer, content_type|

Yield Parameters:

  • writer (RDF::Writer)
  • content_type (String)

    from accept media-range without parameters

  • accept_params (Hash{Symbol => String})

    from accept media-range

See Also:



116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
# File 'lib/rack/rdf/conneg.rb', line 116

def find_writer(env, headers)
  if @options[:format]
    format = @options[:format]
    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|
      find_writer_for_content_type(content_type) do |writer, ct, accept_params|
        # Yields content type with parameters
        yield(writer, ct, accept_params)
      end
    end
  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]) do |writer, ct|
      # Yields content type with parameters
      yield(writer, ct)
    end
  end
end

#find_writer_for_content_type(content_type) {|writer, content_type| ... } ⇒ Object (protected)

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.

Parameters:

  • content_type (String, #to_s)

Yields:

  • |writer, content_type|

Yield Parameters:

  • writer (RDF::Writer)
  • content_type (String)

    (including media-type parameters)



148
149
150
151
152
153
154
155
156
157
158
159
# File 'lib/rack/rdf/conneg.rb', line 148

def find_writer_for_content_type(content_type)
  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

#http_error(code, message = nil, headers = {}) ⇒ Array(Integer, Hash, #each) (protected)

Outputs an HTTP 4xx or 5xx response.

Parameters:

  • code (Integer, #to_i)
  • message (String, #to_s) (defaults to: nil)
  • headers (Hash{String => String}) (defaults to: {})

Returns:

  • (Array(Integer, Hash, #each))


225
226
227
228
# File 'lib/rack/rdf/conneg.rb', line 225

def http_error(code, message = nil, headers = {})
  message = http_status(code) + (message.nil? ? "\n" : " (#{message})\n")
  [code, {'Content-Type' => "text/plain"}.merge(headers), [message]]
end

#http_status(code) ⇒ String (protected)

Returns the standard HTTP status message for the given status code.

Parameters:

  • code (Integer, #to_i)

Returns:

  • (String)


235
236
237
# File 'lib/rack/rdf/conneg.rb', line 235

def http_status(code)
  [code, Rack::Utils::HTTP_STATUS_CODES[code]].join(' ')
end

#not_acceptable(message = nil) ⇒ Array(Integer, Hash, #each) (protected)

Outputs an HTTP 406 Not Acceptable response.

Parameters:

  • message (String, #to_s) (defaults to: nil)

Returns:

  • (Array(Integer, Hash, #each))


214
215
216
# File 'lib/rack/rdf/conneg.rb', line 214

def not_acceptable(message = nil)
  http_error(406, message, VARY)
end

#parse_accept_header(header) ⇒ Array<String> (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.

Parameters:

  • header (String, #to_s)

Returns:

  • (Array<String>)

See Also:



168
169
170
171
172
# File 'lib/rack/rdf/conneg.rb', line 168

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) }.flatten.compact
end

#serialize(env, status, headers, body) ⇒ Array(Integer, Hash, #each)

Serializes an RDF::Enumerable response into a Rack protocol response using HTTP content negotiation rules or a specified Content-Type.

Passes parameters from Accept header, and Link header to writer.

Parameters:

  • env (Hash{String => String})
  • status (Integer)
  • headers (Hash{String => Object})
  • body (RDF::Enumerable)

Returns:

  • (Array(Integer, Hash, #each))

    Status, Headers and Body



74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
# File 'lib/rack/rdf/conneg.rb', line 74

def serialize(env, status, headers, body)
  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.
      writer_options = @options.merge(
        accept_params: accept_params,
        link: env['HTTP_LINK']
      )
      result, content_type = writer.dump(body, nil, **writer_options), ct.split(';').first
      break
    rescue ::RDF::WriterError
      # Continue to next writer
      ct
    rescue
      ct
    end
  end

  if result
    headers = headers.merge(VARY).merge('Content-Type' => content_type)
    [status, headers, [result]]
  else
    not_acceptable
  end
end