restfulness 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +18 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +342 -0
- data/Rakefile +1 -0
- data/example/Gemfile +5 -0
- data/example/README.md +42 -0
- data/example/app.rb +42 -0
- data/example/config.ru +5 -0
- data/lib/restfulness.rb +36 -0
- data/lib/restfulness/application.rb +83 -0
- data/lib/restfulness/dispatcher.rb +14 -0
- data/lib/restfulness/dispatchers/rack.rb +91 -0
- data/lib/restfulness/exceptions.rb +17 -0
- data/lib/restfulness/log_formatters/quiet_formatter.rb +7 -0
- data/lib/restfulness/log_formatters/verbose_formatter.rb +16 -0
- data/lib/restfulness/path.rb +46 -0
- data/lib/restfulness/request.rb +81 -0
- data/lib/restfulness/resource.rb +111 -0
- data/lib/restfulness/response.rb +57 -0
- data/lib/restfulness/route.rb +48 -0
- data/lib/restfulness/router.rb +27 -0
- data/lib/restfulness/statuses.rb +71 -0
- data/lib/restfulness/version.rb +3 -0
- data/restfulness.gemspec +29 -0
- data/spec/spec_helper.rb +13 -0
- data/spec/unit/application_spec.rb +91 -0
- data/spec/unit/dispatcher_spec.rb +14 -0
- data/spec/unit/dispatchers/rack_spec.rb +34 -0
- data/spec/unit/exceptions_spec.rb +21 -0
- data/spec/unit/path_spec.rb +91 -0
- data/spec/unit/request_spec.rb +129 -0
- data/spec/unit/resource_spec.rb +245 -0
- data/spec/unit/response_spec.rb +65 -0
- data/spec/unit/route_spec.rb +160 -0
- data/spec/unit/router_spec.rb +103 -0
- metadata +189 -0
@@ -0,0 +1,111 @@
|
|
1
|
+
module Restfulness
|
2
|
+
|
3
|
+
class Resource
|
4
|
+
|
5
|
+
attr_reader :request, :response
|
6
|
+
|
7
|
+
def initialize(request, response)
|
8
|
+
@request = request
|
9
|
+
@response = response
|
10
|
+
end
|
11
|
+
|
12
|
+
# Options is the only HTTP method support by default
|
13
|
+
def options
|
14
|
+
response.headers['Allow'] = self.class.supported_methods.map{ |m|
|
15
|
+
m.to_s.upcase
|
16
|
+
}.join(', ')
|
17
|
+
nil
|
18
|
+
end
|
19
|
+
|
20
|
+
def call
|
21
|
+
send(request.action)
|
22
|
+
end
|
23
|
+
|
24
|
+
# Callbacks
|
25
|
+
|
26
|
+
def method_allowed?
|
27
|
+
self.class.supported_methods.include?(request.action)
|
28
|
+
end
|
29
|
+
|
30
|
+
def exists?
|
31
|
+
true
|
32
|
+
end
|
33
|
+
|
34
|
+
def authorized?
|
35
|
+
true
|
36
|
+
end
|
37
|
+
|
38
|
+
def allowed?
|
39
|
+
true
|
40
|
+
end
|
41
|
+
|
42
|
+
def last_modified
|
43
|
+
nil
|
44
|
+
end
|
45
|
+
|
46
|
+
def etag
|
47
|
+
nil
|
48
|
+
end
|
49
|
+
|
50
|
+
def check_callbacks
|
51
|
+
# Access control
|
52
|
+
raise HTTPException.new(405) unless method_allowed?
|
53
|
+
raise HTTPException.new(401) unless authorized?
|
54
|
+
raise HTTPException.new(403) unless allowed?
|
55
|
+
|
56
|
+
# The following callbacks only make sense for certain methods
|
57
|
+
if [:head, :get, :put, :delete].include?(request.action)
|
58
|
+
|
59
|
+
raise HTTPException.new(404) unless exists?
|
60
|
+
|
61
|
+
if [:get, :head].include?(request.action)
|
62
|
+
# Resource status
|
63
|
+
check_etag if etag
|
64
|
+
check_if_modified if last_modified
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
##
|
70
|
+
|
71
|
+
|
72
|
+
protected
|
73
|
+
|
74
|
+
def error(code, payload = nil, opts = {})
|
75
|
+
raise HTTPException.new(code, payload, opts)
|
76
|
+
end
|
77
|
+
|
78
|
+
def logger
|
79
|
+
Restfulness.logger
|
80
|
+
end
|
81
|
+
|
82
|
+
|
83
|
+
private
|
84
|
+
|
85
|
+
def check_if_modified
|
86
|
+
date = request.headers[:if_modified_since]
|
87
|
+
if date && date == last_modified.to_s
|
88
|
+
raise HTTPException.new(304)
|
89
|
+
end
|
90
|
+
response.headers['Last-Modified'] = last_modified
|
91
|
+
end
|
92
|
+
|
93
|
+
def check_etag
|
94
|
+
tag = request.headers[:if_none_match]
|
95
|
+
if tag && tag == etag.to_s
|
96
|
+
raise HTTPException.new(304)
|
97
|
+
end
|
98
|
+
response.headers['ETag'] = etag
|
99
|
+
end
|
100
|
+
|
101
|
+
class << self
|
102
|
+
|
103
|
+
def supported_methods
|
104
|
+
@_actions ||= (instance_methods & [:get, :put, :post, :delete, :head, :patch, :options])
|
105
|
+
end
|
106
|
+
|
107
|
+
end
|
108
|
+
|
109
|
+
end
|
110
|
+
|
111
|
+
end
|
@@ -0,0 +1,57 @@
|
|
1
|
+
|
2
|
+
module Restfulness
|
3
|
+
|
4
|
+
class Response
|
5
|
+
|
6
|
+
# Incoming data
|
7
|
+
attr_reader :request
|
8
|
+
|
9
|
+
# Outgoing data
|
10
|
+
attr_reader :code, :headers, :payload
|
11
|
+
|
12
|
+
|
13
|
+
def initialize(request)
|
14
|
+
@request = request
|
15
|
+
|
16
|
+
# Default headers
|
17
|
+
@headers = {'Content-Type' => 'application/json; charset=utf-8'}
|
18
|
+
end
|
19
|
+
|
20
|
+
def run
|
21
|
+
logger.info("Responding to #{request.action.to_s.upcase} #{request.uri.to_s} from #{request.remote_ip}")
|
22
|
+
|
23
|
+
route = request.route
|
24
|
+
if route
|
25
|
+
logger.info("Using resource: #{route.resource_name}")
|
26
|
+
resource = route.build_resource(request, self)
|
27
|
+
|
28
|
+
# run callbacks, if any fail, they'll raise an error
|
29
|
+
resource.check_callbacks
|
30
|
+
|
31
|
+
# Perform the actual work
|
32
|
+
result = resource.call
|
33
|
+
|
34
|
+
@code ||= (result ? 200 : 204)
|
35
|
+
@payload = MultiJson.encode(result)
|
36
|
+
else
|
37
|
+
logger.error("No route found")
|
38
|
+
# This is not something we can deal with, pass it on
|
39
|
+
@code = 404
|
40
|
+
@payload = ""
|
41
|
+
end
|
42
|
+
update_content_length
|
43
|
+
end
|
44
|
+
|
45
|
+
def logger
|
46
|
+
Restfulness.logger
|
47
|
+
end
|
48
|
+
|
49
|
+
protected
|
50
|
+
|
51
|
+
def update_content_length
|
52
|
+
@headers['Content-Length'] = @payload.bytesize.to_s
|
53
|
+
end
|
54
|
+
|
55
|
+
end
|
56
|
+
|
57
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
module Restfulness
|
2
|
+
|
3
|
+
class Route
|
4
|
+
|
5
|
+
# The path array of eliments, :id always on end!
|
6
|
+
attr_accessor :path
|
7
|
+
|
8
|
+
# Reference to the class that will handle requests for this route
|
9
|
+
attr_accessor :resource_name
|
10
|
+
|
11
|
+
def initialize(*args)
|
12
|
+
self.resource_name = args.pop.to_s
|
13
|
+
self.path = args.reject{|arg| arg == :id}
|
14
|
+
|
15
|
+
if resource_name.empty? || resource.nil? # Try to load the resource
|
16
|
+
raise ArgumentError, "Please provide a resource!"
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
def build_path(path)
|
21
|
+
Path.new(self, path)
|
22
|
+
end
|
23
|
+
|
24
|
+
def handles?(parts)
|
25
|
+
# Make sure same length (accounting for id)
|
26
|
+
diff = parts.length - path.length
|
27
|
+
return false if diff != 0 && diff != 1
|
28
|
+
|
29
|
+
# Compare the pairs
|
30
|
+
path.each_with_index do |slug, i|
|
31
|
+
if slug.is_a?(String) or slug.is_a?(Numeric)
|
32
|
+
return false if parts[i] != slug.to_s
|
33
|
+
end
|
34
|
+
end
|
35
|
+
true
|
36
|
+
end
|
37
|
+
|
38
|
+
def resource
|
39
|
+
resource_name.constantize
|
40
|
+
end
|
41
|
+
|
42
|
+
def build_resource(request, response)
|
43
|
+
resource.new(request, response)
|
44
|
+
end
|
45
|
+
|
46
|
+
end
|
47
|
+
|
48
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
|
2
|
+
module Restfulness
|
3
|
+
|
4
|
+
class Router
|
5
|
+
|
6
|
+
attr_accessor :routes
|
7
|
+
|
8
|
+
def initialize(&block)
|
9
|
+
self.routes = []
|
10
|
+
instance_eval(&block) if block_given?
|
11
|
+
end
|
12
|
+
|
13
|
+
def add(*args)
|
14
|
+
routes << Route.new(*args)
|
15
|
+
end
|
16
|
+
|
17
|
+
def route_for(path)
|
18
|
+
parts = path.gsub(/^\/|\/$/, '').split(/\//)
|
19
|
+
routes.each do |route|
|
20
|
+
return route if route.handles?(parts)
|
21
|
+
end
|
22
|
+
nil
|
23
|
+
end
|
24
|
+
|
25
|
+
end
|
26
|
+
|
27
|
+
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
module Restfulness
|
2
|
+
|
3
|
+
#
|
4
|
+
# List of standard HTTP statuses blatently stolen from Ruby RestClient.
|
5
|
+
#
|
6
|
+
# https://github.com/rest-client/rest-client
|
7
|
+
#
|
8
|
+
|
9
|
+
STATUSES = {
|
10
|
+
100 => 'Continue',
|
11
|
+
101 => 'Switching Protocols',
|
12
|
+
102 => 'Processing', #WebDAV
|
13
|
+
|
14
|
+
200 => 'OK',
|
15
|
+
201 => 'Created',
|
16
|
+
202 => 'Accepted',
|
17
|
+
203 => 'Non-Authoritative Information', # http/1.1
|
18
|
+
204 => 'No Content',
|
19
|
+
205 => 'Reset Content',
|
20
|
+
206 => 'Partial Content',
|
21
|
+
207 => 'Multi-Status', #WebDAV
|
22
|
+
|
23
|
+
300 => 'Multiple Choices',
|
24
|
+
301 => 'Moved Permanently',
|
25
|
+
302 => 'Found',
|
26
|
+
303 => 'See Other', # http/1.1
|
27
|
+
304 => 'Not Modified',
|
28
|
+
305 => 'Use Proxy', # http/1.1
|
29
|
+
306 => 'Switch Proxy', # no longer used
|
30
|
+
307 => 'Temporary Redirect', # http/1.1
|
31
|
+
|
32
|
+
400 => 'Bad Request',
|
33
|
+
401 => 'Unauthorized',
|
34
|
+
402 => 'Payment Required',
|
35
|
+
403 => 'Forbidden',
|
36
|
+
404 => 'Resource Not Found',
|
37
|
+
405 => 'Method Not Allowed',
|
38
|
+
406 => 'Not Acceptable',
|
39
|
+
407 => 'Proxy Authentication Required',
|
40
|
+
408 => 'Request Timeout',
|
41
|
+
409 => 'Conflict',
|
42
|
+
410 => 'Gone',
|
43
|
+
411 => 'Length Required',
|
44
|
+
412 => 'Precondition Failed',
|
45
|
+
413 => 'Request Entity Too Large',
|
46
|
+
414 => 'Request-URI Too Long',
|
47
|
+
415 => 'Unsupported Media Type',
|
48
|
+
416 => 'Requested Range Not Satisfiable',
|
49
|
+
417 => 'Expectation Failed',
|
50
|
+
418 => 'I\'m A Teapot',
|
51
|
+
421 => 'Too Many Connections From This IP',
|
52
|
+
422 => 'Unprocessable Entity', #WebDAV
|
53
|
+
423 => 'Locked', #WebDAV
|
54
|
+
424 => 'Failed Dependency', #WebDAV
|
55
|
+
425 => 'Unordered Collection', #WebDAV
|
56
|
+
426 => 'Upgrade Required',
|
57
|
+
449 => 'Retry With', #Microsoft
|
58
|
+
450 => 'Blocked By Windows Parental Controls', #Microsoft
|
59
|
+
|
60
|
+
500 => 'Internal Server Error',
|
61
|
+
501 => 'Not Implemented',
|
62
|
+
502 => 'Bad Gateway',
|
63
|
+
503 => 'Service Unavailable',
|
64
|
+
504 => 'Gateway Timeout',
|
65
|
+
505 => 'HTTP Version Not Supported',
|
66
|
+
506 => 'Variant Also Negotiates',
|
67
|
+
507 => 'Insufficient Storage', #WebDAV
|
68
|
+
509 => 'Bandwidth Limit Exceeded', #Apache
|
69
|
+
510 => 'Not Extended'
|
70
|
+
}
|
71
|
+
end
|
data/restfulness.gemspec
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'restfulness/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "restfulness"
|
8
|
+
spec.version = Restfulness::VERSION
|
9
|
+
spec.authors = ["Sam Lown"]
|
10
|
+
spec.email = ["me@samlown.com"]
|
11
|
+
spec.description = %q{Simple REST server that focuses on resources instead of routes.}
|
12
|
+
spec.summary = %q{Use to create a powerful, yet simple REST API in your application.}
|
13
|
+
spec.homepage = "https://github.com/samlown/restfulness"
|
14
|
+
spec.license = "MIT"
|
15
|
+
|
16
|
+
spec.files = `git ls-files`.split($/)
|
17
|
+
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
18
|
+
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
19
|
+
spec.require_paths = ["lib"]
|
20
|
+
|
21
|
+
spec.add_dependency "rack", "~> 1.4"
|
22
|
+
spec.add_dependency "multi_json", "~> 1.8"
|
23
|
+
spec.add_dependency "activesupport", ">= 3.1"
|
24
|
+
spec.add_dependency "mono_logger", "~> 1.0"
|
25
|
+
|
26
|
+
spec.add_development_dependency "bundler", "~> 1.3"
|
27
|
+
spec.add_development_dependency "rake"
|
28
|
+
spec.add_development_dependency "rspec"
|
29
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
|
2
|
+
require 'rubygems'
|
3
|
+
require 'bundler/setup'
|
4
|
+
|
5
|
+
require 'restfulness' # and any other gems you need
|
6
|
+
|
7
|
+
RSpec.configure do |config|
|
8
|
+
config.color_enabled = true
|
9
|
+
end
|
10
|
+
|
11
|
+
# Disable any logger output
|
12
|
+
Restfulness.logger.formatter = proc {|severity, datetime, progname, msg| ""}
|
13
|
+
|
@@ -0,0 +1,91 @@
|
|
1
|
+
|
2
|
+
require 'spec_helper'
|
3
|
+
|
4
|
+
describe Restfulness::Application do
|
5
|
+
|
6
|
+
let :klass do
|
7
|
+
Class.new(Restfulness::Application) do
|
8
|
+
routes do
|
9
|
+
# nothing
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
describe "#router" do
|
15
|
+
it "should access class's router" do
|
16
|
+
obj = klass.new
|
17
|
+
obj.router.should eql(klass.router)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
describe "#call" do
|
22
|
+
it "should build rack app and call with env" do
|
23
|
+
env = {}
|
24
|
+
obj = klass.new
|
25
|
+
app = double(:app)
|
26
|
+
app.should_receive(:call).with(env)
|
27
|
+
obj.should_receive(:build_rack_app).and_return(app)
|
28
|
+
obj.call(env)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
describe "#build_rack_app (protected)" do
|
33
|
+
it "should build a new rack app with middlewares" do
|
34
|
+
obj = klass.new
|
35
|
+
app = obj.send(:build_rack_app)
|
36
|
+
app.should be_a(Rack::Builder)
|
37
|
+
# Note, this might brake if Rack changes!
|
38
|
+
app.instance_variable_get(:@use).first.call.should be_a(klass.middlewares.first)
|
39
|
+
app.instance_variable_get(:@run).should be_a(Restfulness::Dispatchers::Rack)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
describe ".routes" do
|
44
|
+
|
45
|
+
context "basic usage" do
|
46
|
+
it "should build a new router with block" do
|
47
|
+
klass.router.should_not be_nil
|
48
|
+
klass.router.should be_a(Restfulness::Router)
|
49
|
+
end
|
50
|
+
|
51
|
+
it "should be accessable from instance" do
|
52
|
+
obj = klass.new
|
53
|
+
obj.router.should eql(klass.router)
|
54
|
+
end
|
55
|
+
|
56
|
+
it "should pass block to Router instance" do
|
57
|
+
block = lambda { }
|
58
|
+
Restfulness::Router.should_receive(:new).with(&block)
|
59
|
+
Class.new(Restfulness::Application) do
|
60
|
+
routes &block
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
end
|
66
|
+
|
67
|
+
describe ".middlewares" do
|
68
|
+
it "should provide simple array of middlewares" do
|
69
|
+
klass.middlewares.should be_a(Array)
|
70
|
+
klass.middlewares.should include(Rack::ShowExceptions)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
describe ".logger" do
|
75
|
+
it "should return main logger" do
|
76
|
+
klass.logger.should eql(Restfulness.logger)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
describe ".logger=" do
|
81
|
+
it "should set main logger" do
|
82
|
+
orig = Restfulness.logger
|
83
|
+
logger = double(:Logger)
|
84
|
+
klass.logger = logger
|
85
|
+
Restfulness.logger.should eql(logger)
|
86
|
+
Restfulness.logger = orig
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
|
91
|
+
end
|