├── .gitignore ├── README ├── Rakefile ├── doc ├── LICENSE ├── TODO └── examples │ ├── README │ ├── environment.ru │ ├── hello_world.ru │ ├── hoshi.ru │ ├── matching.ru │ └── return.ru ├── ext └── .gitignore ├── lib ├── watts.rb └── watts │ └── monkey_patching.rb └── test ├── app_test.rb ├── coverage.rb ├── methods_test.rb └── route_test.rb /.gitignore: -------------------------------------------------------------------------------- 1 | pkg 2 | tmp/* 3 | doc/rdoc 4 | coverage 5 | -------------------------------------------------------------------------------- /README: -------------------------------------------------------------------------------- 1 | # Watts 2 | 3 | ## Intro 4 | 5 | Watts is a minimalist, Rack-based, resource-oriented web framework. It 6 | has fewer than 400 lines of code (including comments), no dependencies 7 | (besides Rack), and only does one thing: it maps resources. 8 | 9 | See doc/LICENSE for the license. See doc/examples if you're impatient. 10 | 11 | ## Goals 12 | 13 | Dead-simple web development. I don't want a framework to take up so 14 | much of my brain that there's no space left for my application. 15 | 16 | ## Resource Orientation 17 | 18 | If you think of resources as first-class objects, and then think of HTTP 19 | methods as operations on those objects, then this should probably make 20 | sense. 21 | 22 | Most of the web frameworks I have seen seem to be HTTP-method-oriented 23 | rather than resource-oriented. It seems odd to me (not to mention 24 | repetitive) that you would type "A GET on /foo does this, a POST on /foo 25 | does that, ..." rather than "There's a resource at /foo. That 26 | resource's GET does this, and its POST does that, ...". 27 | 28 | And because the second one made more sense to me, that's how I wrote 29 | Watts. Let me clarify if that was a bit vague, by showing how a Watts 30 | app comes into being: 31 | * You make a resource by sub-classing `Watts::Resource`. 32 | * On this resource, you define some basic operations, in terms of HTTP 33 | methods. 34 | * You create an app by sub-classing `Watts::App`. 35 | * You use the resource() method to tell Watts the path under which the 36 | resource can be found. 37 | 38 | There are a few easy-to-read examples in doc/examples. 39 | 40 | ## Pattern-matching 41 | 42 | That resource() method mentioned above? It does some pattern-matching 43 | on the different components of the path. They all arrive as strings, of 44 | course. The priority for matches is that Watts will attempt to match a 45 | string literally if possible. Next it tries to match any regex patterns 46 | that have been specified, and failing that, symbols are used as a 47 | catch-all. Here are a few patterns and the things they match: 48 | * '' matches "/" 49 | * 'foo' matches "/foo". 50 | * 'foo/bar' matches "/foo/bar". 51 | * [] matches "/". 52 | * ['foo'] matches "/foo". 53 | * ['foo', 'bar'] matches "/foo/bar". 54 | * ['foo', /^[0-9]+$/] matches "/foo/", followed by a string of digits. 55 | The matching part of the path will be passed as an argument to the 56 | method on the resource when it is called. 57 | * ['foo', :arg] matches "/foo/" followed by anything. Like with the 58 | regex, the argument is passed in. The symbol's actual value doesn't 59 | really matter to Watts; it is intended for documentation. 60 | 61 | See doc/examples/matching.ru. 62 | 63 | ## What methods return: 64 | 65 | When you define an HTTP method on a `Watts::Resource`, the best thing 66 | to return is an array in the format Rack expects for responses, namely: 67 | 68 | [status_code, {headers}, [body, ...]] 69 | 70 | For the sake of convenience, Watts will attempt to do the right thing 71 | if you return a bare string (in which case, it is treated as `text/plain`). 72 | If the return value is a `Rack::Response`, then Watts will use that. If you 73 | call `Watts::Resource#response`, a `Rack::Response` will be created if it 74 | does not exist, and that response will be used if the return value is nil. 75 | 76 | The Rack API has changed a handful of times recently, so see SPEC.rdoc in 77 | the Rack repo or use ri(1) or on the web: 78 | https://github.com/rack/rack/blob/main/SPEC.rdoc#label-The+Response . 79 | 80 | Note also that, although HTTP headers are case-insensitive, 81 | `Rack::Lint` has started throwing errors if you use the canonical case 82 | (e.g., "User-Agent", "Content-Type") rather than all lower-case (e.g., 83 | "user-agent", "content-type"). The canonical case for HTTP headers is 84 | used in the RFCs, in nearly every web server and user agent since the 85 | 1990s, as well as all of the documentation, including the IANA's 86 | header registry: http://www.iana.org/assignments/message-headers . 87 | As Watts is designed to work with Rack, though, Watts now emits only 88 | lower-case header names in the few places where it does emit headers. 89 | 90 | `Rack::Lint` is of dubious utility and can be disabled without consequence. 91 | 92 | See doc/examples/return.ru. 93 | 94 | * A string, in which case, 95 | 96 | ## REST, RFC 2616, TL;DR 97 | 98 | There's a lot of talk on the internets about what exactly REST is, why it's 99 | important, why we're doing it wrong, content-negotiation, discoverability, 100 | avoiding out-of-band communication, and all of that stuff. Watts makes it a 101 | little easier to comply with the spec than Rails or Sinatra if you know what the 102 | spec says, but it doesn't force it on you. (You should definitely care, but 103 | Watts won't make you.) 104 | 105 | ## Design 106 | 107 | Lots of web frameworks move very quickly, have a large number of features, a 108 | large number of bugs resulting from the large number of features, and an onerous 109 | upgrade process when updating to a new version. Watts has a much more 110 | strict policy: do not add a feature unless it is obviously correct. 111 | 112 | Except for the earliest versions, every new feature that made it into Watts has 113 | been implemented in two or three Watts applications, and looks very different 114 | than it would look if it had been implemented based on speculation. (I've been 115 | hacking for a long time; this may seem obvious to older hackers, but you have to 116 | build something a few times before you understand it well enough to put it into 117 | a framework.) 118 | 119 | If you feel that Watts sorely misses some features, the codebase is very small, 120 | and as a result very amenable to extension. 121 | 122 | ## Bugs 123 | 124 | I'm sure they're present. You can email me about them, or ping me on 125 | GitHub, or send a patch. 126 | 127 | There do not seem to be too many. There is a test suite. 128 | 129 | ## About the Name 130 | 131 | I named it after a character in a video game that I liked as a kid (and still 132 | like). It's also the name of a city not far from where I live. 133 | 134 | Also: joules per second. 135 | 136 | The Watts in question: 137 | 138 | ░░░█░░██▒█░░█░░ ███░██░░▓░██░██ 139 | ░░█▒██▓█▒▓██▒█░ ██░▓░░▒░▓▒░░▓░█ 140 | ░█▒██▓▓█▒▓▓██▒█ █░▓░░▒▒░▓▒▒░░▓░ 141 | ░█▒▒▒███▒███▒▒█ █░▓▓▓░░░▓░░░▓▓░ 142 | ░░█▒█▒▒▒▒▒▒█▒█░ ██░▓░▓▓▓▓▓▓░▓░█ 143 | ░░░█▒██████▒█░░ ███░▓░░░░░░▓░██ 144 | ░░█▒██▒██▒██▒█░ ██░▓░░▓░░▓░░▓░█ 145 | ░░░███▒██▒███░░ ███░░░▓░░▓░░░██ 146 | ░█▒▓█▒████▒█▓█░ █░▓▒░▓░░░░▓░▒░█ 147 | █▒▒▓▓█▒▒▒▒█▓██░ ░▓▓▒▒░▓▓▓▓░▒░░█ 148 | █▒▒█▓██▒▒█▓█▒▒█ ░▓▓░▒░░▓▓░▒░▓▓░ 149 | █▒██▓█▓██▓▓█▒▒█ ░▓░░▒░▒░░▒▒░▓▓░ 150 | ░█░██▒████████░ █░█░░▓░░░░░░░░█ 151 | ░░░█▓▓▓▓█▓▓█░░░ ███░▒▒▒▒░▒▒░███ 152 | ░░░░█▓▓▓███░░░░ ████░▒▒▒░░░████ 153 | ░░████████████░ ██░░░░░░░░░░░░█ 154 | 155 | 156 | ## Author 157 | 158 | Pete Elmore. Feel free to email me using pete at debu dot gs. I'm on 159 | GitHub at http://github.com/pete . Also there's http://debu.gs/ , which 160 | runs Watts. 161 | 162 | ### Acknowledgements 163 | 164 | The commits coming just from me is a bit misleading! Suggestions and tweaks 165 | have been provided by friends and coworkers: 166 | 167 | * John Dewey ( http://bitbucket.org/retr0h ) 168 | * Jim Radford ( http://github.com/radford ) 169 | * Johnathon Britz ( http://github.com/johnathonbritz ) 170 | * Justin George ( http://github.com/jaggederest ) 171 | 172 | And, as with Hoshi ( http://github.com/pete/hoshi ), I think I'll continue the 173 | tradition of crediting the music I was blasting while writing out the first 174 | draft: Softball and Dance☆Man. 175 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rubygems/package_task' 2 | require 'rdoc/task' 3 | 4 | $: << "#{File.dirname(__FILE__)}/lib" 5 | 6 | spec = Gem::Specification.new { |s| 7 | s.platform = Gem::Platform::RUBY 8 | 9 | s.author = "Pete Elmore" 10 | s.email = "pete@debu.gs" 11 | s.files = Dir["{lib,doc,bin,ext}/**/*"].delete_if {|f| 12 | /\/rdoc(\/|$)/i.match f 13 | } + %w(Rakefile) 14 | s.require_path = 'lib' 15 | s.extra_rdoc_files = Dir['doc/*'].select(&File.method(:file?)) 16 | s.extensions << 'ext/extconf.rb' if File.exist? 'ext/extconf.rb' 17 | Dir['bin/*'].map(&File.method(:basename)).map(&s.executables.method(:<<)) 18 | 19 | s.name = 'watts' 20 | s.license = 'MIT' 21 | s.summary = 22 | "Resource-oriented, Rack-based, minimalist web framework." 23 | s.homepage = "http://github.com/pete/watts" 24 | %w(rack).each &s.method(:add_dependency) 25 | s.version = '1.0.6' 26 | } 27 | 28 | Rake::RDocTask.new(:doc) { |t| 29 | t.main = 'doc/README' 30 | t.rdoc_files.include 'lib/**/*.rb', 'doc/*', 'bin/*', 'ext/**/*.c', 31 | 'ext/**/*.rb' 32 | t.options << '-S' << '-N' 33 | t.rdoc_dir = 'doc/rdoc' 34 | } 35 | 36 | Gem::PackageTask.new(spec) { |pkg| 37 | pkg.need_tar_bz2 = true 38 | } 39 | desc "Cleans out the packaged files." 40 | task(:clean) { 41 | FileUtils.rm_rf 'pkg' 42 | } 43 | 44 | desc "Builds and installs the gem for #{spec.name}" 45 | task(:install => :package) { 46 | g = "pkg/#{spec.name}-#{spec.version}.gem" 47 | system "gem install -l #{g}" 48 | } 49 | 50 | desc "Runs IRB, automatically require()ing #{spec.name}." 51 | task(:irb) { 52 | exec "irb -Ilib -r#{spec.name}" 53 | } 54 | 55 | desc "Runs tests." 56 | task(:test) { 57 | tests = Dir['test/*_test.rb'].map { |t| "-r#{t}" } 58 | if ENV['COVERAGE'] 59 | tests.unshift "-rtest/coverage" 60 | end 61 | system 'ruby', '-Ilib', '-I.', *tests, '-e', '' 62 | } 63 | -------------------------------------------------------------------------------- /doc/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010-2016 Peter Elmore (pete at debu.gs) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a 4 | copy of this software and associated documentation files (the "Software"), 5 | to deal in the Software without restriction, including without limitation 6 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 7 | and/or sell copies of the Software, and to permit persons to whom the 8 | Software is furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL 16 | THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 18 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 19 | DEALINGS IN THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /doc/TODO: -------------------------------------------------------------------------------- 1 | Perhaps use Rack::URLMap where applicable (and transparent) to speed things up, 2 | provided it doesn't preclude things like changing the app on the fly. 3 | 4 | Tweaks to make inheritance behave better. 5 | 6 | Hooks? I keep running into places where I'd like to have them, but a clean way 7 | to do this is not yet obvious. 8 | -------------------------------------------------------------------------------- /doc/examples/README: -------------------------------------------------------------------------------- 1 | All of the examples are standalone Watts applications, which you can run 2 | directly. To run them, you can use the command: 3 | rackup file.ru 4 | 5 | * hello_world.ru is the most basic demonstration. 6 | * matching.ru shows how Watts's pattern-matching works. 7 | * hoshi.ru is a demonstration of the view-wrapping resource. 8 | * environment.ru shows what you have to work with in your request. 9 | * return.ru demonstrates how Watts handles the return value. 10 | 11 | Although they're all single-file applications, there's nothing that 12 | prevents you from splitting things up arbitrarily. It's all just Ruby. 13 | -------------------------------------------------------------------------------- /doc/examples/environment.ru: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env rackup 2 | # This example gives you a feel for the environment in which Watts::Resources 3 | # run. By "environment", of course, I really just mean that the 'env' value 4 | # Rack gives you on requests is accessible from inside your resources. You can 5 | # request /, /foo, or whatever. If you want to have a look at how query string 6 | # parsing works, try having a look at /query?asdf=jkl%3B . This example just 7 | # uses the CGI library that comes with Ruby for parsing queries. 8 | 9 | require 'watts' 10 | require 'pp' 11 | require 'cgi' 12 | 13 | class WattsEnvironment < Watts::App 14 | class EnvPrinter < Watts::Resource 15 | get { |*a| 16 | s = '' 17 | PP.pp env, s 18 | s 19 | } 20 | end 21 | 22 | class Queries < Watts::Resource 23 | get { 24 | CGI.parse(env['QUERY_STRING']).inspect rescue 'Couldn\'t parse.' 25 | } 26 | end 27 | 28 | res('/', EnvPrinter) { 29 | res('foo', EnvPrinter) 30 | res([:yeah], EnvPrinter) 31 | res('query', Queries) 32 | } 33 | end 34 | 35 | app = WattsEnvironment.new 36 | builder = Rack::Builder.new { run app } 37 | run builder 38 | -------------------------------------------------------------------------------- /doc/examples/hello_world.ru: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env rackup 2 | # This is, I think, the simplest possible Watts application. It starts up Rack 3 | # on port 9292 and responds only to GET /. 4 | 5 | require 'watts' 6 | 7 | class Simple < Watts::App 8 | class EZResource < Watts::Resource 9 | get { "Hello, World!\n" } 10 | end 11 | 12 | res('/', EZResource) 13 | end 14 | 15 | app = Simple.new 16 | builder = Rack::Builder.new { run app } 17 | run builder 18 | -------------------------------------------------------------------------------- /doc/examples/hoshi.ru: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env rackup 2 | # An example illustrating how to use the for_html_view method, with help from 3 | # Hoshi. Try running this and opening http://localhost:8080/ in a browser. 4 | 5 | require 'watts' 6 | require 'hoshi' 7 | 8 | class HelloHTML < Watts::App 9 | # First, a simple, traditional greeting, done in Hoshi: 10 | class View < Hoshi::View :html5 11 | def hello 12 | doc { 13 | head { title 'Hello, World!' } 14 | body { 15 | h1 'Here is your greeting:' 16 | p 'Hello, World!' 17 | } 18 | } 19 | render 20 | end 21 | end 22 | 23 | Res = Watts::Resource.for_html_view(View, :hello) 24 | 25 | res('/', Res) 26 | end 27 | 28 | app = HelloHTML.new 29 | builder = Rack::Builder.new { run app } 30 | run builder 31 | -------------------------------------------------------------------------------- /doc/examples/matching.ru: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # An illustration of the pattern-matching capabilities of Watts. Some URLs to 3 | # try if you start this one up: 4 | # http://localhost:9292/strlen/foo (Which should tell you '3'.) 5 | # http://localhost:9292/fib/15 (Which should give you 987.) 6 | # http://localhost:9292/fib/foo (Which is a 404. 'foo' isn't a number!) 7 | # http://localhost:9292/fib/f (Which should give you 0x3db.) 8 | # http://localhost:9292/fib/0x15 (Which should give you 0x452f.) 9 | 10 | require 'watts' 11 | 12 | class MatchingDemo < Watts::App 13 | class Strlen < Watts::Resource 14 | # Takes an argument, and just returns the length of the argument. 15 | get { |str| str.length.to_s + "\n" } 16 | end 17 | 18 | class Fibonacci < Watts::Resource 19 | # This resource takes an argument for GET. It is filled in by Watts 20 | # according to the argument pattern passed into resource below. 21 | get { |n| fib(n.to_i).to_s + "\n" } 22 | 23 | # A naive, recursive, slow, text-book implementation of Fibonacci. 24 | def fib(n) 25 | if n < 2 26 | 1 27 | else 28 | fib(n - 1) + fib(n - 2) 29 | end 30 | end 31 | end 32 | 33 | # As above, but with a base-16 number. 34 | class HexFibonacci < Fibonacci 35 | get { |n| "0x" + fib(n.to_i(16)).to_s(16) + "\n" } 36 | end 37 | 38 | res('/') { 39 | # A symbol can be used to indicate an 'argument' component of a path, 40 | # which is in turn passed to the resource's method as paths. It will 41 | # match anything, making it almost equivalent to just using an empty 42 | # regex (see below), except that it can serve as documentation. 43 | res(['strlen', :str], Strlen) 44 | 45 | res('fib') { 46 | # You can match arguments based on a regex. The path component for 47 | # the regex is passed to the resource's method as part of the 48 | # argument list. 49 | res([/^[0-9]+$/], Fibonacci) 50 | 51 | # As above, but here we use hexadecimal. If the pattern for 52 | # Fibonacci doesn't match, then we'll end up hitting this one. 53 | res([/^(0x)?[0-9a-f]+$/i], HexFibonacci) 54 | } 55 | } 56 | end 57 | 58 | app = MatchingDemo.new 59 | builder = Rack::Builder.new { run app } 60 | run builder 61 | -------------------------------------------------------------------------------- /doc/examples/return.ru: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env rackup 2 | # Demonstrating the return values Watts expects from methods. Some URLs to 3 | # try if you start this one up: 4 | # http://localhost:9292/string 5 | # http://localhost:9292/array 6 | # http://localhost:9292/nil 7 | # http://localhost:9292/response 8 | 9 | require 'watts' 10 | require 'hoshi' 11 | 12 | class ReturnDemo < Watts::App 13 | SomeHTML = Hoshi::View(:html5) { 14 | doc 15 | html{body{h1 'Hello, World!';p 'This is some HTML.'}} 16 | } 17 | 18 | class RString < Watts::Resource 19 | get { "Returned a string, so it's text/plain." } 20 | end 21 | 22 | class RArray < Watts::Resource 23 | get { [200, {'content-type' => 'text/html'}, [SomeHTML]] } 24 | end 25 | 26 | class RNil < Watts::Resource 27 | get { 28 | response.body << SomeHTML 29 | response.headers.merge!({ 30 | 'content-type' => 'text/html; charset=utf-8', 31 | 'content-length' => SomeHTML.bytesize.to_s, 32 | }) 33 | nil 34 | } 35 | end 36 | 37 | class RResponse < Watts::Resource 38 | get { 39 | Rack::Response.new.tap { |r| 40 | r.body << SomeHTML 41 | r.headers['content-type'] = 'text/html' 42 | } 43 | } 44 | end 45 | 46 | res 'string', RString 47 | res 'array', RArray 48 | res 'nil', RNil 49 | res 'response', RResponse 50 | end 51 | 52 | run ReturnDemo.new 53 | -------------------------------------------------------------------------------- /ext/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pete/watts/53ce11d40df9ee67ea501e12070e0991ec849527/ext/.gitignore -------------------------------------------------------------------------------- /lib/watts.rb: -------------------------------------------------------------------------------- 1 | %w( 2 | forwardable 3 | rack 4 | set 5 | watts/monkey_patching 6 | ).each &method(:require) 7 | 8 | # Here's the main module, Watts. 9 | module Watts 10 | # You are unlikely to need to interact with this. It's mainly for covering 11 | # up the path-matching logic for Resources. 12 | class Path 13 | extend Forwardable 14 | include Enumerable 15 | 16 | attr_accessor :resource 17 | attr_new Hash, :sub_paths 18 | 19 | def match path, args 20 | if path.empty? 21 | [resource, args] if resource 22 | elsif(sub = self[path[0]]) 23 | sub.match(path[1..-1], args) 24 | else 25 | each { |k,sub| 26 | if k.kind_of?(Regexp) && k.match(path[0]) 27 | return sub.match(path[1..-1], args + [path[0]]) 28 | end 29 | } 30 | each { |k,sub| 31 | if k.kind_of?(Symbol) 32 | return sub.match(path[1..-1], args + [path[0]]) 33 | end 34 | } 35 | nil 36 | end 37 | end 38 | 39 | def rmatch res, args, acc = [] 40 | return "/#{acc.join('/')}" if args.empty? && resource == res 41 | 42 | rest = args[1..-1] || [] 43 | accnext = acc.dup << args[0] 44 | 45 | each { |k,sub| 46 | if k.kind_of?(String) 47 | t = sub.rmatch res, args, acc + [k] 48 | return t if t 49 | elsif k.kind_of?(Regexp) && k.match(args[0]) 50 | t = sub.rmatch res, rest, accnext 51 | return t if t 52 | elsif k.kind_of?(Symbol) 53 | t = sub.rmatch res, rest, accnext 54 | return t if t 55 | end 56 | } 57 | 58 | nil 59 | end 60 | 61 | def_delegators :sub_paths, :'[]', :'[]=', :each 62 | end 63 | 64 | # In order to have a Watts app, you'll want to subclass Watts::App. For a 65 | # good time, you'll also probably want to provide some resources to that 66 | # class using the resource method, which maps paths to resources. 67 | class App 68 | Errors = { 69 | 400 => 70 | [400, {'content-type' => 'text/plain'}, ["400 Bad Request.\n"]], 71 | 404 => 72 | [404, {'content-type' => 'text/plain'}, ["404 Not Found\n"]], 73 | 501 => 74 | [501, {'content-type' => 'text/plain'}, 75 | ["501 Not Implemented.\n"]], 76 | } 77 | 78 | # The "empty" set. 79 | ESet = Set.new(['/', '']) 80 | 81 | # Method name cache. Maps HTTP methods to object methods. 82 | MNCache = Hash.new { |h,k| 83 | h[k] = k.downcase.to_sym 84 | } 85 | # Prefill MNCache above with the likely culprits: 86 | %w( 87 | GET PUT POST DELETE OPTIONS HEAD TRACE CONNECT PATCH 88 | ).each &MNCache.method(:'[]') 89 | 90 | class << self 91 | attr_new Hash, :http_methods 92 | attr_new Watts::Path, :path_map 93 | attr_new Array, :path_stack 94 | attr_writer :path_stack 95 | end 96 | 97 | def self.decypher_path p 98 | return p if p.kind_of?(Array) 99 | return [] if ESet.include?(p) 100 | return [p] if p.kind_of?(Regexp) || p.kind_of?(Symbol) 101 | p.split('/').tap { |a| a.reject! { |c| '' == c } } #(&''.method(:'==')) } 102 | end 103 | 104 | to_instance :path_map, :decypher_path, :path_to 105 | 106 | # If you want your Watts application to do anything at all, you're very 107 | # likely to want to call this method at least once. The basic purpose 108 | # of the method is to tell your app how to match a resource to a path. 109 | # For example, if you create a resource (see Watts::Resource) Foo, and 110 | # you want requests against '/foo' to match it, you could do this: 111 | # resource('foo', Foo) 112 | # 113 | # The first argument is the path, and the second is the resource that 114 | # path is to match. (Please see the README for more detailed 115 | # documentation of path-matching.) You may also pass it a block, in 116 | # which resources that are defined are 'namespaced'. For example, if 117 | # you also had a resource called Bar and wanted its path to be a 118 | # sub-path of the Foo resource's (e.g., '/foo/bar'), then typing these 119 | # lines is a pretty good plan: 120 | # resource('foo', Foo) { 121 | # resource('bar', Bar) 122 | # } 123 | # 124 | # Lastly, the resource argument itself is optional, for when you want a 125 | # set of resources to be namespaced under a given path, but don't 126 | # have a resource in mind. For example, if you suddenly needed your 127 | # entire application to reside under '/api', you could do this: 128 | # resource('api') { 129 | # resource('foo', Foo) { 130 | # resource('bar', Bar) 131 | # resource('baz', Baz) 132 | # } 133 | # } 134 | # 135 | # This is probably the most important method in Watts. Have a look at 136 | # the README and the example applications under doc/examples if you 137 | # want to understand the pattern-matching, arguments to resources, etc. 138 | def self.resource(path, res = nil, &b) 139 | path = decypher_path(path) 140 | 141 | last = (path_stack + path).inject(path_map) { |m,p| 142 | m[p] ||= Path.new 143 | } 144 | last.resource = res 145 | 146 | if b 147 | old_stack = path_stack 148 | self.path_stack = old_stack + path 149 | b.call 150 | self.path_stack = old_stack 151 | end 152 | res 153 | end 154 | # And because res(...) is a little less distracting than resource(...): 155 | class << self; alias_method :res, :resource; end 156 | 157 | # Given a resource (and, optionally, arguments if the path requires 158 | # them), this method returns an absolute path to the resource. 159 | def self.path_to res, *args 160 | path_map.rmatch res, args 161 | end 162 | 163 | # Given a path, returns the matching resource, if any. 164 | def match req_path 165 | req_path = decypher_path req_path 166 | path_map.match req_path, [] 167 | end 168 | 169 | # Our interaction with Rack. 170 | def call env, req_path = nil 171 | rm = MNCache[env['REQUEST_METHOD']] 172 | return(Errors[501]) unless Resource::HTTPMethods.include?(rm) 173 | 174 | req_path ||= decypher_path env['PATH_INFO'] 175 | resource_class, args = path_map.match req_path, [] 176 | return Errors[404] unless resource_class 177 | 178 | res = resource_class.new env, self 179 | res.send(rm, *args) 180 | end 181 | end 182 | 183 | # HTTP is all about resources, and this class represents them. You'll want 184 | # to subclass it and then define some HTTP methods on it, then use 185 | # your application's resource method to tell it where to find these 186 | # resources. (See Watts::App.resource().) If you want your resource to 187 | # respond to GET with a cheery, text/plain greeting, for example: 188 | # class Foo < Watts::Resource 189 | # get { || "Hello, world!" } 190 | # end 191 | # 192 | # Or you could do something odd like this: 193 | # class RTime < Watts::Resource 194 | # class << self; attr_accessor :last_post_time; end 195 | # 196 | # get { || "The last POST was #{last_post_time}." } 197 | # post { || 198 | # self.class.last_post_time = Time.now.strftime('%F %R') 199 | # [204, {}, []] 200 | # } 201 | # 202 | # def last_post_time 203 | # self.class.last_post_time || "...never" 204 | # end 205 | # end 206 | # 207 | # It is also possible to define methods in the usual way (e.g., `def get 208 | # ...`), although you'll need to add them to the list of allowed methods 209 | # (for OPTIONS) manually. Have a look at the README and doc/examples. 210 | class Resource 211 | HTTPMethods = 212 | Set.new(%i(get post put delete head options trace connect patch)) 213 | 214 | class << self; attr_accessor :http_methods; end 215 | def self.inherited base 216 | base.http_methods = (http_methods || []).dup 217 | end 218 | 219 | # For each method allowed by HTTP, we define a "Method not allowed" 220 | # response, and a method for generating a method. You may also just 221 | # def methods, as seen below for the options method. 222 | HTTPMethods.each { |http_method| 223 | define_singleton_method(http_method) { |&b| 224 | (http_methods << http_method.to_s.upcase).uniq! 225 | bmname = :"__#{http_method}" 226 | define_method(bmname, &b) 227 | define_method(http_method) { |*args| 228 | begin 229 | resp = send bmname, *args 230 | rescue ArgumentError => e 231 | # TODO: Arity/path args mismatch handler here. 232 | # ...Maybe. It seems appropriate, but I've never 233 | # needed it. 234 | raise e 235 | end 236 | 237 | # TODO: Problems. 238 | case resp 239 | when nil 240 | response.to_a 241 | when Array 242 | resp 243 | when Rack::Response 244 | resp.to_a 245 | else 246 | resp = resp.to_s 247 | [ 248 | 200, 249 | {'content-type' => 'text/plain', 250 | 'content-length' => resp.bytesize.to_s, 251 | }, 252 | [resp] 253 | ] 254 | end 255 | } 256 | } 257 | define_method(http_method) { |*args| default_http_method(*args) } 258 | } 259 | 260 | # This method is for creating Resources that simply wrap first-class 261 | # HTML views. It was created with Hoshi in mind, although you can use 262 | # any class that can be instantiated and render some HTML when the 263 | # specified method is called. It takes two arguments: the view class, 264 | # and the method to call to render the HTML. 265 | def self.for_html_view klass, method 266 | c = Class.new HTMLViewResource 267 | c.view_class = klass 268 | c.view_method = method 269 | c 270 | end 271 | 272 | # This method generates a HEAD that just calls #get and only passes 273 | # back the headers. It's sub-optimal when GET is expensive, so it is 274 | # disabled by default. 275 | def self.auto_head 276 | head { |*a| 277 | status, headers, = get(*a) 278 | [status, headers, []] 279 | } 280 | end 281 | 282 | to_instance :http_methods 283 | attr_new Rack::Response, :response 284 | attr_accessor :env, :app 285 | 286 | # Every resource, on being instantiated, is given the Rack env. 287 | def initialize(env, app = nil) 288 | @env, @app = env, app 289 | end 290 | 291 | # The default options method, to comply with RFC 2616, returns a list 292 | # of allowed methods in the Allow header. These are filled in when the 293 | # method-defining methods (i.e., get() et al) are called. 294 | def options(*args) 295 | [ 296 | 200, 297 | { 298 | 'content-length' => '0', # cf. RFC 2616 299 | 'content-type' => 'text/plain', # Appease Rack::Lint 300 | 'allow' => http_methods.join(', ') 301 | }, 302 | [] 303 | ] 304 | end 305 | 306 | def request 307 | @request ||= Rack::Request.new(env) 308 | end 309 | 310 | # By default, we return "405 Method Not Allowed" and set the Allow: 311 | # header appropriately. 312 | def default_http_method(*args) 313 | s = 'Method not allowed.' 314 | [405, 315 | { 'allow' => http_methods.join(', '), 316 | 'content-type' => 'text/plain', 317 | 'content-length' => s.bytesize.to_s, 318 | }, 319 | ['Method not allowed.']] 320 | end 321 | 322 | # HEAD responds the same way as the default_method, but does not return 323 | # a body. 324 | def head(*args) 325 | r = default_http_method 326 | r[2] = [] 327 | r 328 | end 329 | end 330 | 331 | # See the documentation for Watts::Resource.for_html_view(). 332 | # Semi-deprecated: I don't use this anywhere besides my blog, and I don't 333 | # think anyone else uses it. 334 | class HTMLViewResource < Resource 335 | class << self 336 | attr_writer :view_class, :view_method 337 | end 338 | 339 | def self.view_class 340 | @view_class ||= (superclass.view_class rescue nil) 341 | end 342 | 343 | def self.view_method 344 | @view_method ||= (superclass.view_method rescue nil) 345 | end 346 | 347 | to_instance :view_class, :view_method 348 | 349 | def get *args 350 | body = view_class.new.send(view_method, *args) 351 | [200, 352 | {'content-type' => 'text/html', 353 | 'content-length' => body.bytesize.to_s}, 354 | [body]] 355 | end 356 | end 357 | end 358 | -------------------------------------------------------------------------------- /lib/watts/monkey_patching.rb: -------------------------------------------------------------------------------- 1 | # This is the place to stuff all of the monkey-patches. 2 | 3 | class Class 4 | # Has instances delegate methods to the class. 5 | def to_instance *ms 6 | ms.each { |m| 7 | define_method(m) { |*a| 8 | self.class.send(m, *a) 9 | } 10 | } 11 | end 12 | 13 | # A replacement for def x; @x ||= Y.new; end 14 | def attr_new klass, *attrs 15 | attrs.each { |attr| 16 | ivname = "@#{attr}" 17 | define_method(attr) { 18 | ivval = instance_variable_get(ivname) 19 | return ivval if ivval 20 | instance_variable_set(ivname, klass.new) 21 | } 22 | } 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /test/app_test.rb: -------------------------------------------------------------------------------- 1 | require 'test/unit' 2 | require 'watts' 3 | 4 | class AppTest < Test::Unit::TestCase 5 | def test_responses 6 | %w(str resp arr).each { |path| 7 | resp = mkreq "/#{path}" 8 | assert_equal 200, resp.status, 9 | "Expect 200 OK from /#{path}" 10 | assert_equal 'test', resp.body, 11 | "Expect the appropriate body from /#{path}" 12 | } 13 | end 14 | 15 | def test_bad_methods 16 | resp = mkreq "/str", 'DOODLE' 17 | assert_equal 501, resp.status, 18 | "Expect that responses to bad methods comply with RFC2616." 19 | end 20 | 21 | def test_404 22 | resp = mkreq "/notfound" 23 | assert_equal 404, resp.status, 24 | "Expect to not find /notfound." 25 | end 26 | 27 | def test_request 28 | resp = mkreq '/method' 29 | assert_equal 'GET', resp.body, 30 | "Expected the request method in the body." 31 | end 32 | 33 | def test_resource_args 34 | # This behavior may or may not be desirable. It is unfortunate that the 35 | # pattern-matching and the arity of the methods can't line up completely. 36 | assert_raise_kind_of(ArgumentError) { 37 | resp = mkreq "/arity/mismatch" 38 | } 39 | end 40 | 41 | def app 42 | @app ||= begin 43 | gr = method(:getres) # Scoping! 44 | app = Class.new(Watts::App) { 45 | res('str', gr.call { 'test' }) 46 | res('resp', gr.call { response.body << 'test';nil }) 47 | res('arr', gr.call { [200, {}, ['test']] }) 48 | 49 | res(['arity', :mismatch], gr.call { || 'test' }) 50 | res('method', gr.call { request.env['REQUEST_METHOD'] }) 51 | }.new 52 | end 53 | end 54 | 55 | def getres &b 56 | Class.new(Watts::Resource) { 57 | get &b 58 | } 59 | end 60 | 61 | def mkreq path, m = 'GET' 62 | req = Rack::MockRequest.new app 63 | req.request(m, path) 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /test/coverage.rb: -------------------------------------------------------------------------------- 1 | require 'simplecov' 2 | SimpleCov.start { 3 | add_filter "test/" 4 | } -------------------------------------------------------------------------------- /test/methods_test.rb: -------------------------------------------------------------------------------- 1 | require 'test/unit' 2 | require 'watts' 3 | 4 | class MethodsTest < Test::Unit::TestCase 5 | def test_auto_head 6 | r = Class.new(Watts::Resource) 7 | env = {} 8 | 9 | r.get { |arg| [9001, {'no' => arg}, ["LENGTHY"]] } 10 | getresp = r.new(env).get(:asdf) 11 | headresp = r.new(env).head 12 | 13 | assert_equal 9001, getresp[0] 14 | assert_equal 405, headresp[0] 15 | 16 | r.auto_head 17 | autoheadresp = r.new(env).head(:asdf) 18 | assert_equal getresp[0..1], autoheadresp[0..1] 19 | assert_equal [], autoheadresp[2] 20 | end 21 | 22 | def test_accept 23 | r = Class.new(Watts::Resource) 24 | r.get { } 25 | resp = r.new({}).options 26 | assert resp[1]['allow'], "Should have an Allow: header." 27 | assert_equal %w(GET), resp[1]['allow'].split(/, */).sort, 28 | "Should allow only GET." 29 | rs = Class.new(r) 30 | r.get { } 31 | r.auto_head 32 | resp = r.new({}).options 33 | assert resp[1]['allow'], "Should have an Allow: header." 34 | assert_equal %w(GET HEAD), resp[1]['allow'].split(/, */).sort, 35 | "Should allow GET and HEAD." 36 | resp = r.new({}).post 37 | assert_equal 405, resp[0], 38 | "Should give us 'Method not allowed'." 39 | assert resp[1]['allow'], "Should have an Allow: header." 40 | assert_equal %w(GET HEAD), resp[1]['allow'].split(/, */).sort, 41 | "Should specify that only GET and HEAD are allowed." 42 | 43 | rp = Class.new(r) 44 | resp = r.new({}).options 45 | respp = r.new({}).options 46 | assert_equal resp, respp, 47 | "Subclasses should inherit Allow: values." 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /test/route_test.rb: -------------------------------------------------------------------------------- 1 | require 'test/unit' 2 | require 'watts' 3 | 4 | class RouteTest < Test::Unit::TestCase 5 | def test_matching 6 | r = Hash.new { |h,k| 7 | h[k] = Class.new(Watts::Resource) { 8 | %i(name inspect to_s).each { |m| 9 | define_singleton_method(m) { "TestResource#{k}" } 10 | } 11 | } 12 | } 13 | 14 | app = Class.new(Watts::App) { 15 | res('/', r[1]) { 16 | res(['one', :two], r[2]) { 17 | res(/three/, r[3]) { resource('4', r[4]) } 18 | res(:five, r[5]) 19 | } 20 | } 21 | }.new 22 | 23 | # We expect for match to return [resource, args] or nil. 24 | assert_equal [r[1], []], app.match('/') 25 | assert_nil app.match('/one') 26 | assert_nil app.match('/one/') 27 | assert_equal [r[2], ['two']], app.match('/one/two') 28 | assert_equal [r[3], ['two', 'three']], app.match('/one/two/three') 29 | assert_equal [r[4], ['two', 'three']], app.match('/one/two/three/4') 30 | assert_equal [r[5], ['tu', 'faib']], app.match('/one/tu/faib') 31 | assert_nil app.match('/one/two/three/four') 32 | assert_nil app.match('Just some random gibberish. Bad client!') 33 | 34 | # Next, we check to make sure that we can generate a path to the 35 | # resources. 36 | assert_equal '/', app.path_to(r[1]) 37 | assert_equal '/one/asdf', app.path_to(r[2], 'asdf') 38 | assert_equal '/one/2/three', app.path_to(r[3], '2', 'three') 39 | assert_equal '/one/2/3three3', app.path_to(r[3], '2', '3three3') 40 | assert_equal '/one/2/3three/4', app.path_to(r[4], '2', '3three') 41 | assert_equal '/one/tu/faib', app.path_to(r[5], 'tu', 'faib') 42 | 43 | assert_equal nil, app.path_to(r[3]) 44 | assert_equal nil, app.path_to(r[3], '2', '3') 45 | end 46 | end 47 | --------------------------------------------------------------------------------