├── lib ├── coset │ ├── collection.rb │ └── mimeparse.rb └── coset.rb ├── sample └── glueball.rb └── test ├── spec_coset_collection.rb └── spec_coset.rb /lib/coset/collection.rb: -------------------------------------------------------------------------------- 1 | require 'coset' 2 | 3 | class Coset::Collection < Coset 4 | def initialize(object=[]) 5 | @object = object 6 | end 7 | 8 | def to_a 9 | @object.to_a 10 | end 11 | def list 12 | to_a 13 | end 14 | 15 | def <<(item) 16 | @object << item 17 | end 18 | def create(item) 19 | self << item 20 | end 21 | 22 | def [](index) 23 | @object[map_index(index)] 24 | end 25 | def retrieve(index) 26 | self[index] 27 | end 28 | 29 | def []=(index, value) 30 | @object[map_index(index)] = value 31 | end 32 | def update(index, value) 33 | self[index] = value 34 | end 35 | 36 | def delete(index) 37 | @object.delete map_index(index) 38 | end 39 | 40 | def to_s 41 | @object.to_s 42 | end 43 | 44 | def map_index(index) 45 | index 46 | end 47 | 48 | GET "/" do 49 | res.write list 50 | end 51 | 52 | POST "/" do 53 | create req.body 54 | end 55 | 56 | GET "/{index}" do 57 | res.write retrieve(@index) 58 | end 59 | 60 | PUT "/{index}" do 61 | update(@index, req.body) 62 | end 63 | 64 | DELETE "/{index}" do 65 | delete @index 66 | end 67 | end 68 | 69 | class Coset::Collection::Array < Coset::Collection 70 | def map_index(index) 71 | Integer(index) 72 | end 73 | 74 | def delete(index) 75 | @object.delete_at(map_index(index)) 76 | end 77 | end 78 | 79 | class Coset::Collection::Hash < Coset::Collection 80 | def initialize(pkey, object={}) 81 | super object 82 | @pkey = pkey 83 | end 84 | 85 | def to_a 86 | @object.values 87 | end 88 | 89 | def <<(item) 90 | update(item.fetch(@pkey), item) 91 | end 92 | end 93 | 94 | class Coset::Collection::Atom < Coset::Collection 95 | def initialize(feed=::Atom::Feed.new) 96 | super feed 97 | end 98 | 99 | def list 100 | @object.to_s 101 | end 102 | 103 | def <<(item) 104 | super(::Atom::Entry.parse(item)) 105 | end 106 | 107 | def [](index) 108 | @object.entries.find { |e| e.id == index } 109 | end 110 | 111 | def []=(index, value) 112 | value.id = index 113 | self << value 114 | end 115 | 116 | def delete(index) 117 | @object.entries.delete_if { |e| e.id == index } 118 | end 119 | end 120 | 121 | -------------------------------------------------------------------------------- /sample/glueball.rb: -------------------------------------------------------------------------------- 1 | require 'atom/service' 2 | require 'atom/collection' 3 | 4 | require 'rack' 5 | require 'coset' 6 | 7 | class Glueball < Coset 8 | def initialize(svc=Atom::Service.new) 9 | @svc = svc 10 | end 11 | 12 | GET "/" do 13 | res.write '' 14 | res.write '' 15 | end 16 | 17 | GET "/service.atomsvc" do 18 | res["Content-Type"] = "application/atomserv+xml" 19 | res.write @svc.to_s 20 | end 21 | 22 | 23 | class NotFound < IndexError; end 24 | map_exception NotFound, 404 25 | 26 | def feed 27 | @svc.workspaces.first.collections.find { |feed| feed.id == @feed } or 28 | raise NotFound 29 | end 30 | 31 | def entry 32 | feed.entries.find { |entry| entry.id == @id } or 33 | raise NotFound 34 | end 35 | 36 | 37 | GET "/{feed}" do 38 | feed.entries.each { |entry| 39 | unless entry.edit_url 40 | link = Atom::Link.new.update "rel" => "edit", 41 | "href" => "#{feed.id}/#{entry.id}" 42 | entry.links << link 43 | end 44 | } 45 | 46 | res["Content-Type"] = "application/atom+xml" 47 | res.write feed.to_s 48 | end 49 | 50 | POST "/{feed}" do 51 | new_entry = Atom::Entry.parse(req.body) 52 | 53 | feed << new_entry 54 | res["Content-Type"] = "application/atom+xml" 55 | res.status = 201 56 | res.write new_entry.to_s 57 | end 58 | 59 | GET "/{feed}/{id}" do 60 | res["Content-Type"] = "application/atom+xml" 61 | res.write entry.to_s 62 | end 63 | 64 | PUT "/{feed}/{id}" do 65 | new_entry = Atom::Entry.parse(req.body) 66 | feed << new_entry 67 | new_entry.id = @id 68 | 69 | res["Content-Type"] = "application/atom+xml" 70 | res.write new_entry.to_s 71 | end 72 | 73 | DELETE "/{feed}/{id}" do 74 | feed.entries.delete_if { |e| e.id == @id } 75 | end 76 | end 77 | 78 | 79 | svc = Atom::Service.new 80 | ws = Atom::Workspace.new 81 | col = Atom::Collection.new("/blogone") 82 | 83 | svc.workspaces << ws 84 | 85 | ws.title = "Glueball server" 86 | ws.collections << col 87 | 88 | col.title = "Blog one" 89 | col.id = "blogone" 90 | col << Atom::Entry.new { |e| 91 | e.id = `uuidgen`.strip 92 | e.title = "An entry" 93 | e.content = "the content" 94 | } 95 | 96 | app = Glueball.new(svc) 97 | # app = Rack::Lint.new(app) 98 | app = Rack::ShowExceptions.new(app) 99 | app = Rack::ShowStatus.new(app) 100 | app = Rack::CommonLogger.new(app) 101 | 102 | Rack::Handler::WEBrick.run app, :Port => 9266 103 | -------------------------------------------------------------------------------- /test/spec_coset_collection.rb: -------------------------------------------------------------------------------- 1 | require 'test/spec' 2 | 3 | require 'coset' 4 | require 'coset/collection' 5 | 6 | require 'atom/feed' # atom-tools 7 | 8 | context "Coset::Collection" do 9 | specify "should support Arrays" do 10 | c = Coset::Collection::Array.new([1,2,3]) 11 | 12 | c.list.should.equal [1,2,3] 13 | c.to_a.should.equal [1,2,3] 14 | 15 | c.create(4) 16 | c.list.should.equal [1,2,3,4] 17 | 18 | c << 5 19 | c.list.should.equal [1,2,3,4,5] 20 | 21 | c.retrieve(1).should.equal 2 22 | c[1].should.equal 2 23 | 24 | c.update(1, 4) 25 | c[1].should.equal 4 26 | c.list.should.equal [1,4,3,4,5] 27 | c[1] = 5 28 | c.list.should.equal [1,5,3,4,5] 29 | 30 | c.delete(1) 31 | c.list.should.equal [1,3,4,5] 32 | end 33 | 34 | specify "should support Hashes" do 35 | c = Coset::Collection::Hash.new("name", 36 | {"Simpson" => {"name" => "Simpson", 37 | "firstname" => "Homer"}, 38 | "Newton" => {"name" => "Newton", 39 | "firstname" => "Isaac"}, 40 | }) 41 | 42 | c.list.should.equal [{"name" => "Simpson", "firstname" => "Homer"}, 43 | {"name" => "Newton", "firstname" => "Isaac"}] 44 | c.to_a.should.equal [{"name" => "Simpson", "firstname" => "Homer"}, 45 | {"name" => "Newton", "firstname" => "Isaac"}] 46 | 47 | c.create({"name" => "Einstein", "firstname" => "Albert"}) 48 | c.list.should.equal [{"name" => "Simpson", "firstname" => "Homer"}, 49 | {"name" => "Newton", "firstname" => "Isaac"}, 50 | {"name" => "Einstein", "firstname" => "Albert"}] 51 | 52 | c.retrieve("Newton").should.equal({"name" => "Newton", "firstname" => "Isaac"}) 53 | c["Newton"].should.equal({"name" => "Newton", "firstname" => "Isaac"}) 54 | 55 | c.update("Simpson", {"name" => "Simpson", "firstname" => "Thomas"}) 56 | c["Simpson"].should.equal({"name" => "Simpson", "firstname" => "Thomas"}) 57 | c.list.should.equal [{"name" => "Simpson", "firstname" => "Thomas"}, 58 | {"name" => "Newton", "firstname" => "Isaac"}, 59 | {"name" => "Einstein", "firstname" => "Albert"}] 60 | 61 | 62 | c.delete("Simpson") 63 | c.list.should.equal [{"name" => "Newton", "firstname" => "Isaac"}, 64 | {"name" => "Einstein", "firstname" => "Albert"}] 65 | end 66 | 67 | specify "should support Atom" do 68 | c = Coset::Collection::Atom.new 69 | 70 | c.list.should.equal "" 71 | 72 | c.create Atom::Entry.new { |e| e.id = "foo"; e.title = "title of bar" } 73 | 74 | c.list.should.match "foo" 75 | c["foo"].to_s.should.match "foo" 76 | 77 | c["foo"] = Atom::Entry.new { |e| e.id = "bar"; e.title = "title of bar" } 78 | c["foo"].to_s.should.match "foo" 79 | c["foo"].to_s.should.match "title of bar" 80 | end 81 | end 82 | 83 | -------------------------------------------------------------------------------- /test/spec_coset.rb: -------------------------------------------------------------------------------- 1 | require 'test/spec' 2 | 3 | require 'coset' 4 | 5 | class TestApp < Coset 6 | GET "/{path}{EXT}" do 7 | wants("text/html") { res.write "HTML" } 8 | wants("text/plain") { res.write "ASCII" } 9 | end 10 | end 11 | 12 | class TestApp2 < Coset 13 | map_exception IndexError, 404 14 | map_exception NameError, 500 15 | 16 | GET "/ie" do 17 | raise IndexError 18 | end 19 | 20 | GET "/urgs" do 21 | res.write "meh" 22 | quux! 23 | end 24 | end 25 | 26 | class FooApp < Coset 27 | GET "/foo" do 28 | res.write "foo" 29 | end 30 | 31 | GET "/a" do 32 | res.write "a" 33 | end 34 | 35 | GET "/duh" do 36 | raise IndexError 37 | end 38 | 39 | map_exception IndexError, 500 40 | end 41 | 42 | class BarApp < FooApp 43 | GET "/bar" do 44 | res.write "bar" 45 | end 46 | 47 | GET "/a" do 48 | res.write "b" 49 | end 50 | 51 | map_exception IndexError, 404 52 | end 53 | 54 | context "Coset" do 55 | specify "should parse Accept-headers correctly" do 56 | 57 | res = Rack::MockRequest.new(TestApp). 58 | get("/index.html", {"HTTP_ACCEPT" => "text/html ;q=0.5;v=1"}) 59 | res.should.match "HTML" 60 | 61 | res = Rack::MockRequest.new(TestApp). 62 | get("/index.html", {"HTTP_ACCEPT" => "text/html ;q=0.5,text/plain;q=0.2"}) 63 | res.should.match "HTML" 64 | 65 | res = Rack::MockRequest.new(TestApp). 66 | get("/index.html", {"HTTP_ACCEPT" => "text/html ;q=0.5;v=1,text/plain"}) 67 | res.should.match "HTML" # extension overrides 68 | 69 | res = Rack::MockRequest.new(TestApp). 70 | get("/index.html", {"HTTP_ACCEPT" => "text/*"}) 71 | res.should.match "HTML" # first choice 72 | 73 | res = Rack::MockRequest.new(TestApp). 74 | get("/index", {"HTTP_ACCEPT" => nil}) 75 | res.should.match "HTML" # first choice 76 | 77 | res = Rack::MockRequest.new(TestApp). 78 | get("/index", {"HTTP_ACCEPT" => "x-noidea/x-whatthisis"}) 79 | res.status.should.equal 406 # not acceptable 80 | 81 | res = Rack::MockRequest.new(TestApp). 82 | get("/index.html", {"HTTP_ACCEPT" => nil}) 83 | res.should.match "HTML" # extension 84 | 85 | res = Rack::MockRequest.new(TestApp). 86 | get("/index.txt", {"HTTP_ACCEPT" => nil}) 87 | res.should.match "ASCII" # extension 88 | 89 | end 90 | 91 | specify "should map exceptions" do 92 | res = Rack::MockRequest.new(TestApp2). 93 | get("/ie") 94 | res.status.should.equal 404 95 | 96 | res = Rack::MockRequest.new(TestApp2). 97 | get("/urgs") 98 | res.status.should.equal 500 99 | end 100 | 101 | specify "should support inheritance properly" do 102 | BarApp.ancestors.should.include FooApp 103 | BarApp.ancestors.should.include Coset 104 | 105 | res = Rack::MockRequest.new(BarApp).get("/foo") 106 | res.should.match "foo" 107 | 108 | res = Rack::MockRequest.new(BarApp).get("/bar") 109 | res.should.match "bar" 110 | 111 | res = Rack::MockRequest.new(FooApp).get("/a") 112 | res.should.match "a" 113 | res = Rack::MockRequest.new(BarApp).get("/a") 114 | res.should.match "b" 115 | 116 | res = Rack::MockRequest.new(FooApp).get("/duh") 117 | res.status.should.equal 500 118 | res = Rack::MockRequest.new(BarApp).get("/duh") 119 | res.status.should.equal 404 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /lib/coset.rb: -------------------------------------------------------------------------------- 1 | require 'rack' 2 | require 'pp' 3 | 4 | require 'coset/mimeparse' 5 | 6 | class Coset 7 | def call(env) 8 | @env = env 9 | path = env["PATH_INFO"] 10 | path = "/" if path.empty? 11 | @res = Rack::Response.new 12 | @req = Rack::Request.new(env) 13 | run path, env["REQUEST_METHOD"] 14 | @res.finish 15 | end 16 | 17 | def run(path, everb=//) 18 | @wants = {} 19 | @wants_content_types = [] 20 | @EXT = "" 21 | 22 | self.class.routes.each { |rx, verb, fields, meth| 23 | if path =~ rx && everb === verb 24 | fields.each_with_index { |field, index| 25 | instance_variable_set "@#{field}", $~[index+1] 26 | } 27 | begin 28 | __send__(meth) 29 | run_wants unless @wants.empty? 30 | rescue *self.class.exceptions.keys => e 31 | status, message = self.class.exceptions.find_all { |klass, _| 32 | e.kind_of? klass 33 | }.sort.first[1] 34 | 35 | res.status = status 36 | env["rack.showstatus.detail"] = "

