tynn 2.0.0.beta3 → 2.0.0.beta4

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.
@@ -3,74 +3,12 @@
3
3
  require_relative "request"
4
4
 
5
5
  class Tynn
6
- # Enforces secure HTTP requests by:
7
- #
8
- # 1. Redirecting HTTP requests to their HTTPS counterparts.
9
- #
10
- # 2. Setting the HTTP <tt>Strict-Transport-Security</tt> header (HSTS).
11
- # This ensures the browser never visits the http version of a website.
12
- # This reduces the impact of leaking session data through cookies
13
- # and external links, and defends against Man-in-the-middle attacks.
14
- #
15
- # 3. Setting the <tt>secure</tt> flag on cookies. This tells the browser to
16
- # only transmit them over HTTPS.
17
- #
18
- # You can configure HSTS passing through the <tt>:hsts</tt> option.
19
- # The following options are supported:
20
- #
21
- # [expires]
22
- # The time, in seconds, that the browser access the site only by HTTPS.
23
- # Defaults to 180 days.
24
- #
25
- # [subdomains]
26
- # If this is <tt>true</tt>, the rule applies to all the site's subdomains as
27
- # well. Defaults to <tt>true</tt>.
28
- #
29
- # [preload]
30
- # A limitation of HSTS is that the initial request remains unprotected if it
31
- # uses HTTP. The same applies to the first request after the activity period
32
- # specified by <tt>max-age</tt>. Modern browsers implements a "HSTS preload
33
- # list", which contains known sites supporting HSTS. If you would like to
34
- # include your website into the list, set this option to <tt>true</tt> and
35
- # submit your domain to this form[https://hstspreload.appspot.com/].
36
- # Supported by Chrome, Firefox, IE11+ and IE Edge.
37
- #
38
- # To disable HSTS, you will need to tell the browser to expire it immediately.
39
- # Setting <tt>hsts: false</tt> is a shortcut for <tt>hsts: { expires: 0 }</tt>.
40
- #
41
- # require "tynn"
42
- # require "tynn/ssl"
43
- # require "tynn/test"
44
- #
45
- # Tynn.plugin(Tynn::SSL)
46
- #
47
- # Tynn.define { }
48
- #
49
- # app = Tynn::Test.new
50
- # app.get("/", {}, "HTTP_HOST" => "tynn.xyz")
51
- #
52
- # app.res.status # => 301
53
- # app.res.location # => "https://tynn.xyz/"
54
- #
55
- # # Using different HSTS options
56
- # Tynn.plugin(
57
- # Tynn::SSL,
58
- # hsts: {
59
- # expires: 31_536_000,
60
- # includeSubdomains: false,
61
- # preload: true
62
- # }
63
- # )
64
- #
65
- # # Disabling HSTS
66
- # Tynn.plugin(Tynn::SSL, hsts: false)
67
- #
68
6
  module SSL
69
- def self.setup(app, hsts: {}) # :nodoc:
7
+ def self.setup(app, hsts: {})
70
8
  app.use(Tynn::SSL::Middleware, hsts: hsts)
71
9
  end
72
10
 
73
- class Middleware # :nodoc:
11
+ class Middleware
74
12
  HSTS_MAX_AGE = 15_552_000 # 180 days
75
13
 
76
14
  def initialize(app, hsts: {})
@@ -86,7 +24,7 @@ class Tynn
86
24
  @app.call(env).tap do |_, headers, _|
87
25
  set_hsts_header!(headers)
88
26
 
89
- flag_cookies_as_secure!(headers)
27
+ set_secure_flag!(headers)
90
28
  end
91
29
  end
92
30
 
@@ -115,13 +53,16 @@ class Tynn
115
53
  headers["Strict-Transport-Security"] ||= @hsts_header
116
54
  end
117
55
 
118
- def flag_cookies_as_secure!(headers)
119
- return unless cookies = headers["Set-Cookie"]
56
+ def set_secure_flag!(headers)
57
+ return unless header = headers["Set-Cookie"]
120
58
 
121
- headers["Set-Cookie"] = cookies.split("\n").map do |cookie|
122
- cookie << "; secure" if cookie !~ /;\s*secure\s*(;|$)/i
123
- cookie
124
- end.join("\n")
59
+ cookies = header.split("\n")
60
+
61
+ cookies.each do |c|
62
+ c << "; secure" if c !~ /;\s*secure\s*(;|$)/i
63
+ end
64
+
65
+ headers["Set-Cookie"] = cookies.join("\n")
125
66
  end
