cannon 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. checksums.yaml +7 -0
  2. data/.bundle/config +2 -0
  3. data/.gitignore +4 -0
  4. data/.rspec +2 -0
  5. data/Gemfile +5 -0
  6. data/README +3 -0
  7. data/Rakefile +20 -0
  8. data/bin/bundler +16 -0
  9. data/bin/coderay +16 -0
  10. data/bin/htmldiff +16 -0
  11. data/bin/ldiff +16 -0
  12. data/bin/mustache +16 -0
  13. data/bin/pry +16 -0
  14. data/bin/rspec +16 -0
  15. data/cannon.gemspec +29 -0
  16. data/lib/cannon.rb +16 -0
  17. data/lib/cannon/app.rb +196 -0
  18. data/lib/cannon/concerns/path_cache.rb +48 -0
  19. data/lib/cannon/concerns/signature.rb +14 -0
  20. data/lib/cannon/config.rb +58 -0
  21. data/lib/cannon/cookie_jar.rb +99 -0
  22. data/lib/cannon/handler.rb +25 -0
  23. data/lib/cannon/middleware.rb +47 -0
  24. data/lib/cannon/middleware/content_type.rb +15 -0
  25. data/lib/cannon/middleware/cookies.rb +18 -0
  26. data/lib/cannon/middleware/files.rb +33 -0
  27. data/lib/cannon/middleware/flush_and_benchmark.rb +20 -0
  28. data/lib/cannon/middleware/request_logger.rb +13 -0
  29. data/lib/cannon/middleware/router.rb +19 -0
  30. data/lib/cannon/request.rb +36 -0
  31. data/lib/cannon/response.rb +165 -0
  32. data/lib/cannon/route.rb +80 -0
  33. data/lib/cannon/route_action.rb +92 -0
  34. data/lib/cannon/version.rb +3 -0
  35. data/lib/cannon/views.rb +28 -0
  36. data/spec/app_spec.rb +20 -0
  37. data/spec/config_spec.rb +41 -0
  38. data/spec/environments_spec.rb +68 -0
  39. data/spec/features/action_types_spec.rb +154 -0
  40. data/spec/features/cookies_spec.rb +62 -0
  41. data/spec/features/files_spec.rb +17 -0
  42. data/spec/features/method_types_spec.rb +104 -0
  43. data/spec/features/requests_spec.rb +59 -0
  44. data/spec/features/views_spec.rb +31 -0
  45. data/spec/fixtures/public/background.jpg +0 -0
  46. data/spec/fixtures/views/render_test.html +1 -0
  47. data/spec/fixtures/views/test.html +1 -0
  48. data/spec/spec_helper.rb +98 -0
  49. data/spec/support/cannon_test.rb +108 -0
  50. metadata +219 -0
