Created
March 6, 2009 17:14
-
-
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
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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