Skip to content

Instantly share code, notes, and snippets.

@mjwillson
Created March 6, 2009 17:14
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save mjwillson/74978 to your computer and use it in GitHub Desktop.
Save mjwillson/74978 to your computer and use it in GitHub Desktop.
RESTful way of exposing a collection resource in merb in a pageable / sub-range-fetchable way. Supports HTTP Content-Range
# Drop me a line if you wanna see this as a proper merb plugin.
class Merb::Controller
ITEM_RANGE = /^items=(\d+)-(\d+)$/
RANGE = /^(\d+)-(\d+)$/
# Displays a collection resource (using Merb's display method) while supporting requests for sub-ranges of items in a RESTful fashion.
# This supports a subset of the HTTP/1.1 spec for content ranges, using a custom range unit 'items'. eg:
# GET /collection HTTP/1.1
# Range: items 10-20
#
# HTTP/1.1 206 Partial Content
# Content-Range: items=10-20/1234
#
# GET /collection HTTP/1.1
# Range: items 1234567-23456568
#
# HTTP/1.1 416 Request Range Not Satisfiable
# Content-Range: items=*/1234
#
# GET /collection HTTP/1.1
# Range: items 0-1000
#
# HTTP/1.1 400 Bad Request
# Item range request exceeds maximum range length for a single request
#
# GET /collection HTTP/1.1
#
# HTTP/1.1 400 Bad Request
# Accept-Ranges: items
# This resource only responds to item range requests. Use a Range header with an range in unit 'items', or an equivalent _item_range parameter
#
# or if :default_to_initial_range set:
# GET /collection HTTP/1.1
#
# HTTP/1.1 302 Moved Temporarily
# Accept-Ranges: items
# Location: /collection?_item_range=0-15
#
# It also supports the use of an _item_range parameter treated equivalently to the Range: items header, for browser-based range requests. In this case
# it will use 200 and 400 rather than 206 and 416, and no Content-Range header, since these are only specced for use with standard Range requests.
#
# It has functionality based on _item_range to support a default initial range (eg first 10 items) in a moderately RESTful way,
# and is configurable as to whether or not it will accept requests for the entire collection, or for ranges over a given length.
#
# Note, while this will work in the browser with the :default_to_initial_range option, it is still designed primarily with APIs in mind rather than templated
# bits of web app UI. In particular it relies on Merb's display method to display the collection in an appropriate mime-type, which in turn needs to be
# able to call eg to_json, to_yaml etc on the object (array of objects in this case) being displayed.
# Works well together with "provides :json".
#
# collection:
# an object answering to .count, .all, and .limit(length, offset).all, eg Sequel::Dataset (TODO: abstract this for other ORMs)
#
# options:
# :max_length - if set to an integer, rejects range requests of length greater than the limit. default 100
# :allow_full_request - if true, allows the entire collection to be served from the resource URI in the absence of a range request.
# if false, rejects (or redirects, see :default_to_initial_range) requests without a range request
# :default_to_initial_range - if set to an integer, redirects requests without a range, to an initial range of length at most that given.
#
# block:
# if given, the collection will be mapped through this block, after limiting to the desired range and before display. Useful if you want
# to serialize items in a particular way before display is called.
def display_rangeable_collection(collection, options={}, &block)
options = {
:max_length => 100,
:default_to_initial_range => 10,
:allow_full_request => false,
}.merge(options)
headers['Accept-Ranges'] = 'items'
headers['Vary'] = 'Range'
range_match = if (range_header = request.env['HTTP_RANGE'])
ITEM_RANGE.match(range_header) or (
self.status = :bad_request
return display("Unsupported or invalid format or units for Range header")
)
elsif (range_param = request.params['_item_range'])
RANGE.match(range_param) or (
self.status = :bad_request
return display("Unsupported or invalid format for _item_range parameter")
)
end
if range_match
first, last = range_match[1].to_i, range_match[2].to_i
length = last + 1 - first
unless length > 0
self.status = :bad_request
return display("Invalid range")
end
max = options[:max_length]; if max && length > max
self.status = :bad_request
return display("Item range request exceeds maximum range length for a single request")
end
collection_length = collection.count
if last >= collection_length
if range_header
headers['Content-Range'] = "items */#{collection_length}"
self.status = :request_range_not_satisfiable
return display('Request range not satisfiable')
else
self.status = :bad_request
return display("Item range request via _item_range parameter is not satisfiable")
end
end
result = collection.limit(length, first).all
result = result.map(&block) if block_given?
if range_header
headers['Content-Range'] = "items #{first}-#{last}/#{collection_length}"
self.status = :partial_content
end
display(result)
elsif options[:allow_full_request]
result = collection.all
result = result.map(&block) if block_given?
display(result)
else
# A request has been made for the collection resource without a range being specified, and :allow_full_request is false.
# (eg if the collection is a big one and a max_length is set)
if options[:default_to_initial_range]
# If default_to_initial_range is set to a range length, we 302 redirect requests for the main resource to an appropriate
# initial range of the resource, using the _item_range parameter workaround to specify the range.
# This option exists mainly for nice behaviour in user agents which don't know that the resource requires a Range header.
collection_length = collection.count
if collection_length == 0
display([])
else
last = [collection_length, options[:default_to_initial_range]].min - 1
uri = if request.query_string.blank?
"#{request.uri}?"
else
"#{request.uri}?#{request.query_string}&"
end + "_item_range=0-#{last}"
redirect(uri)
end
else
# This is probably the more spec-compliant way to handle requests without a required range, although makes it harder to sniff around the API in a browser.
self.status = :bad_request
display("This resource only responds to item range requests. Use a Range header with an range in unit 'items', or an equivalent _item_range parameter")
end
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment