syntropy 0.18.1 → 0.19

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d1748e9bb0cb68467f64598d1adfdef654790ab934285a0269cb622aeedf1783
4
- data.tar.gz: de07940425a73e41073454a0fce3dd6d8309868fbe8cc3e8476061a99780093b
3
+ metadata.gz: 48230355504c92bce0f90adb332d50de6699cbefc63c93c7fd594e37f22fb5fe
4
+ data.tar.gz: 1849eede16f0764b199ee2e343cf95b28c7d45a2de62e217d5b12a916473251b
5
5
  SHA512:
6
- metadata.gz: b9ed5c3adc822135af00b789a0d735efd34bc8977bd84cb85f43f5a37f07c55e8abf44648eebbd3c206b740ef5158dc24e72195925cb4034a448e1510aa4b7d7
7
- data.tar.gz: ec1aa4e1101aa4ff7acc7186b3b0a07755a2baef5b5bfefd5934d743b5dfe5fd7c1589296cb40a5ba691dacefa43db32348f9697b52ceba2036a94fcd055908a
6
+ metadata.gz: 7e0e4d42d5d227fa11237f759cc3dae325dcff2ea2996746bb664e8e34ea94d831186c78c792422b20881f3a9d393e8f06279b029e42ac19c49e19ddc137b58a
7
+ data.tar.gz: bd1c8101ac5be676ce1f687be69147432b3200ab74b648b2a66f2a8742b61f28d74f143931cae5eb9b354d6b0b727e9257d6bffe03aa38aceea9170a7d975533
data/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ # 0.19 2025-09-14
2
+
3
+ - Implement HTTP caching for static files
4
+ - Add `--no-server-headers` option
5
+ - Update TP2: server headers, injected response headers, cookies
6
+
1
7
  # 0.18 2025-09-11
2
8
 
3
9
  - Rename P2 back to Papercraft
data/README.md CHANGED
@@ -17,6 +17,16 @@
17
17
  </a>
18
18
  </p>
19
19
 
20
+ ```
21
+ ooo
22
+ ooooo
23
+ ooo vvv Syntropy - a web framework for Ruby
24
+ o vvvvv -------------------------------------
25
+ | vvv o
26
+ :|:::|::|:
27
+ +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
28
+ ```
29
+
20
30
  ## What is Syntropy?
21
31
 
22
32
  | Syntropy: A tendency towards complexity, structure, order, organization of
data/bin/syntropy CHANGED
@@ -8,7 +8,11 @@ env = {
8
8
  mount_path: '/',
9
9
  banner: Syntropy::BANNER,
10
10
  logger: true,
11
- builtin_applet_path: '/.syntropy'
11
+ builtin_applet_path: '/.syntropy',
12
+ server_extensions: {
13
+ date: true,
14
+ name: 'Syntropy'
15
+ }
12
16
  }
13
17
 
14
18
  parser = OptionParser.new do |o|
@@ -45,6 +49,10 @@ parser = OptionParser.new do |o|
45
49
  env[:builtin_applet_path] = nil
46
50
  end
47
51
 
52
+ o.on('--no-server-headers', 'Don\'t include Server and Date headers') do
53
+ env[:server_extensions] = nil
54
+ end
55
+
48
56
  o.on('-v', '--version', 'Show version') do
49
57
  require 'syntropy/version'
50
58
  puts "Syntropy version #{Syntropy::VERSION}"
data/lib/syntropy/app.rb CHANGED
@@ -81,8 +81,17 @@ module Syntropy
81
81
  error_handler.(req, e)
82
82
  end
83
83
 
84
+ # Returns the route entry for the given path. If compute_proc is true,
85
+ # computes the route proc if not yet computed.
86
+ #
87
+ # @param path [String] path
88
+ # @param params [Hash] hash receiving path parameters
89
+ # @param compute_proc [bool] whether to compute the route proc
90
+ # @return [Hash] route entry
84
91
  def route(path, params = {}, compute_proc: false)
85
92
  route = @router_proc.(path, params)
93
+ return if !route
94
+
86
95
  route[:proc] ||= compute_route_proc(route) if compute_proc
87
96
  route
88
97
  end
@@ -101,6 +110,7 @@ module Syntropy
101
110
  @router_proc = @routing_tree.router_proc
102
111
  end
103
112
 
113
+
104
114
  def mount_builtin_applet
105
115
  path = @env[:builtin_applet_path]
106
116
  @builtin_applet ||= Syntropy.builtin_applet(@env, mount_path: path)
@@ -117,6 +127,11 @@ module Syntropy
117
127
  compose_up_tree_hooks(route, pure)
118
128
  end
119
129
 
130
+ # Returns the pure route proc for the given route. A pure route proc is the
131
+ # computed proc for the route without any middleware hooks.
132
+ #
133
+ # @param route [Hash] route entry
134
+ # @return [Proc] route proc
120
135
  def pure_route_proc(route)
121
136
  case (kind = route[:target][:kind])
122
137
  when :static
@@ -131,19 +146,90 @@ module Syntropy
131
146
  end
132
147
 
133
148
  # Returns a proc rendering the given static route
149
+ #
150
+ # @param [Hash] route entry
151
+ # @return [Proc] route handler proc
134
152
  def static_route_proc(route)
135
153
  fn = route[:target][:fn]
136
154
  headers = { 'Content-Type' => Qeweney::MimeTypes[File.extname(fn)] }
137
155
 
138
156
  ->(req) {
139
- req.respond_by_http_method(
140
- 'head' => [nil, headers],
141
- 'get' => -> { [IO.read(fn), headers] }
142
- )
157
+ case req.method
158
+ when 'head'
159
+ req.respond(nil, headers)
160
+ when 'get'
161
+ serve_static_file(req, route[:target])
162
+ else
163
+ raise Syntropy::Error.method_not_allowed
164
+ end
143
165
  }
144
166
  end
145
167
 
146
- # Returns a proc rendering the given markdown route
168
+ # Serves a static file from the given target hash with cache validation.
169
+ #
170
+ # @param req [Qeweney::Request] request
171
+ # @param target [Hash] route target hash
172
+ # @return [void]
173
+ def serve_static_file(req, target)
174
+ validate_static_file_info(target)
175
+ cache_opts = {
176
+ cache_control: 'max-age=3600',
177
+ last_modified: target[:last_modified_date],
178
+ etag: target[:etag]
179
+ }
180
+ req.validate_cache(**cache_opts) {
181
+ req.respond(target[:content], 'Content-Type' => target[:mime_type])
182
+ }
183
+ rescue => e
184
+ p e
185
+ p e.backtrace
186
+ exit!
187
+ end
188
+
189
+ # Validates and conditionally updates the file information for the given
190
+ # target.
191
+ #
192
+ # @param target [Hash] route target hash
193
+ # @return [void]
194
+ def validate_static_file_info(target)
195
+ now = Time.now
196
+ return if target[:last_update] && ((Time.now - target[:last_update]) < 390)
197
+
198
+ update_static_file_info(target, now)
199
+ end
200
+
201
+ STATX_MASK = UM::STATX_MTIME | UM::STATX_SIZE
202
+
203
+ # Updates the static file information for the given target
204
+ #
205
+ # @param target [Hash] route target hash
206
+ # @param now [Time] current time
207
+ # @return [void]
208
+ def update_static_file_info(target, now)
209
+ target[:last_update] = now
210
+ fd = @machine.open(target[:fn], UM::O_RDONLY)
211
+ stat = @machine.statx(fd, nil, UM::AT_EMPTY_PATH, STATX_MASK)
212
+ target[:size] = size = stat[:size]
213
+ mtime = stat[:mtime].to_i
214
+ return if target[:last_modified] == mtime # file not modified
215
+
216
+ target[:last_modified] = mtime
217
+ target[:last_modified_date] = Time.at(mtime).httpdate
218
+ target[:content] = buffer = String.new(capacity: size)
219
+ target[:mime_type] = Qeweney::MimeTypes[File.extname(target[:fn])]
220
+ len = 0
221
+ while len < size
222
+ len += @machine.read(fd, buffer, size, len)
223
+ end
224
+ target[:etag] = Digest::SHA1.hexdigest(buffer)
225
+ ensure
226
+ @machine.close_async(fd) if fd
227
+ end
228
+
229
+ # Returns a proc rendering the given markdown route.
230
+ #
231
+ # @param route [Hash] route entry
232
+ # @return [Proc] route proc
147
233
  def markdown_route_proc(route)
148
234
  headers = { 'Content-Type' => 'text/html' }
149
235
 
@@ -155,27 +241,39 @@ module Syntropy
155
241
  }
156
242
  end
157
243
 
