syntropy 0.32.0 → 0.34.0
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 +4 -4
- data/CHANGELOG.md +14 -0
- data/TODO.md +0 -39
- data/cmd/console.rb +18 -7
- data/cmd/serve.rb +26 -20
- data/cmd/test.rb +90 -21
- data/examples/blog/.gitignore +1 -0
- data/examples/blog/app/_lib/database.rb +13 -0
- data/examples/blog/app/_lib/{post_store.rb → posts.rb} +3 -1
- data/examples/blog/app/posts/[id]/edit.rb +2 -2
- data/examples/blog/app/posts/[id]/index.rb +8 -5
- data/examples/blog/app/posts/index.rb +7 -5
- data/examples/blog/app/posts/new.rb +1 -1
- data/examples/blog/config/development.rb +5 -0
- data/examples/blog/config/production.rb +4 -0
- data/examples/blog/config/test.rb +5 -0
- data/examples/blog/test/test_posts.rb +65 -0
- data/examples/mcp-oauth/app/oauth/token.rb +1 -1
- data/examples/mcp-oauth/test/test_app.rb +2 -20
- data/examples/mcp-oauth/test/test_oauth.rb +93 -217
- data/lib/syntropy/app.rb +48 -40
- data/lib/syntropy/applets/builtin/auto_refresh/watch.sse.rb +1 -1
- data/lib/syntropy/db/schema.rb +1 -1
- data/lib/syntropy/db/store.rb +2 -0
- data/lib/syntropy/errors.rb +6 -2
- data/lib/syntropy/http/client.rb +1 -0
- data/lib/syntropy/http/server_connection.rb +15 -13
- data/lib/syntropy/json_api.rb +27 -1
- data/lib/syntropy/logger.rb +81 -27
- data/lib/syntropy/markdown.rb +61 -32
- data/lib/syntropy/mime_types.rb +9 -5
- data/lib/syntropy/module_loader.rb +25 -13
- data/lib/syntropy/papercraft_extensions.rb +2 -2
- data/lib/syntropy/request/mock_adapter.rb +10 -8
- data/lib/syntropy/request/request_info.rb +91 -0
- data/lib/syntropy/request/response.rb +3 -14
- data/lib/syntropy/request/validation.rb +1 -0
- data/lib/syntropy/request.rb +55 -14
- data/lib/syntropy/routing_tree.rb +27 -28
- data/lib/syntropy/session.rb +198 -0
- data/lib/syntropy/side_run.rb +25 -2
- data/lib/syntropy/test.rb +168 -2
- data/lib/syntropy/utils.rb +53 -18
- data/lib/syntropy/version.rb +1 -1
- data/lib/syntropy.rb +44 -10
- data/syntropy.gemspec +1 -0
- data/test/bm_router_proc.rb +4 -4
- data/test/fixtures/app/class_instance.rb +5 -0
- data/test/fixtures/app/http.rb +5 -0
- data/test/fixtures/app/post_ct.rb +5 -0
- data/test/fixtures/app/singleton.rb +3 -0
- data/test/test_app.rb +13 -52
- data/test/test_caching.rb +2 -2
- data/test/test_db_schema.rb +1 -1
- data/test/test_http_server_connection.rb +11 -8
- data/test/test_module_loader.rb +5 -2
- data/test/test_request_session.rb +254 -0
- data/test/test_response.rb +0 -19
- data/test/test_routing_tree.rb +69 -69
- data/test/test_server.rb +5 -9
- data/test/test_test.rb +70 -0
- metadata +67 -42
- data/examples/blog/app/_setup.rb +0 -4
- data/examples/mcp-oauth/test/helper.rb +0 -9
- /data/test/{app → fixtures/app}/.well-known/foo.rb +0 -0
- /data/test/{app → fixtures/app}/_hook.rb +0 -0
- /data/test/{app → fixtures/app}/_layout/default.rb +0 -0
- /data/test/{app → fixtures/app}/_lib/callable.rb +0 -0
- /data/test/{app → fixtures/app}/_lib/dep.rb +0 -0
- /data/test/{app → fixtures/app}/_lib/env.rb +0 -0
- /data/test/{app → fixtures/app}/_lib/klass.rb +0 -0
- /data/test/{app → fixtures/app}/_lib/missing-export.rb +0 -0
- /data/test/{app → fixtures/app}/_lib/self.rb +0 -0
- /data/test/{app → fixtures/app}/about/_error.rb +0 -0
- /data/test/{app → fixtures/app}/about/foo.md +0 -0
- /data/test/{app → fixtures/app}/about/index.rb +0 -0
- /data/test/{app → fixtures/app}/about/raise.rb +0 -0
- /data/test/{app → fixtures/app}/api+.rb +0 -0
- /data/test/{app → fixtures/app}/assets/style.css +0 -0
- /data/test/{app → fixtures/app}/bad_mod.rb +0 -0
- /data/test/{app → fixtures/app}/bar.rb +0 -0
- /data/test/{app → fixtures/app}/baz.rb +0 -0
- /data/test/{app → fixtures/app}/by_method.rb +0 -0
- /data/test/{app → fixtures/app}/deps.rb +0 -0
- /data/test/{app → fixtures/app}/index.html +0 -0
- /data/test/{app → fixtures/app}/mod/bar/index+.rb +0 -0
- /data/test/{app → fixtures/app}/mod/foo/index.rb +0 -0
- /data/test/{app → fixtures/app}/mod/path/a.rb +0 -0
- /data/test/{app → fixtures/app}/mod/path/b.rb +0 -0
- /data/test/{app → fixtures/app}/params/[foo].rb +0 -0
- /data/test/{app → fixtures/app}/rss.rb +0 -0
- /data/test/{app → fixtures/app}/tmp.rb +0 -0
- /data/test/{app_custom → fixtures/app_custom}/_site.rb +0 -0
- /data/test/{app_multi_site → fixtures/app_multi_site}/_site.rb +0 -0
- /data/test/{app_multi_site → fixtures/app_multi_site}/bar.baz/index.html +0 -0
- /data/test/{app_multi_site → fixtures/app_multi_site}/foo.bar/index.html +0 -0
- /data/test/{app_setup → fixtures/app_setup}/_setup.rb +0 -0
- /data/test/{app_setup → fixtures/app_setup}/index.rb +0 -0
- /data/test/{app_with_schema → fixtures/app_with_schema}/_schema/2026-01-02-foo.rb +0 -0
- /data/test/{app_with_schema → fixtures/app_with_schema}/_schema/2026-05-30-bar.rb +0 -0
- /data/test/{schema → fixtures/schema}/2026-01-02-foo.rb +0 -0
- /data/test/{schema → fixtures/schema}/2026-05-30-bar.rb +0 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Syntropy
|
|
4
|
+
# Implements a mock adapter for testing
|
|
4
5
|
class MockAdapter
|
|
5
6
|
attr_reader :response_body, :response_headers, :calls
|
|
6
7
|
|
|
@@ -19,14 +20,13 @@ module Syntropy
|
|
|
19
20
|
end
|
|
20
21
|
|
|
21
22
|
def initialize(request_body)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
end
|
|
23
|
+
@request_body_chunks =
|
|
24
|
+
case request_body
|
|
25
|
+
when Array then request_body
|
|
26
|
+
when nil then []
|
|
27
|
+
else [request_body]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
30
|
@calls = []
|
|
31
31
|
end
|
|
32
32
|
|
|
@@ -47,6 +47,8 @@ module Syntropy
|
|
|
47
47
|
response_headers[':status'] || HTTP::OK
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
+
def respond_to_missing?(sym) = true
|
|
51
|
+
|
|
50
52
|
def method_missing(sym, *args)
|
|
51
53
|
calls << [sym, *args]
|
|
52
54
|
end
|
|
@@ -3,36 +3,61 @@
|
|
|
3
3
|
require 'uri'
|
|
4
4
|
|
|
5
5
|
module Syntropy
|
|
6
|
+
# Request information extension methods.
|
|
6
7
|
module RequestInfoMethods
|
|
8
|
+
# Returns the request host.
|
|
9
|
+
#
|
|
10
|
+
# @return [String, nil]
|
|
7
11
|
def host
|
|
8
12
|
@headers['host'] || @headers[':authority']
|
|
9
13
|
end
|
|
10
14
|
alias_method :authority, :host
|
|
11
15
|
|
|
16
|
+
# Returns the connection header value.
|
|
17
|
+
#
|
|
18
|
+
# @return [String, nil]
|
|
12
19
|
def connection
|
|
13
20
|
@headers['connection']
|
|
14
21
|
end
|
|
15
22
|
|
|
23
|
+
# Returns the upgrade protocol.
|
|
24
|
+
#
|
|
25
|
+
# @return [String, nil]
|
|
16
26
|
def upgrade_protocol
|
|
17
27
|
connection == 'upgrade' && @headers['upgrade']&.downcase
|
|
18
28
|
end
|
|
19
29
|
|
|
30
|
+
# Returns the websocket version.
|
|
31
|
+
#
|
|
32
|
+
# @return [String, nil]
|
|
20
33
|
def websocket_version
|
|
21
34
|
headers['sec-websocket-version'].to_i
|
|
22
35
|
end
|
|
23
36
|
|
|
37
|
+
# Returns the protocol.
|
|
38
|
+
#
|
|
39
|
+
# @return [String, nil]
|
|
24
40
|
def protocol
|
|
25
41
|
@protocol ||= @adapter.protocol
|
|
26
42
|
end
|
|
27
43
|
|
|
44
|
+
# Returns the HTTP method in lower case.
|
|
45
|
+
#
|
|
46
|
+
# @return [String]
|
|
28
47
|
def method
|
|
29
48
|
@method ||= @headers[':method'].downcase
|
|
30
49
|
end
|
|
31
50
|
|
|
51
|
+
# Returns the request scheme.
|
|
52
|
+
#
|
|
53
|
+
# @return [String, nil]
|
|
32
54
|
def scheme
|
|
33
55
|
@scheme ||= @headers[':scheme']
|
|
34
56
|
end
|
|
35
57
|
|
|
58
|
+
# Returns the request content type.
|
|
59
|
+
#
|
|
60
|
+
# @return [String, nil]
|
|
36
61
|
def content_type
|
|
37
62
|
ct = @headers['content-type']
|
|
38
63
|
return nil if !ct
|
|
@@ -59,22 +84,37 @@ module Syntropy
|
|
|
59
84
|
self
|
|
60
85
|
end
|
|
61
86
|
|
|
87
|
+
# Returns the parsed request URI.
|
|
88
|
+
#
|
|
89
|
+
# @return [URI::Generic]
|
|
62
90
|
def uri
|
|
63
91
|
@uri ||= URI.parse(@headers[':path'] || '')
|
|
64
92
|
end
|
|
65
93
|
|
|
94
|
+
# Returns the parsed full request URI.
|
|
95
|
+
#
|
|
96
|
+
# @return [URI::HTTP]
|
|
66
97
|
def full_uri
|
|
67
98
|
@full_uri = "#{scheme}://#{host}#{uri}"
|
|
68
99
|
end
|
|
69
100
|
|
|
101
|
+
# Returns the request path.
|
|
102
|
+
#
|
|
103
|
+
# @return [String]
|
|
70
104
|
def path
|
|
71
105
|
@path ||= uri.path
|
|
72
106
|
end
|
|
73
107
|
|
|
108
|
+
# Returns the request (unparsed) query string.
|
|
109
|
+
#
|
|
110
|
+
# @return [String, nil]
|
|
74
111
|
def query_string
|
|
75
112
|
@query_string ||= uri.query
|
|
76
113
|
end
|
|
77
114
|
|
|
115
|
+
# Returns the parsed query hash.
|
|
116
|
+
#
|
|
117
|
+
# @return [Hash]
|
|
78
118
|
def query
|
|
79
119
|
return @query if @query
|
|
80
120
|
|
|
@@ -83,6 +123,10 @@ module Syntropy
|
|
|
83
123
|
|
|
84
124
|
QUERY_KV_REGEXP = /([^=]+)(?:=(.*))?/
|
|
85
125
|
|
|
126
|
+
# Converts a query string into a query hash
|
|
127
|
+
#
|
|
128
|
+
# @param query [String]
|
|
129
|
+
# @return [Hash]
|
|
86
130
|
def parse_query(query)
|
|
87
131
|
query.split('&').each_with_object({}) do |kv, h|
|
|
88
132
|
k, v = kv.match(QUERY_KV_REGEXP)[1..2]
|
|
@@ -90,10 +134,16 @@ module Syntropy
|
|
|
90
134
|
end
|
|
91
135
|
end
|
|
92
136
|
|
|
137
|
+
# Returns the request ID.
|
|
138
|
+
#
|
|
139
|
+
# @return [String, nil]
|
|
93
140
|
def request_id
|
|
94
141
|
@headers['x-request-id']
|
|
95
142
|
end
|
|
96
143
|
|
|
144
|
+
# Returns the forwarded for value.
|
|
145
|
+
#
|
|
146
|
+
# @return [String, nil]
|
|
97
147
|
def forwarded_for
|
|
98
148
|
@headers['x-forwarded-for']
|
|
99
149
|
end
|
|
@@ -107,6 +157,9 @@ module Syntropy
|
|
|
107
157
|
encoding.split(',').map { |i| i.strip }
|
|
108
158
|
end
|
|
109
159
|
|
|
160
|
+
# Returns the parsed cookie values.
|
|
161
|
+
#
|
|
162
|
+
# @return [String, nil]
|
|
110
163
|
def cookies
|
|
111
164
|
@cookies ||= parse_cookies(headers['cookie'])
|
|
112
165
|
end
|
|
@@ -114,6 +167,10 @@ module Syntropy
|
|
|
114
167
|
COOKIE_RE = /^([^=]+)=(.*)$/.freeze
|
|
115
168
|
SEMICOLON = ';'
|
|
116
169
|
|
|
170
|
+
# Parses the cookie string.
|
|
171
|
+
#
|
|
172
|
+
# @param cookies [String]
|
|
173
|
+
# @return [Hash]
|
|
117
174
|
def parse_cookies(cookies)
|
|
118
175
|
return {} unless cookies
|
|
119
176
|
|
|
@@ -139,6 +196,9 @@ module Syntropy
|
|
|
139
196
|
raise Syntropy::Error.new('Invalid form data', HTTP::BAD_REQUEST)
|
|
140
197
|
end
|
|
141
198
|
|
|
199
|
+
# Returns true if the user-agent is a browser.
|
|
200
|
+
#
|
|
201
|
+
# @return [bool]
|
|
142
202
|
def browser?
|
|
143
203
|
user_agent = headers['user-agent']
|
|
144
204
|
user_agent && user_agent =~ /^Mozilla\//
|
|
@@ -156,6 +216,9 @@ module Syntropy
|
|
|
156
216
|
@accept_parts.include?(mime_type)
|
|
157
217
|
end
|
|
158
218
|
|
|
219
|
+
# Returns the bearer token.
|
|
220
|
+
#
|
|
221
|
+
# @return [String, nil]
|
|
159
222
|
def auth_bearer_token
|
|
160
223
|
auth = headers['authorization']
|
|
161
224
|
if auth && (m = auth.match(/Bearer\s+([^\w]+)/))
|
|
@@ -167,12 +230,22 @@ module Syntropy
|
|
|
167
230
|
|
|
168
231
|
private
|
|
169
232
|
|
|
233
|
+
# Parses an accept string into an array of accepted MIME types.
|
|
234
|
+
#
|
|
235
|
+
# @param accept [string]
|
|
236
|
+
# @return [Array<String>]
|
|
170
237
|
def parse_accept_parts(accept)
|
|
171
238
|
accept.split(',').map { it.match(/^\s*([^\s;]+)/)[1] }
|
|
172
239
|
end
|
|
173
240
|
end
|
|
174
241
|
|
|
242
|
+
# Request info class methods
|
|
175
243
|
module RequestInfoClassMethods
|
|
244
|
+
# Parses form data into a hash
|
|
245
|
+
#
|
|
246
|
+
# @param body [String]
|
|
247
|
+
# @param headers [Hash]
|
|
248
|
+
# @return [Hash]
|
|
176
249
|
def parse_form_data(body, headers)
|
|
177
250
|
case (content_type = headers['content-type'])
|
|
178
251
|
when /^multipart\/form\-data; boundary=([^\s]+)/
|
|
@@ -185,6 +258,11 @@ module Syntropy
|
|
|
185
258
|
end
|
|
186
259
|
end
|
|
187
260
|
|
|
261
|
+
# Parses a multipart form body.
|
|
262
|
+
#
|
|
263
|
+
# @param body [String]
|
|
264
|
+
# @param boundary [String]
|
|
265
|
+
# @return [Hash]
|
|
188
266
|
def parse_multipart_form_data(body, boundary)
|
|
189
267
|
parts = body.split(boundary)
|
|
190
268
|
raise BadRequestError, 'Invalid form data' if parts.size < 2
|
|
@@ -197,6 +275,11 @@ module Syntropy
|
|
|
197
275
|
end
|
|
198
276
|
end
|
|
199
277
|
|
|
278
|
+
# Parses a multipart form data part.
|
|
279
|
+
#
|
|
280
|
+
# @param body [String]
|
|
281
|
+
# @param hash [Hash] output hash
|
|
282
|
+
# @return [void]
|
|
200
283
|
def parse_multipart_form_data_part(part, hash)
|
|
201
284
|
body, headers = parse_multipart_form_data_part_headers(part)
|
|
202
285
|
disposition = headers['content-disposition'] || ''
|
|
@@ -211,6 +294,10 @@ module Syntropy
|
|
|
211
294
|
end
|
|
212
295
|
end
|
|
213
296
|
|
|
297
|
+
# Parses a multipart form data part headers.
|
|
298
|
+
#
|
|
299
|
+
# @param part [String]
|
|
300
|
+
# @return [Hash]
|
|
214
301
|
def parse_multipart_form_data_part_headers(part)
|
|
215
302
|
headers = {}
|
|
216
303
|
while true
|
|
@@ -234,6 +321,10 @@ module Syntropy
|
|
|
234
321
|
MAX_PARAMETER_NAME_SIZE = 256
|
|
235
322
|
MAX_PARAMETER_VALUE_SIZE = 2**20 # 1MB
|
|
236
323
|
|
|
324
|
+
# Parses a URL-encoded form.
|
|
325
|
+
#
|
|
326
|
+
# @param body [String]
|
|
327
|
+
# @return [Hash]
|
|
237
328
|
def parse_urlencoded_form_data(body)
|
|
238
329
|
return {} unless body
|
|
239
330
|
|
|
@@ -7,18 +7,7 @@ require_relative '../http/status'
|
|
|
7
7
|
require_relative '../mime_types'
|
|
8
8
|
|
|
9
9
|
module Syntropy
|
|
10
|
-
|
|
11
|
-
class << self
|
|
12
|
-
def file_stat_to_etag(stat)
|
|
13
|
-
"#{stat.mtime.to_i.to_s(36)}#{stat.size.to_s(36)}"
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
def file_stat_to_last_modified(stat)
|
|
17
|
-
stat.mtime.httpdate
|
|
18
|
-
end
|
|
19
|
-
end
|
|
20
|
-
end
|
|
21
|
-
|
|
10
|
+
# Response methods.
|
|
22
11
|
module ResponseMethods
|
|
23
12
|
WEBSOCKET_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
|
|
24
13
|
|
|
@@ -105,8 +94,8 @@ module Syntropy
|
|
|
105
94
|
adapter.set_response_headers(headers)
|
|
106
95
|
end
|
|
107
96
|
|
|
108
|
-
def set_cookie(
|
|
109
|
-
adapter.set_cookie(
|
|
97
|
+
def set_cookie(k, v)
|
|
98
|
+
adapter.set_cookie(k, v)
|
|
110
99
|
end
|
|
111
100
|
|
|
112
101
|
def upgrade(protocol, custom_headers = nil, &block)
|
data/lib/syntropy/request.rb
CHANGED
|
@@ -3,19 +3,26 @@
|
|
|
3
3
|
require_relative './request/request_info'
|
|
4
4
|
require_relative './request/validation'
|
|
5
5
|
require_relative './request/response'
|
|
6
|
+
require_relative './session'
|
|
6
7
|
require_relative './http/status'
|
|
7
8
|
|
|
8
9
|
module Syntropy
|
|
10
|
+
# Syntropy::Request represents an HTTP request. By interacting with the
|
|
11
|
+
# request, the app can extract request information and respond to the request.
|
|
9
12
|
class Request
|
|
10
13
|
include RequestInfoMethods
|
|
11
14
|
include RequestValidationMethods
|
|
12
15
|
include ResponseMethods
|
|
13
|
-
|
|
14
16
|
extend RequestInfoClassMethods
|
|
15
17
|
|
|
16
18
|
attr_reader :headers, :adapter, :start_stamp, :route_params
|
|
17
19
|
attr_accessor :route
|
|
18
20
|
|
|
21
|
+
# Initializes the request.
|
|
22
|
+
#
|
|
23
|
+
# @param headers [Hash] request headers
|
|
24
|
+
# @param adapter [Object] connection adapter
|
|
25
|
+
# @return [void]
|
|
19
26
|
def initialize(headers, adapter)
|
|
20
27
|
@headers = headers
|
|
21
28
|
@adapter = adapter
|
|
@@ -25,37 +32,62 @@ module Syntropy
|
|
|
25
32
|
@ctx = nil
|
|
26
33
|
end
|
|
27
34
|
|
|
28
|
-
# Returns the request context
|
|
35
|
+
# Returns the request context, used to store auxiliary information.
|
|
36
|
+
#
|
|
37
|
+
# @return [Hash] request context hash
|
|
29
38
|
def ctx
|
|
30
39
|
@ctx ||= {}
|
|
31
40
|
end
|
|
32
41
|
|
|
42
|
+
# Returns the next request body chunk.
|
|
43
|
+
#
|
|
44
|
+
# @return [String, nil]
|
|
33
45
|
def next_chunk
|
|
34
46
|
@adapter.get_body_chunk(self)
|
|
35
47
|
end
|
|
36
48
|
|
|
49
|
+
# Reads request body chunks until the entire body is consumed, yielding each
|
|
50
|
+
# chunk to the given block.
|
|
51
|
+
#
|
|
52
|
+
# @return [void]
|
|
37
53
|
def each_chunk
|
|
38
54
|
while (chunk = @adapter.get_body_chunk(self))
|
|
39
55
|
yield chunk
|
|
40
56
|
end
|
|
41
57
|
end
|
|
42
58
|
|
|
59
|
+
# Reads the request body.
|
|
60
|
+
#
|
|
61
|
+
# @return [String, nil] request body
|
|
43
62
|
def read
|
|
44
63
|
@adapter.get_body(self)
|
|
45
64
|
end
|
|
46
65
|
alias_method :body, :read
|
|
47
66
|
|
|
67
|
+
# Returns true if the request body has been consumed.
|
|
68
|
+
#
|
|
69
|
+
# @return [bool]
|
|
48
70
|
def complete?
|
|
49
71
|
@adapter.complete?(self)
|
|
50
72
|
end
|
|
51
73
|
|
|
52
74
|
EMPTY_HEADERS = {}.freeze
|
|
53
75
|
|
|
76
|
+
# Sends a response.
|
|
77
|
+
#
|
|
78
|
+
# @param body [String, nil] response body
|
|
79
|
+
# @param headers [Hash] response headers
|
|
80
|
+
# @return [void]
|
|
54
81
|
def respond(body, headers = EMPTY_HEADERS)
|
|
55
82
|
@adapter.respond(self, body, headers)
|
|
56
83
|
@headers_sent = true
|
|
57
84
|
end
|
|
58
85
|
|
|
86
|
+
# Sends response headers.
|
|
87
|
+
#
|
|
88
|
+
# @param headers [Hash] response headers
|
|
89
|
+
# @param empty_response [bool] body should be sent
|
|
90
|
+
# @return [void]
|
|
59
91
|
def send_headers(headers = EMPTY_HEADERS, empty_response = false)
|
|
60
92
|
return if @headers_sent
|
|
61
93
|
|
|
@@ -63,6 +95,11 @@ module Syntropy
|
|
|
63
95
|
@adapter.send_headers(self, headers, empty_response: empty_response)
|
|
64
96
|
end
|
|
65
97
|
|
|
98
|
+
# Sends a response body chunk.
|
|
99
|
+
#
|
|
100
|
+
# @param body [String] response body chunk
|
|
101
|
+
# @param done [bool] body is complete
|
|
102
|
+
# @return [void]
|
|
66
103
|
def send_chunk(body, done: false)
|
|
67
104
|
send_headers({}) unless @headers_sent
|
|
68
105
|
|
|
@@ -70,30 +107,34 @@ module Syntropy
|
|
|
70
107
|
end
|
|
71
108
|
alias_method :<<, :send_chunk
|
|
72
109
|
|
|
110
|
+
# Finish response.
|
|
111
|
+
#
|
|
112
|
+
# @return [void]
|
|
73
113
|
def finish
|
|
74
114
|
send_headers({}) unless @headers_sent
|
|
75
115
|
|
|
76
116
|
@adapter.finish(self)
|
|
77
117
|
end
|
|
78
118
|
|
|
119
|
+
# Returns true if response headers were sent.
|
|
120
|
+
#
|
|
121
|
+
# @return [bool]
|
|
79
122
|
def headers_sent?
|
|
80
123
|
@headers_sent
|
|
81
124
|
end
|
|
82
125
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
headers[':tx'] ? headers[':tx'] += count : headers[':tx'] = count
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
def transfer_counts
|
|
92
|
-
[headers[':rx'], headers[':tx']]
|
|
126
|
+
# Returns the request session.
|
|
127
|
+
#
|
|
128
|
+
# @return [Syntropy::Session]
|
|
129
|
+
def session
|
|
130
|
+
@session ||= Session.new(self)
|
|
93
131
|
end
|
|
94
132
|
|
|
95
|
-
|
|
96
|
-
|
|
133
|
+
# Returns the request flash session storage.
|
|
134
|
+
#
|
|
135
|
+
# @return [Syntropy::Session::Flash]
|
|
136
|
+
def flash
|
|
137
|
+
session.flash
|
|
97
138
|
end
|
|
98
139
|
end
|
|
99
140
|
end
|
|
@@ -5,7 +5,7 @@ module Syntropy
|
|
|
5
5
|
# static files, markdown files, ruby modules, parametric routes, subtree routes,
|
|
6
6
|
# nested middleware and error handlers.
|
|
7
7
|
#
|
|
8
|
-
# A RoutingTree instance takes the given directory (
|
|
8
|
+
# A RoutingTree instance takes the given directory (app_root) and constructs a
|
|
9
9
|
# tree of route entries corresponding to the directory's contents. Finally, it
|
|
10
10
|
# generates an optimized router proc, which is used by the application to return
|
|
11
11
|
# a route entry for each incoming HTTP request.
|
|
@@ -41,15 +41,15 @@ module Syntropy
|
|
|
41
41
|
# allows you to prevent access through the HTTP server to protected or
|
|
42
42
|
# internal modules or files.
|
|
43
43
|
class RoutingTree
|
|
44
|
-
attr_reader :
|
|
44
|
+
attr_reader :app_root, :mount_path, :static_map, :dynamic_map, :root
|
|
45
45
|
|
|
46
46
|
# Initializes a new RoutingTree instance and computes the routing tree
|
|
47
47
|
#
|
|
48
|
-
# @param
|
|
48
|
+
# @param app_root [String] root directory of file tree
|
|
49
49
|
# @param mount_path [String] base URL path
|
|
50
50
|
# @return [void]
|
|
51
|
-
def initialize(
|
|
52
|
-
@
|
|
51
|
+
def initialize(app_root:, mount_path:, **env)
|
|
52
|
+
@app_root = app_root
|
|
53
53
|
@mount_path = mount_path
|
|
54
54
|
@static_map = {}
|
|
55
55
|
@dynamic_map = {}
|
|
@@ -72,7 +72,7 @@ module Syntropy
|
|
|
72
72
|
# @param fn [String] file path
|
|
73
73
|
# @return [String] clean path
|
|
74
74
|
def compute_clean_url_path(fn)
|
|
75
|
-
rel_path = fn.sub(@
|
|
75
|
+
rel_path = fn.sub(@app_root, '')
|
|
76
76
|
case rel_path
|
|
77
77
|
when /^(.*)\/index\.(md|rb|html)$/
|
|
78
78
|
Regexp.last_match(1).then { it == '' ? '/' : it }
|
|
@@ -88,7 +88,7 @@ module Syntropy
|
|
|
88
88
|
# @param fn [String] filename
|
|
89
89
|
# @return [String] relative path
|
|
90
90
|
def fn_to_rel_path(fn)
|
|
91
|
-
fn.sub(/^#{Regexp.escape(@
|
|
91
|
+
fn.sub(/^#{Regexp.escape(@app_root)}\//, '').sub(/\.[^\.]+$/, '')
|
|
92
92
|
end
|
|
93
93
|
|
|
94
94
|
# Mounts the given applet on the routng tree at the given (absolute) mount
|
|
@@ -142,7 +142,7 @@ module Syntropy
|
|
|
142
142
|
#
|
|
143
143
|
# @return [Hash] root entry
|
|
144
144
|
def compute_tree
|
|
145
|
-
compute_route_directory(dir: @
|
|
145
|
+
compute_route_directory(dir: @app_root, rel_path: '/', parent: nil)
|
|
146
146
|
end
|
|
147
147
|
|
|
148
148
|
# Converts the given absolute path to a relative one (relative to the
|
|
@@ -233,7 +233,7 @@ module Syntropy
|
|
|
233
233
|
# @return [String, nil] file path if found
|
|
234
234
|
def find_aux_module_entry(dir, name)
|
|
235
235
|
fn = File.join(dir, name)
|
|
236
|
-
File.file?(fn) ?
|
|
236
|
+
File.file?(fn) ? { kind: :module, fn: } : nil
|
|
237
237
|
end
|
|
238
238
|
|
|
239
239
|
# Returns a hash mapping file/dir names to route entries.
|
|
@@ -247,10 +247,10 @@ module Syntropy
|
|
|
247
247
|
|
|
248
248
|
rel_path = compute_clean_url_path(fn)
|
|
249
249
|
child = if File.file?(fn)
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
250
|
+
compute_route_file(fn:, rel_path:, parent:)
|
|
251
|
+
elsif File.directory?(fn)
|
|
252
|
+
compute_route_directory(dir: fn, rel_path:, parent:)
|
|
253
|
+
end
|
|
254
254
|
map[child_key(child)] = child if child
|
|
255
255
|
}
|
|
256
256
|
end
|
|
@@ -317,7 +317,6 @@ module Syntropy
|
|
|
317
317
|
set_index_route_target(parent:, path:, kind:, fn:, handle_subtree:)
|
|
318
318
|
end
|
|
319
319
|
|
|
320
|
-
|
|
321
320
|
# Sets an index route target for the given parent entry. Index files are
|
|
322
321
|
# applied as targets to the immediate containing directory. HTML index files
|
|
323
322
|
# are considered static and therefore not added to the routing tree.
|
|
@@ -329,7 +328,7 @@ module Syntropy
|
|
|
329
328
|
# @param handle_subtree [bool] whether the target handles the subtree
|
|
330
329
|
# @return [nil] (prevents addition of an index route)
|
|
331
330
|
def set_index_route_target(parent:, path:, kind:, fn:, handle_subtree: nil)
|
|
332
|
-
if
|
|
331
|
+
if parametric_route?(parent) || handle_subtree
|
|
333
332
|
@dynamic_map[path] = parent
|
|
334
333
|
parent[:target] = { kind:, fn: }
|
|
335
334
|
parent[:handle_subtree] = handle_subtree
|
|
@@ -383,7 +382,7 @@ module Syntropy
|
|
|
383
382
|
# @param entry [Hash] route entry
|
|
384
383
|
def make_route_entry(entry)
|
|
385
384
|
path = entry[:path]
|
|
386
|
-
if
|
|
385
|
+
if parametric_route?(entry) || entry[:handle_subtree]
|
|
387
386
|
@dynamic_map[path] = entry
|
|
388
387
|
else
|
|
389
388
|
entry[:static] = true
|
|
@@ -394,8 +393,8 @@ module Syntropy
|
|
|
394
393
|
# returns true if the route or any of its ancestors are parametric.
|
|
395
394
|
#
|
|
396
395
|
# @param entry [Hash] route entry
|
|
397
|
-
def
|
|
398
|
-
entry[:param] || (entry[:parent] &&
|
|
396
|
+
def parametric_route?(entry)
|
|
397
|
+
entry[:param] || (entry[:parent] && parametric_route?(entry[:parent]))
|
|
399
398
|
end
|
|
400
399
|
|
|
401
400
|
# Converts a relative URL path to absolute URL path.
|
|
@@ -468,7 +467,7 @@ module Syntropy
|
|
|
468
467
|
emit_router_proc_postlude(buffer, default_route_path: wildcard_root && @root[:path])
|
|
469
468
|
end
|
|
470
469
|
|
|
471
|
-
buffer
|
|
470
|
+
buffer # .tap { puts '*' * 40; puts it; puts }
|
|
472
471
|
end
|
|
473
472
|
|
|
474
473
|
# Emits optimized code for a childless wildcard router.
|
|
@@ -540,7 +539,7 @@ module Syntropy
|
|
|
540
539
|
return
|
|
541
540
|
end
|
|
542
541
|
|
|
543
|
-
if
|
|
542
|
+
if void_route?(entry)
|
|
544
543
|
parent = entry[:parent]
|
|
545
544
|
parametric_sibling = parent && parent[:children] && parent[:children]['[]']
|
|
546
545
|
if parametric_sibling
|
|
@@ -580,7 +579,7 @@ module Syntropy
|
|
|
580
579
|
# @param entry [Hash] route entry
|
|
581
580
|
# @return [Hash, nil] route target if exists
|
|
582
581
|
def find_target_in_subtree(entry)
|
|
583
|
-
entry[:children]&.
|
|
582
|
+
entry[:children]&.each_value { |e|
|
|
584
583
|
target = e[:target] || find_target_in_subtree(e)
|
|
585
584
|
return target if target
|
|
586
585
|
}
|
|
@@ -593,14 +592,14 @@ module Syntropy
|
|
|
593
592
|
#
|
|
594
593
|
# @param entry [Hash] route entry
|
|
595
594
|
# @return [bool]
|
|
596
|
-
def
|
|
595
|
+
def void_route?(entry)
|
|
597
596
|
return false if entry[:param] || entry[:target]
|
|
597
|
+
return true if entry[:static]
|
|
598
598
|
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
end
|
|
599
|
+
children = entry[:children]
|
|
600
|
+
return false if !children
|
|
601
|
+
|
|
602
|
+
return true if !children['[]'] && children.values.all? { void_route?(it) }
|
|
604
603
|
|
|
605
604
|
false
|
|
606
605
|
end
|
|
@@ -640,7 +639,7 @@ module Syntropy
|
|
|
640
639
|
|
|
641
640
|
elsif has_children
|
|
642
641
|
# otherwise look at the next segment
|
|
643
|
-
next if
|
|
642
|
+
next if void_route?(child_entry) && !param_entry
|
|
644
643
|
|
|
645
644
|
when_buffer = +''
|
|
646
645
|
visit_routing_tree_entry(buffer: when_buffer, entry: child_entry, indent: indent + 1, segment_idx: segment_idx + 1)
|