126
67
  end
127
68
  end
@@ -3,26 +3,8 @@
3
3
  require "rack/static"
4
4
 
5
5
  class Tynn
6
- # Serves static files (javascript files, images, stylesheets, etc).
7
- #
8
- # By default, these files are served from the <tt>./public</tt> folder.
9
- # A different location can be specified through the <tt>:root</tt> option.
10
- #
11
- # Under the hood, it uses the Rack::Static middleware.
12
- # Thus, supports all the options available by the middleware.
13
- #
14
- # require "tynn"
15
- # require "tynn/static"
16
- #
17
- # Tynn.plugin(Tynn::Static, ["/js", "/css"])
18
- # Tynn.plugin(Tynn::Static, ["/js", "/css"], root: "assets")
19
- # Tynn.plugin(Tynn::Static, ["/js", "/css"], index: "index.html")
20
- #
21
- # For more information on the supported options, please see
22
- # Rack::Static[http://www.rubydoc.info/gems/rack/Rack/Static].
23
- #
24
6
  module Static
25
- def self.setup(app, urls, opts = {}) # :nodoc:
7
+ def self.setup(app, urls, opts = {})
26
8
  options = opts.dup
27
9
 
28
10
  options[:urls] ||= urls
@@ -1,160 +1,46 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Tynn
4
- # A simple helper class to simulate requests to the application.
5
- #
6
- # require "tynn"
7
- # require "tynn/test"
8
- #
9
- # Tynn.define do
10
- # on get do
11
- # res.write("Hei!")
12
- # end
13
- # end
14
- #
15
- # ts = Tynn::Test.new
16
- # ts.get("/")
17
- #
18
- # ts.res.status # => 200
19
- # ts.res.body.join # => "Hei!"
20
- #
21
4
  class Test
22
- attr_reader :app # :nodoc:
5
+ attr_reader :app
23
6
 
24
- # Initializes a new Tynn::Test object.
25
- #
26
- # class API < Tynn
27
- # end
28
- #
29
- # ts = Tynn::Test.new(API)
30
- # ts.get("/user.json")
31
- #
32
7
  def initialize(app = Tynn)
33
8
  @app = app
34
9
  end
35
10
 
36
- # This module provides the Tynn::Test API methods. If the stand-alone
37
- # version is not preferred, this module can be integrated into the
38
- # testing environment. The following example uses Minitest:
39
- #
40
- # class HomeTest < Minitest::Test
41
- # include Tynn::Test::Methods
42
- #
43
- # def app
44
- # return Tynn
45
- # end
46
- #
47
- # def test_home
48
- # get("/")
49
- #
50
- # assert_equal 200, res.status
51
- # end
52
- # end
53
- #
54
11
  module Methods
55
- # If a request has been issued, returns an instance of Tynn::Request.
56
- # Otherwise, returns <tt>nil</tt>.
57
- #
58
- # ts = Tynn::Test.new
59
- # ts.get("/", { foo: "foo" }, { "HTTP_USER_AGENT" => "Tynn::Test" })
60
- #
61
- # ts.req.get?
62
- # # => true
63
- #
64
- # ts.req.params["foo"]
65
- # # => "foo"
66
- #
67
- # ts.req.env["HTTP_USER_AGENT"]
68
- # # => "Tynn::Test"
69
- #
70
12
  def req
71
13
  @__req
72
14
  end
73
15
 
74
- # If a request has been issued, returns an instance of Tynn::Response
75
- # Otherwise, returns <tt>nil</tt>.
76
- #
77
- # ts = Tynn::Test.new
78
- # ts.get("/", name: "Jane")
79
- #
80
- # ts.res.status
81
- # # => 200
82
- #
83
- # ts.res.body.join
84
- # # => "Hello Jane!"
85
- #
86
- # ts.res.headers["Content-Type"]
87
- # # => "text/html"
88
- #
89
16
  def res
90
17
  @__res
91
18
  end
92
19
 
93
- # Issues a <tt>GET</tt> request.
94
- #
95
- # [path] A request path.
96
- # [params] A Hash of query/post parameters, a String request body,
97
- # or <tt>nil</tt>.
98
- # [env] A Hash of Rack environment values.
99
- #
100
- # ts = Tynn::Test.new
101
- # ts.get("/search", name: "jane")
102
- # ts.get("/cart", {}, { "HTTPS" => "on" })
103
- #
104
20
  def get(path, params = {}, env = {})