244
+ # Renders and returns the given markdown route as HTML.
245
+ #
246
+ # @param route [Hash] route entry
247
+ # @return [String] rendered HTML
158
248
  def render_markdown(route)
159
249
  atts, md = Syntropy.parse_markdown_file(route[:target][:fn], @env)
160
250
 
161
251
  if (layout = atts[:layout])
162
252
  route[:applied_layouts] ||= {}
163
- proc = route[:applied_layouts][layout] ||= markdown_layout_proc(layout)
253
+ proc = route[:applied_layouts][layout] ||= markdown_layout_template(layout)
164
254
  html = proc.render(md:, **atts)
165
255
  else
166
- html = default_markdown_layout_proc.render(md:, **atts)
256
+ html = default_markdown_layout_template.render(md:, **atts)
167
257
  end
168
258
  html
169
259
  end
170
260
 
171
- # returns a markdown template based on the given layout
172
- def markdown_layout_proc(layout)
261
+ # Returns a markdown template based on the given layout.
262
+ #
263
+ # @param layout [String] layout name
264
+ # @return [Proc] layout template
265
+ def markdown_layout_template(layout)
173
266
  @layouts ||= {}
174
267
  template = @module_loader.load("_layout/#{layout}")
175
268
  @layouts[layout] = template.apply { |md:, **| markdown(md) }
176
269
  end
177
270
 