" + message + "

" 37 | end 38 | return 39 | end 40 | } 41 | res.status = 404 42 | env["rack.showstatus.detail"] = "

Routes:

#{Rack::Utils.escape_html PP.pp(self.class.routes, '')}
" 43 | end 44 | 45 | attr_reader :res, :req, :env 46 | 47 | def wants(type, &block) 48 | @wants_content_types << type # keep track of order 49 | @wants[type] = block 50 | end 51 | 52 | def run_wants 53 | # File extensions override Accept:-headers. 54 | if exttype = Rack::File::MIME_TYPES[@EXT.to_s[1..-1]] 55 | @env["HTTP_ACCEPT"] = exttype 56 | end 57 | 58 | # If you accept anything, you'll get the first option. 59 | @env.delete "HTTP_ACCEPT" if @env["HTTP_ACCEPT"] == "*/*" 60 | @env["HTTP_ACCEPT"] ||= @wants_content_types.first 61 | 62 | match = MIMEParse.best_match(@wants_content_types, @env["HTTP_ACCEPT"]) 63 | 64 | if match 65 | @wants[match].call 66 | else 67 | res.status = 406 # Not Acceptable 68 | end 69 | end 70 | 71 | class << self 72 | def call(env) 73 | new.call(env) 74 | end 75 | 76 | def inherited(newone) 77 | newone.exceptions = (exceptions || {}).dup 78 | newone.routes = (routes || []).dup 79 | end 80 | 81 | attr_accessor :exceptions 82 | attr_accessor :routes 83 | 84 | def define(desc, &block) 85 | meth = method_name desc 86 | verb, fields, rx = *tokenize(desc) 87 | routes << [rx, verb, fields, meth] 88 | define_method(meth, &block) 89 | end 90 | 91 | def GET(desc, &block) define("GET #{desc}", &block) end 92 | def POST(desc, &block) define("POST #{desc}", &block) end 93 | def PUT(desc, &block) define("PUT #{desc}", &block) end 94 | def DELETE(desc, &block) define("DELETE #{desc}", &block) end 95 | 96 | def map_exception(exception, status, message="") 97 | exceptions[exception] = [status, message] 98 | end 99 | 100 | def tokenize(desc) 101 | verb, path = desc.split(" ", 2) 102 | if verb.nil? || path.nil? 103 | raise ArgumentError, "Invalid description string #{desc}" 104 | end 105 | 106 | verb.upcase! 107 | 108 | fields = [] 109 | rx = Regexp.new "\\A" + path.gsub(/\{(.*?)(?::(.*?))?\}/) { 110 | fields << $1 111 | 112 | if $1 == "EXT" 113 | "(\\.\\w+)?" 114 | else 115 | case $2 116 | when "numeric" 117 | "(\d+?)" 118 | when "all" 119 | "(.*?)" 120 | when "", nil 121 | "([^/]*?)" 122 | else 123 | raise ArgumentError, "Invalid qualifier #$2 for #$1" 124 | end 125 | end 126 | } + "\\z" 127 | 128 | [verb, fields, rx] 129 | end 130 | 131 | def method_name(desc) 132 | desc.gsub(/\{(.*?)\}/) { $1.upcase }.delete(" ").gsub(/[^\w-]/, '_') 133 | end 134 | end 135 | end 136 | -------------------------------------------------------------------------------- /lib/coset/mimeparse.rb: -------------------------------------------------------------------------------- 1 | # ADAPTED FROM http://mimeparse.googlecode.com/svn/trunk/mimeparse.rb 2 | # REVISION 3. MIT-LICENSED, ORIGINALLY BY Brendan Taylor. 3 | 4 | # This version has been stripped down by Christian Neukirchen for 5 | # inclusion with Coset. 6 | 7 | 8 | # mimeparse.rb 9 | # 10 | # This module provides basic functions for handling mime-types. It can 11 | # handle matching mime-types against a list of media-ranges. See section 12 | # 14.1 of the HTTP specification [RFC 2616] for a complete explanation. 13 | # 14 | # http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.1 15 | # 16 | # --------- 17 | # 18 | # This is a port of Joe Gregario's mimeparse.py, which can be found at 19 | # . 20 | # 21 | # ported from version 0.1.1 22 | # 23 | # Comments are mostly excerpted from the original. 24 | 25 | class Coset 26 | module MIMEParse 27 | module_function 28 | 29 | # Carves up a mime_type and returns an Array of the 30 | # [type, subtype, params] where "params" is a Hash of all 31 | # the parameters for the media range. 32 | # 33 | # For example, the media range "application/xhtml;q=0.5" would 34 | # get parsed into: 35 | # 36 | # ["application", "xhtml", { "q" => "0.5" }] 37 | def parse_mime_type(mime_type) 38 | parts = mime_type.split(";") 39 | 40 | params = {} 41 | 42 | parts[1..-1].map do |param| 43 | k,v = param.split("=").map { |s| s.strip } 44 | params[k] = v 45 | end 46 | 47 | type, subtype = parts[0].split("/") 48 | raise "malformed mime type" unless subtype 49 | 50 | [type.strip, subtype.strip, params] 51 | end 52 | 53 | # Carves up a media range and returns an Array of the 54 | # [type, subtype, params] where "params" is a Hash of all 55 | # the parameters for the media range. 56 | # 57 | # For example, the media range "application/*;q=0.5" would 58 | # get parsed into: 59 | # 60 | # ["application", "*", { "q", "0.5" }] 61 | # 62 | # In addition this function also guarantees that there 63 | # is a value for "q" in the params dictionary, filling it 64 | # in with a proper default if necessary. 65 | def parse_media_range(range) 66 | type, subtype, params = parse_mime_type(range) 67 | unless params.has_key?("q") and params["q"] and params["q"].to_f and params["q"].to_f <= 1 and params["q"].to_f >= 0 68 | params["q"] = "1" 69 | end 70 | 71 | [type, subtype, params] 72 | end 73 | 74 | # Find the best match for a givven mime_type against a list of 75 | # media_ranges that have already been parsed by #parse_media_range 76 | # 77 | # Returns the "q" quality parameter of the best match, 0 if no match 78 | # was found. This function behaves the same as #quality except that 79 | # "parsed_ranges" must be an Enumerable of parsed media ranges. 80 | def quality_parsed(mime_type, parsed_ranges) 81 | best_fitness = -1 82 | best_match = "" 83 | best_fit_q = 0 84 | target_type, target_subtype, target_params = parse_media_range(mime_type) 85 | 86 | parsed_ranges.each do |type,subtype,params| 87 | param_matches = target_params.find_all { |k,v| k != "q" and params.has_key?(k) and v == params[k] }.length 88 | 89 | if (type == target_type or type == "*" or target_type == "*") and 90 | (subtype == target_subtype or subtype == "*" or target_subtype == "*") 91 | 92 | fitness = (type == target_type) ? 100 : 0 93 | fitness += (subtype == target_subtype) ? 10 : 0 94 | fitness += param_matches 95 | 96 | if fitness > best_fitness 97 | best_fitness = fitness 98 | best_fit_q = params["q"] 99 | end 100 | end 101 | end 102 | 103 | best_fit_q.to_f 104 | end 105 | 106 | # Returns the quality "q" of a mime_type when compared against 107 | # the media-ranges in ranges. For example: 108 | # 109 | # irb> quality("text/html", "text/*;q=0.3, text/html;q=0.7, text/html;level=1, text/html;level=2;q=0.4, */*;q=0.5") 110 | # => 0.7 111 | def quality(mime_type, ranges) 112 | parsed_ranges = ranges.split(",").map { |r| parse_media_range(r) } 113 | quality_parsed(mime_type, parsed_ranges) 114 | end 115 | 116 | # Takes a list of supported mime-types and finds the best match 117 | # for all the media-ranges listed in header. The value of header 118 | # must be a string that conforms to the format of the HTTP Accept: 119 | # header. The value of supported is an Enumerable of mime-types 120 | # 121 | # irb> best_match(["application/xbel+xml", "text/xml"], "text/*;q=0.5,*/*; q=0.1") 122 | # => "text/xml" 123 | def best_match(supported, header) 124 | parsed_header = header.split(",").map { |r| parse_media_range(r) } 125 | 126 | weighted_matches = supported.map do |mime_type| 127 | [quality_parsed(mime_type, parsed_header), mime_type] 128 | end 129 | 130 | weighted_matches.sort! 131 | 132 | weighted_matches.last[0].zero? ? nil : weighted_matches.last[1] 133 | end 134 | end 135 | end 136 | --------------------------------------------------------------------------------