@@ -0,0 +1,14 @@
1
+ require 'openssl'
2
+
3
+ module Signature
4
+ class CookieSecretNotSet < StandardError; end
5
+
6
+ def signature(value)
7
+ raise CookieSecretNotSet, 'Set config.cookies.secret to use signed cookies' if Cannon.config.cookies.secret.nil?
8
+ OpenSSL::HMAC.hexdigest(digest, Cannon.config.cookies.secret, value)
9
+ end
10
+
11
+ def digest
12
+ @digest ||= OpenSSL::Digest.new('sha1')
13
+ end
14
+ end
@@ -0,0 +1,58 @@
1
+ module Cannon
2
+ class UnknownLogLevel < StandardError; end
3
+
4
+ class Config
5
+ attr_accessor :middleware, :public_path, :view_path, :reload_on_request, :benchmark_requests, :port, :ip_address
6
+ attr_reader :logger, :log_level
7
+
8
+ DEFAULT_MIDDLEWARE = %w{RequestLogger Files Cookies Router ContentType}
9
+
10
+ LOG_LEVELS = {
11
+ unknown: Logger::UNKNOWN,
12
+ fatal: Logger::FATAL,
13
+ error: Logger::ERROR,
14
+ warn: Logger::WARN,
15
+ info: Logger::INFO,
16
+ debug: Logger::DEBUG,
17
+ }
18
+
19
+ def initialize
20
+ self.ip_address = '127.0.0.1'
21
+ self.port = 5030
22
+ self.middleware = DEFAULT_MIDDLEWARE
23
+ self.public_path = 'public'
24
+ self.view_path = 'views'
25
+ self.reload_on_request = false
26
+ self.benchmark_requests = true
27
+ @log_level = :info
28
+ self.logger = Logger.new(STDOUT)
29
+ end
30
+
31
+ def logger=(value)
32
+ @logger = value
33
+ logger.datetime_format = '%Y-%m-%d %H:%M:%S'
34
+ logger.formatter = proc do |severity, datetime, progname, msg|
35
+ "#{msg}\n"
36
+ end
37
+ self.log_level = log_level
38
+ end
39
+
40
+ def log_level=(value)
41
+ raise UnknownLogLevel unless LOG_LEVELS.keys.include? value.to_sym
42
+ @log_level = value
43
+ logger.level = LOG_LEVELS[value.to_sym]
44
+ end
45
+
46
+ def cookies
47
+ @cookies ||= Cookies.new
48
+ end
49
+
50
+ class Cookies
51
+ attr_accessor :secret
52
+
53
+ def initialize
54
+ self.secret = nil
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,99 @@
1
+ require 'msgpack'
2
+
3
+ module Cannon
4
+ class CookieJar
5
+ include Signature
6
+
7
+ class EndOfString < Exception; end
8
+
9
+ def initialize(http_cookie: nil, cookies: nil, signed: false)
10
+ @http_cookie = http_cookie
11
+ @cookies = cookies
12
+ @signed = signed
13
+
14
+ self.define_singleton_method(:signed) do
15
+ @signed_cookies ||= CookieJar.new(cookies: cookies_with_signatures, signed: true)
16
+ end if !@signed
17
+ end
18
+
19
+ def [](cookie_name)
20
+ cookie = cookies[cookie_name]
21
+ if cookie
22
+ @signed ? verified_signature(cookie_name, cookie) : cookie['value']
23
+ else
24
+ nil
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def verified_signature(name, cookie)
31
+ return cookie['value'] if cookie['verified']
32
+
33
+ if cookie['signature'] == signature(cookie['value'])
34
+ cookie['verified'] = true
35
+ cookie['value']
36
+ else
37
+ cookies.delete(name)
38
+ nil
39
+ end
40
+ end
41
+
42
+ def cookies_with_signatures
43
+ cookies.select { |k, v| v.include? 'signature' }
44
+ end
45
+
46
+ def cookies
47
+ @cookies ||= parse_cookies
48
+ end
49
+
50
+ def parse_cookies
51
+ cookies = {}
52
+ return cookies if @http_cookie.nil? || @http_cookie == ''
53
+
54
+ begin
55
+ pos = 0
56
+ loop do
57
+ pos = read_whitespace(@http_cookie, pos)
58
+ name, pos = read_cookie_name(@http_cookie, pos)
59
+ value, pos = read_cookie_value(@http_cookie, pos)
60
+ begin
61
+ cookies[name.to_sym] = MessagePack.unpack(value)
62
+ rescue StandardError; end
63
+ end
64
+ rescue EndOfString
65
+ end
66
+
67
+ cookies
68
+ end
69
+
70
+ def read_whitespace(cookie, pos)
71
+ raise EndOfString if cookie[pos] == nil
72
+ pos = pos + 1 while cookie[pos] == ' ' && pos < cookie.length
73
+ pos
74
+ end
75
+
76
+ def read_cookie_name(cookie, pos)
77
+ start_pos = pos
78
+ pos = pos + 1 while !['=', nil].include?(cookie[pos])
79
+ return cookie[start_pos..(pos - 1)], pos + 1
80
+ end
81
+
82
+ def read_cookie_value(cookie, pos)
83
+ in_quotes = false
84
+ pos = pos + 1 and in_quotes = true if cookie[pos] == '"'
85
+ start_pos = pos
86
+
87
+ if in_quotes
88
+ pos = pos + 1 while pos < cookie.length && !(cookie[pos] == '"' && cookie[pos - 1] != '\\')
89
+ value = cookie[start_pos..(pos - 1)].gsub("\\\"", '"')
90
+ pos = pos + 1 while ![';', nil].include?(cookie[pos])
91
+ else
92
+ pos = pos + 1 while ![';', nil].include?(cookie[pos])
93
+ value = cookie[start_pos..(pos - 1)]
94
+ end
95
+
96
+ return value, pos + 1
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,25 @@
1
+ module Cannon
2
+ class Handler < EventMachine::Connection
3
+ include EventMachine::HttpServer
4
+
5
+ def app
6
+ # magically defined by Cannon::App
7
+ self.class.app
8
+ end
9
+
10
+ def process_http_request
11
+ request = Request.new(self, app)
12
+ response = Response.new(self, app)
13
+
14
+ app.reload_environment if app.config.reload_on_request
15
+
16
+ app.middleware_runner.run(request, response) if middleware?
17
+ end
18
+
19
+ private
20
+
21
+ def middleware?
22
+ app.config.middleware.size > 0
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,47 @@
1
+ require 'cannon/middleware/flush_and_benchmark'
2
+ require 'cannon/middleware/request_logger'
3
+ require 'cannon/middleware/files'
4
+ require 'cannon/middleware/router'
5
+ require 'cannon/middleware/content_type'
6
+ require 'cannon/middleware/cookies'
7
+
8
+ module Cannon
9
+ class MiddlewareRunner
10
+ include EventMachine::Deferrable
11
+
12
+ def initialize(ware, callback:, app:)
13
+ @app = app
14
+ @ware, @callback = instantiate(ware), callback
15
+ end
16
+
17
+ def run(request, response)
18
+ next_proc = -> do
19
+ setup_callback
20
+ self.succeed(request, response)
21
+ end
22
+
23
+ result = @ware.run(request, response, next_proc)
24
+ end
25
+
26
+ private
27
+
28
+ def setup_callback
29
+ set_deferred_status nil
30
+ callback do |request, response|
31
+ @callback.run(request, response) unless @callback.nil?
32
+ end
33
+ end
34
+
35
+ def instantiate(ware)
36
+ if ware.is_a?(String)
37
+ begin
38
+ Object.const_get(ware).new(@app)
39
+ rescue NameError
40
+ Object.const_get("Cannon::Middleware::#{ware}").new(@app)
41
+ end
42
+ else
43
+ ware.new(@app)
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,15 @@
1
+ module Cannon
2
+ module Middleware
3
+ class ContentType
4
+ def initialize(app)
5
+ @app = app
6
+ end
7
+
8
+ def run(request, response, next_proc)
9
+ return next_proc.call unless response.headers['Content-Type'].nil?
10
+ response.headers['Content-Type'] = 'text/plain; charset=us-ascii'
11
+ next_proc.call
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,18 @@
1
+ module Cannon
2
+ module Middleware
3
+ class Cookies
4
+
5
+ def initialize(app)
6
+ @app = app
7
+ end
8
+
9
+ def run(request, response, next_proc)
10
+ request.define_singleton_method(:cookies) do
11
+ @cookie_jar ||= CookieJar.new(http_cookie: request.http_cookie)
12
+ end
13
+
14
+ next_proc.call
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,33 @@
1
+ module Cannon
2
+ module Middleware
3
+ class Files
4
+ include PathCache
5
+
6
+ def initialize(app)
7
+ @app = app
8
+
9
+ self.cache = :files
10
+ self.base_path = build_base_path
11
+ end
12
+
13
+ def run(request, response, next_proc)
14
+ reload_cache if outdated_cache?
15
+
16
+ if path_array.include? request.path
17
+ file, content_type = *file_and_content_type("#{base_path}#{request.path}")
18
+ response.header('Content-Type', content_type)
19
+ response.send(file)
20
+ response.flush
21
+ else
22
+ next_proc.call
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def build_base_path
29
+ @app.config.public_path =~ /^\// ? @app.config.public_path : "#{Cannon.root}/#{@app.config.public_path}"
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,20 @@
1
+ module Cannon
2
+ module Middleware
3
+ class FlushAndBenchmark
4
+ def initialize(app)
5
+ @app = app
6
+ end
7
+
8
+ def run(request, response, next_proc)
9
+ response.flush unless response.flushed?
10
+ Cannon.logger.debug "Response took #{time_ago_in_ms(request.start_time)}ms" if @app.config.benchmark_requests
11
+ end
12
+
13
+ private
14
+
15
+ def time_ago_in_ms(time_ago)
16
+ Time.at((Time.now - time_ago)).strftime('%6N').to_i/1000.0
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,13 @@
1
+ module Cannon
2
+ module Middleware
3
+ class RequestLogger
4
+ def initialize(app)
5
+ end
6
+
7
+ def run(request, response, next_proc)
8
+ Cannon.logger.info "#{request.method} #{request.path}"
9
+ next_proc.call
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,19 @@
1
+ module Cannon
2
+ module Middleware
3
+ class Router
4
+ def initialize(app)
5
+ @app = app
6
+ end
7
+
8
+ def run(request, response, next_proc)
9
+ matched_route = @app.routes.find { |route| route.matches? request }
10
+ if matched_route.nil?
11
+ response.not_found
12
+ next_proc.call
13
+ else
14
+ matched_route.handle(request, response, next_proc)
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,36 @@
1
+ require 'cgi'
2
+
3
+ module Cannon
4
+ class Request
5
+ attr_accessor :protocol, :method, :http_cookie, :content_type, :path, :uri, :query_string, :post_content, :headers,
6
+ :start_time
7
+
8
+ def initialize(http_server, app)
9
+ self.protocol = http_server.instance_variable_get('@http_protocol')
10
+ self.method = http_server.instance_variable_get('@http_request_method')
11
+ self.http_cookie = http_server.instance_variable_get('@http_cookie')
12
+ self.content_type = http_server.instance_variable_get('@http_content_type')
13
+ self.path = http_server.instance_variable_get('@http_path_info')
14
+ self.uri = http_server.instance_variable_get('@http_request_uri')
15
+ self.query_string = http_server.instance_variable_get('@http_query_string')
16
+ self.post_content = http_server.instance_variable_get('@http_post_content')
17
+ self.headers = http_server.instance_variable_get('@http_headers')
18
+ self.start_time = Time.now
19
+ end
20
+
21
+ def params
22
+ @params ||= parse_params
23
+ end
24
+
25
+ private
26
+
27
+ def parse_params
28
+ case method.downcase
29
+ when 'get'
30
+ Hash[CGI::parse(query_string || '').map { |(k, v)| [k.to_sym, v.last] }]
31
+ else
32
+ Hash[CGI::parse(post_content || '').map { |(k, v)| [k.to_sym, v.count > 1 ? v : v.first] }]
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,165 @@
1
+ require 'msgpack'
2
+
3
+ module Cannon
4
+ class Response
5
+ extend Forwardable
6
+ include Views
7
+ include Signature
8
+
9
+ attr_reader :delegated_response, :headers
10
+ attr_accessor :status
11
+
12
+ delegate :content => :delegated_response
13
+ delegate :content= => :delegated_response
14
+
15
+ HTTP_STATUS = {
16
+ continue: 100,
17
+ switching_protocols: 101,
18
+ ok: 200,
19
+ created: 201,
20
+ accepted: 202,
21
+ non_authoritative_information: 203,
22
+ no_content: 204,
23
+ reset_content: 205,
24
+ partial_content: 206,
25
+ multiple_choices: 300,
26
+ moved_permanently: 301,
27
+ found: 302,
28
+ see_other: 303,
29
+ not_modified: 304,
30
+ use_proxy: 305,
31
+ temporary_redirect: 307,
32
+ bad_request: 400,
33
+ unauthorized: 401,
34
+ payment_required: 402,
35
+ forbidden: 403,
36
+ not_found: 404,
37
+ method_not_allowed: 405,
38
+ not_acceptable: 406,
39
+ proxy_authentication_required: 407,
40
+ request_timeout: 408,
41
+ conflict: 409,
42
+ gone: 410,
43
+ length_required: 411,
44
+ precondition_failed: 412,
45
+ request_entity_too_large: 413,
46
+ request_uri_too_long: 414,
47
+ unsupported_media_type: 415,
48
+ requested_range_not_satisfied: 416,
49
+ expectation_failed: 417,
50
+ internal_server_error: 500,
51
+ not_implemented: 501,
52
+ bad_gateway: 502,
53
+ service_unavailable: 503,
54
+ gateway_timeout: 504,
55
+ http_version_not_supported: 505,
56
+ }
57
+
58
+ def initialize(http_server, app)
59
+ @app = app
60
+ @delegated_response = EventMachine::DelegatedHttpResponse.new(http_server)
61
+ @flushed = false
62
+ @headers = {}
63
+ @cookies = {}
64
+
65
+ initialize_views
66
+
67
+ self.status = :ok
68
+ end
69
+
70
+ def flushed?
71
+ @flushed
72
+ end
73
+
74
+ def send(content, status: self.status)
75
+ self.content ||= ''
76
+ self.status = status
77
+ delegated_response.status = converted_status(status)
78
+ delegated_response.content += content
79
+ end
80
+
81
+ def flush
82
+ unless flushed?
83
+ set_cookie_headers
84
+ delegated_response.headers = self.headers
85
+ delegated_response.send_response
86
+ @flushed = true
87
+ end
88
+ end
89
+
90
+ def header(key, value)
91
+ headers[key] = value
92
+ end
93
+
94
+ def location_header(location)
95
+ header('Location', location)
96
+ end
97
+
98
+ def permanent_redirect(location)
99
+ location_header(location)
100
+ self.status = :moved_permanently
101
+ flush
102
+ end
103
+
104
+ def temporary_redirect(location)
105
+ location_header(location)
106
+ self.status = :found
107
+ flush
108
+ end
109
+
110
+ def not_found
111
+ send('Not Found', status: :not_found)
112
+ end
113
+
114
+ def internal_server_error(title:, content:)
115
+ html = "<html><head><title>Internal Server Error: #{title}</title></head><body><h1>#{title}</h1><p>#{content}</p></body></html>"
116
+ header('Content-Type', 'text/html')
117
+ send(html, status: :internal_server_error)
118
+ end
119
+
120
+ def cookie(cookie, value:, expires: nil, httponly: nil, signed: false)
121
+ cookie_options = {:value => value}
122
+ cookie_options[:expires] = expires unless expires.nil?
123
+ cookie_options[:httponly] = httponly unless httponly.nil?
124
+ cookie_options[:signed] = signed
125
+ @cookies[cookie] = cookie_options
126
+ end
127
+
128
+ private
129
+
130
+ def set_cookie_headers
131
+ cookie_headers = (headers['Set-Cookie'] = [])
132
+ @cookies.each do |cookie, cookie_options|
133
+ cookie_headers << build_cookie_value(cookie, cookie_options)
134
+ end
135
+ end
136
+
137
+ def build_cookie_value(name, options)
138
+ cookie = "#{name}=#{cookie_value(options[:value], signed: options[:signed])}"
139
+ cookie << "; Expires=#{options[:expires].httpdate}" if options.include?(:expires)
140
+ cookie << '; HttpOnly' if options[:httponly] == true
141
+ cookie
142
+ end
143
+
144
+ def converted_status(status)
145
+ if status.is_a?(Symbol)
146
+ HTTP_STATUS[status] || status.to_s
147
+ elsif status.is_a?(Fixnum)
148
+ status
149
+ else
150
+ status.to_s
151
+ end
152
+ end
153
+
154
+ def cookie_value(value, signed:)
155
+ cookie_hash = {'value' => value}
156
+ cookie_hash['signature'] = signature(value) if signed
157
+ escape_cookie_value(cookie_hash.to_msgpack)
158
+ end
159
+
160
+ def escape_cookie_value(value)
161
+ return value unless value.match(/([\x00-\x20\x7F",;\\])/)
162
+ "\"#{value.gsub(/([\\"])/, "\\\\\\1")}\""
163
+ end
164
+ end
165
+ end