178
- def default_markdown_layout_proc
271
+ # Returns the default markdown layout, which renders to HTML and includes a
272
+ # title, the markdown content, and emits code for auto refreshing the page
273
+ # on file change.
274
+ #
275
+ # @return [Proc] default Markdown layout template
276
+ def default_markdown_layout_template
179
277
  @default_markdown_layout ||= ->(md:, **atts) {
180
278
  html5 {
181
279
  head {
@@ -189,12 +287,22 @@ module Syntropy
189
287
  }
190
288
  end
191
289
 
290
+ # Returns the route proc for a module route.
291
+ #
292
+ # @param route [Hash] route entry
293
+ # @return [Proc] route proc
192
294
  def module_route_proc(route)
193
295
  ref = @routing_tree.fn_to_rel_path(route[:target][:fn])
194
296
  mod = @module_loader.load(ref)
195
297
  compute_module_proc(mod)
196
298
  end
197
299
 
300
+ # Computes a route proc for the given module. If the module is a template,
301
+ # returns a route proc wrapping the template, otherwise the module itself is
302
+ # considered as the route proc.
303
+ #
304
+ # @param mod [any] module value
305
+ # @return [Proc] route proc
198
306
  def compute_module_proc(mod)
199
307
  case mod
200
308
  when Papercraft::Template
@@ -204,6 +312,10 @@ module Syntropy
204
312
  end
205
313
  end
206
314
 
315
+ # Returns a route proc for the given template.
316
+ #
317
+ # @param template [Papercraft::Template] template
318
+ # @return [Proc] route proc
207
319
  def papercraft_template_proc(template)
208
320
  xml_mode = template.mode == :xml
209
321
  template = template.proc
@@ -244,6 +356,11 @@ module Syntropy
244
356
  (parent = route[:parent]) ? compose_up_tree_hooks(parent, proc) : proc
245
357
  end
246
358
 
359
+ # Loads and returns an auxiliary module. This method is used for loading
360
+ # hook modules.
361
+ #
362
+ # @param hook_spec [Hash] hook spec
363
+ # @return [any] hook module
247
364
  def load_aux_module(hook_spec)
248
365
  ref = @routing_tree.fn_to_rel_path(hook_spec[:fn])
249
366
  @module_loader.load(ref)
@@ -348,6 +465,9 @@ module Syntropy
348
465
  end
349
466
  end
350
467
 
468
+ # Signals a file change to any auto refresh watchers.
469
+ #
470
+ # @return [void]
351
471
  def signal_auto_refresh_watchers!
352
472
  return if !@builtin_applet
353
473
 
@@ -28,7 +28,7 @@
28
28
  const attachment = document.createElement('debug-attachment');
29
29
  const tag = ele.tagName;
30
30
  if (tag == 'SCRIPT' || tag == 'HEAD') return;
31
-
31
+
32
32
  const level = ele.dataset.syntropyLevel;
33
33
  const href = ele.dataset.syntropyLoc;
34
34
  const fn = ele.dataset.syntropyFn;
@@ -140,6 +140,36 @@ module Syntropy
140
140
  value
141
141
  end
142
142
 
143
+ # Validates request cache information. If the request cache information
144
+ # matches the given etag or last_modified values, responds with a 304 Not
145
+ # Modified status. Otherwise, yields to the given block for a normal
146
+ # response, and sets cache control headers according to the given arguments.
147
+ #
148
+ # @param cache_control [String] value for Cache-Control header
149
+ # @param etag [String, nil] Etag header value
150
+ # @param last_modified [String, nil] Last-Modified header value
151
+ # @return [void]
152
+ def validate_cache(cache_control: 'public', etag: nil, last_modified: nil)
153
+ validated = false
154
+ if (client_etag = headers['if-none-match'])
155
+ validated = true if client_etag == etag
156
+ end
157
+ if (client_mtime = headers['if-modified-since'])
158
+ validated = true if client_mtime == last_modified
159
+ end
160
+ if validated
161
+ respond(nil, ':status' => Qeweney::Status::NOT_MODIFIED)
162
+ else
163
+ cache_headers = {
164
+ 'Cache-Control' => cache_control
165
+ }
166
+ cache_headers['Etag'] = etag if etag
167
+ cache_headers['Last-Modified'] = last_modified if last_modified
168
+ set_response_headers(cache_headers)
169
+ yield
170
+ end
171
+ end
172
+
143
173
  # Reads the request body and returns form data.
144
174
  #
145
175
  # @return [Hash] form data
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Syntropy
4
- VERSION = '0.18.1'
4
+ VERSION = '0.19'
5
5
  end
data/syntropy.gemspec CHANGED
@@ -25,7 +25,7 @@ Gem::Specification.new do |s|
25
25
  s.add_dependency 'json', '2.13.2'
26
26
  s.add_dependency 'papercraft', '2.13'
27
27
  s.add_dependency 'qeweney', '0.22'
28
- s.add_dependency 'tp2', '0.16'
28
+ s.add_dependency 'tp2', '0.18'
29
29
  s.add_dependency 'uringmachine', '0.18'
30
30
 
31
31
  s.add_dependency 'listen', '3.9.0'
data/test/bm_server.rb CHANGED
@@ -12,5 +12,5 @@ def m(x=1)
12
12
  e = Time.now - t0
13
13
  p [x, e, e/x, x/e]
14
14
  end
15
-
15
+
16
16
  m(10000) { t('http://localhost:1234/') }
data/test/test_app.rb CHANGED
@@ -117,7 +117,6 @@ class AppTest < Minitest::Test
117
117
 
118
118
  req = make_request(':method' => 'GET', ':path' => '/test/rss')
119
119
  assert_equal '<link>foo</link>', req.response_body
120
-
121
120
  end
122
121
 
123
122
  def test_app_file_watching
@@ -208,3 +207,67 @@ class MultiSiteAppTest < Minitest::Test
208
207
  assert_equal '<h1>bar.baz</h1>', req.response_body.chomp
209
208
  end
210
209
  end
210
+
211
+ class AppAPITest < Minitest::Test
212
+ Status = Qeweney::Status
213
+
214
+ APP_ROOT = File.join(__dir__, 'app')
215
+
216
+ def setup
217
+ @machine = UM.new
218
+
219
+ @tmp_path = '/test/tmp'
220
+ @tmp_fn = File.join(APP_ROOT, 'tmp.rb')
221
+
222
+ @app = Syntropy::App.new(
223
+ root_dir: APP_ROOT,
224
+ mount_path: '/test',
225
+ watch_files: 0.05,
226
+ machine: @machine
227
+ )
228
+ end
229
+
230
+ def test_route_method
231
+ route = @app.route('/')
232
+ assert_nil route
233
+
234
+ route = @app.route('/', compute_proc: true)
235
+ assert_nil route
236
+
237
+ route = @app.route('/test')
238
+ assert_nil route[:parent]
239
+ assert_equal '/test', route[:path]
240
+ assert_equal :static, route[:target][:kind]
241
+ assert_equal File.join(APP_ROOT, 'index.html'), route[:target][:fn]
242
+
243
+ route = @app.route('/test/assets/style.css')
244
+ assert_equal '/test/assets', route[:parent][:path]
245
+ assert_equal :static, route[:target][:kind]
246
+ assert_equal File.join(APP_ROOT, 'assets/style.css'), route[:target][:fn]
247
+
248
+ route = @app.route('/test/api')
249
+ assert_equal '/test', route[:parent][:path]
250
+ assert_equal :module, route[:target][:kind]
251
+ assert_equal File.join(APP_ROOT, 'api+.rb'), route[:target][:fn]
252
+
253
+ route = @app.route('/test/api/foo')
254
+ assert_equal '/test', route[:parent][:path]
255
+ assert_equal :module, route[:target][:kind]
256
+ assert_equal File.join(APP_ROOT, 'api+.rb'), route[:target][:fn]
257
+
258
+ route = @app.route('/test/bar')
259
+ assert_equal '/test', route[:parent][:path]
260
+ assert_equal :module, route[:target][:kind]
261
+ assert_equal File.join(APP_ROOT, 'bar.rb'), route[:target][:fn]
262
+
263
+ route = @app.route('/test/about/raise')
264
+ assert_equal '/test/about', route[:parent][:path]
265
+ assert_equal :module, route[:target][:kind]
266
+ assert_equal File.join(APP_ROOT, 'about/raise.rb'), route[:target][:fn]
267
+
268
+ route = @app.route('/test/about/foo')
269
+ assert_equal '/test/about', route[:parent][:path]
270
+ assert_equal :markdown, route[:target][:kind]
271
+ assert_equal File.join(APP_ROOT, 'about/foo.md'), route[:target][:fn]
272
+ end
273
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'helper'
4
+ require 'digest/sha1'
5
+
6
+ class CachingTest < Minitest::Test
7
+ Status = Qeweney::Status
8
+
9
+ APP_ROOT = File.join(__dir__, 'app')
10
+
11
+ def make_socket_pair
12
+ port = SecureRandom.random_number(10000..40000)
13
+ server_fd = @machine.socket(UM::AF_INET, UM::SOCK_STREAM, 0, 0)
14
+ @machine.setsockopt(server_fd, UM::SOL_SOCKET, UM::SO_REUSEADDR, true)
15
+ @machine.bind(server_fd, '127.0.0.1', port)
16
+ @machine.listen(server_fd, UM::SOMAXCONN)
17
+
18
+ client_conn_fd = @machine.socket(UM::AF_INET, UM::SOCK_STREAM, 0, 0)
19
+ @machine.connect(client_conn_fd, '127.0.0.1', port)
20
+
21
+ server_conn_fd = @machine.accept(server_fd)
22
+
23
+ @machine.close(server_fd)
24
+ [client_conn_fd, server_conn_fd]
25
+ end
26
+
27
+ def setup
28
+ @machine = UM.new
29
+
30
+ @tmp_path = '/test/tmp'
31
+ @tmp_fn = File.join(APP_ROOT, 'tmp.rb')
32
+
33
+ @env = {
34
+ machine: @machine,
35
+ root_dir: APP_ROOT,
36
+ mount_path: '/test',
37
+ watch_files: 0.05
38
+ }
39
+
40
+ @app = Syntropy::App.new(**@env)
41
+
42
+ @c_fd, @s_fd = make_socket_pair
43
+ @adapter = TP2::Connection.new(nil, @machine, @s_fd, @env) { @app.(it) }
44
+ end
45
+
46
+ def teardown
47
+ @machine.close(@c_fd) rescue nil
48
+ @machine.close(@s_fd) rescue nil
49
+ end
50
+
51
+ def write_http_request(msg, shutdown_wr = false)
52
+ @machine.send(@c_fd, msg, msg.bytesize, UM::MSG_WAITALL)
53
+ @machine.shutdown(@c_fd, UM::SHUT_WR) if shutdown_wr
54
+ end
55
+
56
+ def write_client_side(msg)
57
+ @machine.send(@c_fd, msg, msg.bytesize, UM::MSG_WAITALL)
58
+ end
59
+
60
+ def read_client_side(len = 65536)
61
+ buf = +''
62
+ res = @machine.recv(@c_fd, buf, len, 0)
63
+ res == 0 ? nil : buf
64
+ end
65
+
66
+ def test_static_file_caching
67
+ fn = File.join(APP_ROOT, 'assets/style.css')
68
+ stat = @machine.statx(UM::AT_FDCWD, fn, 0, UM::STATX_ALL)
69
+ content = IO.read(fn)
70
+ etag = Digest::SHA1.hexdigest(content)
71
+ last_modified = Time.at(stat[:mtime]).httpdate
72
+ size = stat[:size]
73
+
74
+ write_http_request "GET /test/assets/style.css HTTP/1.1\r\n\r\n"
75
+ @adapter.serve_request
76
+ response = read_client_side
77
+
78
+ expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\nCache-Control: max-age=3600\r\nEtag: #{etag}\r\nLast-Modified: #{last_modified}\r\nContent-Type: text/css\r\n\r\n#{size.to_s(16)}\r\n#{content}\r\n0\r\n\r\n"
79
+ assert_equal expected, response
80
+ end
81
+
82
+ def test_static_file_caching_validate_etag
83
+ fn = File.join(APP_ROOT, 'assets/style.css')
84
+ stat = @machine.statx(UM::AT_FDCWD, fn, 0, UM::STATX_ALL)
85
+ content = IO.read(fn)
86
+ etag = Digest::SHA1.hexdigest(content)
87
+ last_modified = Time.at(stat[:mtime]).httpdate
88
+ size = stat[:size]
89
+
90
+ # bad etag
91
+ write_http_request "GET /test/assets/style.css HTTP/1.1\r\nIf-None-Match: foo\r\n\r\n"
92
+ @adapter.serve_request
93
+ response = read_client_side
94
+
95
+ expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\nCache-Control: max-age=3600\r\nEtag: #{etag}\r\nLast-Modified: #{last_modified}\r\nContent-Type: text/css\r\n\r\n#{size.to_s(16)}\r\n#{content}\r\n0\r\n\r\n"
96
+ assert_equal expected, response
97
+
98
+ # good etag
99
+ @adapter.response_headers.clear
100
+ write_http_request "GET /test/assets/style.css HTTP/1.1\r\nIf-None-Match: #{etag}\r\n\r\n"
101
+ @adapter.serve_request
102
+ response = read_client_side
103
+
104
+ expected = "HTTP/1.1 304\r\nContent-Length: 0\r\n\r\n"
105
+ assert_equal expected, response
106
+ end
107
+
108
+ def test_static_file_caching_validate_last_modified
109
+ fn = File.join(APP_ROOT, 'assets/style.css')
110
+ stat = @machine.statx(UM::AT_FDCWD, fn, 0, UM::STATX_ALL)
111
+ content = IO.read(fn)
112
+ etag = Digest::SHA1.hexdigest(content)
113
+ last_modified = Time.at(stat[:mtime]).httpdate
114
+ size = stat[:size]
115
+
116
+ # bad stamp
117
+ write_http_request "GET /test/assets/style.css HTTP/1.1\r\nIf-Modified-Since: foo\r\n\r\n"
118
+ @adapter.serve_request
119
+ response = read_client_side
120
+
121
+ expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\nCache-Control: max-age=3600\r\nEtag: #{etag}\r\nLast-Modified: #{last_modified}\r\nContent-Type: text/css\r\n\r\n#{size.to_s(16)}\r\n#{content}\r\n0\r\n\r\n"
122
+ assert_equal expected, response
123
+
124
+ # good etag
125
+ @adapter.response_headers.clear
126
+ write_http_request "GET /test/assets/style.css HTTP/1.1\r\nIf-Modified-Since: #{last_modified}\r\n\r\n"
127
+ @adapter.serve_request
128
+ response = read_client_side
129
+
130
+ expected = "HTTP/1.1 304\r\nContent-Length: 0\r\n\r\n"
131
+ assert_equal expected, response
132
+ end
133
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: syntropy
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.18.1
4
+ version: '0.19'
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sharon Rosner
@@ -71,14 +71,14 @@ dependencies:
71
71
  requirements:
72
72
  - - '='
73
73
  - !ruby/object:Gem::Version
74
- version: '0.16'
74
+ version: '0.18'
75
75
  type: :runtime
76
76
  prerelease: false
77
77
  version_requirements: !ruby/object:Gem::Requirement
78
78
  requirements:
79
79
  - - '='
80
80
  - !ruby/object:Gem::Version
81
- version: '0.16'
81
+ version: '0.18'
82
82
  - !ruby/object:Gem::Dependency
83
83
  name: uringmachine
84
84
  requirement: !ruby/object:Gem::Requirement
@@ -248,6 +248,7 @@ files:
248
248
  - test/helper.rb
249
249
  - test/run.rb
250
250
  - test/test_app.rb
251
+ - test/test_caching.rb
251
252
  - test/test_connection_pool.rb
252
253
  - test/test_errors.rb
253
254
  - test/test_file_watch.rb