105
21
  request(path, env.merge(method: "GET", params: params))
106
22
  end
107
23
 
108
- # Issues a <tt>POST</tt> request. See #get for more information.
109
- #
110
- # ts = Tynn::Test.new
111
- # ts.post("/signup", username: "janedoe", password: "secret")
112
- #
113
24
  def post(path, params = {}, env = {})
114
25
  request(path, env.merge(method: "POST", params: params))
115
26
  end
116
27
 
117
- # Issues a <tt>PUT</tt> request. See #get for more information.
118
- #
119
- # ts = Tynn::Test.new
120
- # ts.put("/users/1", username: "johndoe", name: "John")
121
- #
122
28
  def put(path, params = {}, env = {})
123
29
  request(path, env.merge(method: "PUT", params: params))
124
30
  end
125
31
 
126
- # Issues a <tt>PATCH</tt> request. See #get for more information.
127
- #
128
- # ts = Tynn::Test.new
129
- # ts.patch("/users/1", username: "janedoe")
130
- #
131
32
  def patch(path, params = {}, env = {})
132
33
  request(path, env.merge(method: "PATCH", params: params))
133
34
  end
134
35
 
135
- # Issues a <tt>DELETE</tt> request. See #get for more information.
136
- #
137
- # ts = Tynn::Test.new
138
- # ts.delete("/users/1")
139
- #
140
36
  def delete(path, params = {}, env = {})
141
37
  request(path, env.merge(method: "DELETE", params: params))
142
38
  end
143
39
 
144
- # Issues a <tt>HEAD</tt> request. See #get for more information.
145
- #
146
- # ts = Tynn::Test.new
147
- # ts.head("/users/1")
148
- #
149
40
  def head(path, params = {}, env = {})
150
41
  request(path, env.merge(method: Rack::HEAD, params: params))
151
42
  end
152
43
 
153
- # Issues a <tt>OPTIONS</tt> request. See #get for more information.
154
- #
155
- # ts = Tynn::Test.new
156
- # ts.options("/users")
157
- #
158
44
  def options(path, params = {}, env = {})
159
45
  request(path, env.merge(method: "OPTIONS", params: params))
160
46
  end
@@ -163,16 +49,7 @@ class Tynn
163
49
 
164
50
  def request(path, opts = {})
165
51
  @__req = Tynn::Request.new(Rack::MockRequest.env_for(path, opts))
166
- @__res = make_response(*app.call(@__req.env))
167
- end
168
-
169
- def make_response(status, headers, body)
170
- res = Tynn::Response.new(headers)
171
- res.status = status
172
-
173
- body.each { |b| res.write(b) }
174
-
175
- res
52
+ @__res = Tynn::Response.new(*app.call(@__req.env))
176
53
  end
177
54
  end
178
55
 
@@ -1,17 +1,60 @@
1
+ # frozen_string_literal: true
2
+
1
3
  class Tynn
2
- module Utils # :nodoc:
4
+ module Utils
3
5
  module_function
4
6
 
5
- def deepclone_hash(hash)
6
- default_proc, hash.default_proc = hash.default_proc, nil
7
+ def deep_dup(obj)
8
+ case obj
9
+ when Array then deep_dup_array(obj)
10
+ when Hash then deep_dup_hash(obj)
11
+ else obj
12
+ end
13
+ end
14
+
15
+ def deep_dup_hash(hash)
16
+ hash.each_with_object(hash.dup) do |(k, v), h|
17
+ h[k] = deep_dup(v)
18
+ end
19
+ end
20
+
21
+ def deep_dup_array(array)
22
+ array.map { |v| deep_dup(v) }
23
+ end
24
+
25
+ def deep_freeze!(obj)
26
+ case obj
27
+ when Array then deep_freeze_array!(obj)
28
+ when Hash then deep_freeze_hash!(obj)
29
+ end
30
+ end
7
31
 
8
- Marshal.load(Marshal.dump(hash))
9
- ensure
10
- hash.default_proc = default_proc
32
+ def deep_freeze_array!(array)
33
+ array.freeze.each { |v| deep_freeze!(v) }
11
34
  end
12
35
 
13
- def raise_error(message, error: RuntimeError, tag: "troubleshooting")
14
- raise error, sprintf("%s. See http://tynn.xyz/#%s.", message, tag)
36
+ def deep_freeze_hash!(hash)
37
+ hash.freeze.each { |_, v| deep_freeze!(v) }
38
+ end
39
+
40
+ HTML_ESCAPE = {
41
+ "&" => "&amp;",
42
+ ">" => "&gt;",
43
+ "<" => "&lt;",
44
+ '"' => "&#39;",
45
+ "'" => "&#34;"
46
+ }.freeze
47
+
48
+ UNSAFE = /[&"'><]/
49
+
50
+ def h(str)
51
+ str.gsub(UNSAFE, HTML_ESCAPE)
52
+ end
53
+
54
+ ERROR_URL = "%s. For more information, see http://tynn.rtfd.io/en/latest/troubleshooting#%s"
55
+
56
+ def raise_error(message, error = RuntimeError)
57
+ raise error, sprintf(ERROR_URL, message, message.downcase.gsub(" ", "-"))
15
58
  end
16
59
  end
17
60
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Tynn
4
- VERSION = "2.0.0.beta3" # :nodoc:
4
+ VERSION = "2.0.0.beta4"
5
5
  end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Tynn
4
+ module Versioning
5
+ module InstanceMethods
6
+ def version(default: nil, vendor: "api")
7
+ lambda do
8
+ version, format = nil
9
+ accepts = req.headers["Accept"]
10
+
11
+ if accepts && !accepts.empty?
12
+ accepts.split(",").each do |accept|
13
+ break if version
14
+
15
+ matcher = /application\/vnd\.#{ vendor }\+(.+);\s*version\s*=\s*(\d+)/
16
+ matcher.match(accept) do |match|
17
+ version, format = match[2], match[1]
18
+ end
19
+ end
20
+ end
21
+
22
+ version ||= default
23
+
24
+ captures.push(version.to_i, format) if version
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -8,24 +8,144 @@ class JSONTest < Minitest::Test
8
8
  @app = new_app
9
9
  end
10
10
 
11
- def test_respond_json_object
11
+ def test_read_hash
12
+ @app.plugin(Tynn::JSON)
13
+
14
+ @app.define do
15
+ on post do
16
+ json(json_params)
17
+ end
18
+ end
19
+
20
+ json = JSON.generate(ok: true)
21
+
22
+ ts = Tynn::Test.new(@app)
23
+ ts.post("/", json)
24
+
25
+ assert_equal %({"ok":true}), ts.res.body.join
26
+ end
27
+
28
+ def test_read_array
29
+ @app.plugin(Tynn::JSON)
30
+
31
+ @app.define do
32
+ on post do
33
+ json(json_params)
34
+ end
35
+ end
36
+
37
+ json = JSON.generate(%w(foo bar baz))
38
+
39
+ ts = Tynn::Test.new(@app)
40
+ ts.post("/", json)
41
+
42
+ assert_equal %(["foo","bar","baz"]), ts.res.body.join
43
+ end
44
+
45
+ def test_read_rewinds_body
46
+ @app.plugin(Tynn::JSON)
47
+
48
+ @app.define do
49
+ on post do
50
+ _ = json_params
51
+
52
+ res.write(req.body.read)
53
+ end
54
+ end
55
+
56
+ json = JSON.generate(%w(foo bar baz))
57
+
58
+ ts = Tynn::Test.new(@app)
59
+ ts.post("/", json)
60
+
61
+ assert_equal json, ts.res.body.join
62
+ end
63
+
64
+ def test_read_on_parse_error
65
+ @app.plugin(Tynn::JSON)
66
+
67
+ @app.define do
68
+ on post do
69
+ json_params
70
+ end
71
+ end
72
+
73
+ ts = Tynn::Test.new(@app)
74
+ ts.post("/", "")
75
+
76
+ assert_equal 400, ts.res.status
77
+ assert_empty ts.res.headers
78
+ assert_empty ts.res.body
79
+ end
80
+
81
+ def test_read_on_parse_error_proc
82
+ @app.plugin(Tynn::JSON, on_parse_error: proc {
83
+ res.status = 400
84
+
85
+ json(ok: false)
86
+
87
+ halt(res.finish)
88
+ })
89
+
90
+ @app.define do
91
+ on post do
92
+ json_params
93
+ end
94
+ end
95
+
96
+ ts = Tynn::Test.new(@app)
97
+ ts.post("/", "")
98
+
99
+ assert_equal 400, ts.res.status
100
+ assert_equal "application/json", ts.res.content_type
101
+ assert_equal %({"ok":false}), ts.res.body.join
102
+ end
103
+
104
+ def test_read_on_parse_error_method
105
+ @app.plugin(Tynn::JSON, on_parse_error: :custom_bad_request)
106
+
107
+ @app.class_eval do
108
+ def custom_bad_request
109
+ res.status = 400
110
+
111
+ json(ok: false)
112
+
113
+ halt(res.finish)
114
+ end
115
+ end
116
+
117
+ @app.define do
118
+ on post do
119
+ json_params
120
+ end
121
+ end
122
+
123
+ ts = Tynn::Test.new(@app)
124
+ ts.post("/", "")
125
+
126
+ assert_equal 400, ts.res.status
127
+ assert_equal "application/json", ts.res.content_type
128
+ assert_equal %({"ok":false}), ts.res.body.join
129
+ end
130
+
131
+ def test_write_hash
12
132
  @app.plugin(Tynn::JSON)
13
133
 
14
134
  @app.define do
15
135
  on get do
16
- json(foo: "foo")
136
+ json(ok: true)
17
137
  end
18
138
  end
19
139
 
20
140
  ts = Tynn::Test.new(@app)
21
141
  ts.get("/")
22
142
 
23
- object = JSON.parse(ts.res.body.join)
143
+ hash = JSON.parse(ts.res.body.join)
24
144
 
25
- assert_equal "foo", object["foo"]
145
+ assert hash["ok"]
26
146
  end
27
147
 
28
- def test_respond_json_array
148
+ def test_write_array
29
149
  @app.plugin(Tynn::JSON)
30
150
 
31
151
  @app.define do
@@ -40,7 +160,60 @@ class JSONTest < Minitest::Test
40
160
  assert_equal %w(foo bar baz), JSON.parse(ts.res.body.join)
41
161
  end
42
162
 
43
- def test_content_type
163
+ def test_write_object
164
+ @app.plugin(Tynn::JSON)
165
+
166
+ @app.define do
167
+ on get do
168
+ object = Object.new
169
+
170
+ def object.to_json(_)
171
+ JSON.generate(ok: true)
172
+ end
173
+
174
+ json(object)
175
+ end
176
+ end
177
+
178
+ ts = Tynn::Test.new(@app)
179
+ ts.get("/")
180
+
181
+ hash = JSON.parse(ts.res.body.join)
182
+
183
+ assert hash["ok"]
184
+ end
185
+
186
+ def test_write_custom_options
187
+ @app.plugin(Tynn::JSON, write_opts: JSON::PRETTY_STATE_PROTOTYPE)
188
+
189
+ @app.define do
190
+ on get do
191
+ json(ok: true)
192
+ end
193
+ end
194
+
195
+ ts = Tynn::Test.new(@app)
196
+ ts.get("/")
197
+
198
+ assert_equal JSON.pretty_generate(ok: true), ts.res.body.join
199
+ end
200
+
201
+ def test_write_options
202
+ @app.plugin(Tynn::JSON)
203
+
204
+ @app.define do
205
+ on get do
206
+ json({ ok: true }, JSON::PRETTY_STATE_PROTOTYPE)
207
+ end
208
+ end
209
+
210
+ ts = Tynn::Test.new(@app)
211
+ ts.get("/")
212
+
213
+ assert_equal JSON.pretty_generate(ok: true), ts.res.body.join
214
+ end
215
+
216
+ def test_write_content_type
44
217
  @app.plugin(Tynn::JSON)
45
218
 
46
219
  @app.define do
@@ -55,7 +228,7 @@ class JSONTest < Minitest::Test
55
228
  assert_equal "application/json", ts.res.content_type
56
229
  end
57
230
 
58
- def test_custom_content_type
231
+ def test_write_custom_content_type
59
232
  @app.plugin(Tynn::JSON, content_type: "application/js")
60
233
 
61
234
  @app.define do
@@ -69,4 +242,21 @@ class JSONTest < Minitest::Test
69
242
 
70
243
  assert_equal "application/js", ts.res.content_type
71
244
  end
245
+
246
+ def test_write_alias
247
+ @app.plugin(Tynn::JSON)
248
+
249
+ @app.define do
250
+ on get do
251
+ json(ok: true)
252
+ end
253
+ end
254
+
255
+ ts = Tynn::Test.new(@app)
256
+ ts.get("/")
257
+
258
+ hash = JSON.parse(ts.res.body.join)
259
+
260
+ assert hash["ok"]
261
+ end
72
262
  end