syntropy 0.30.0 → 0.32.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/.gitignore +2 -0
- data/CHANGELOG.md +30 -0
- data/TODO.md +46 -1
- data/bin/syntropy +8 -86
- data/cmd/_banner.rb +16 -0
- data/cmd/console.rb +77 -0
- data/cmd/help.rb +12 -0
- data/cmd/serve.rb +95 -0
- data/cmd/test.rb +40 -0
- data/examples/{counter.rb → basic/counter.rb} +1 -1
- data/examples/{templates.rb → basic/templates.rb} +1 -1
- data/examples/blog/app/_layout/default.rb +11 -0
- data/examples/blog/app/_lib/post_store.rb +47 -0
- data/examples/blog/app/_schema/2026-01-01-initial.rb +9 -0
- data/examples/blog/app/_setup.rb +4 -0
- data/examples/blog/app/index.rb +7 -0
- data/examples/blog/app/posts/[id]/edit.rb +33 -0
- data/examples/blog/app/posts/[id]/index.rb +58 -0
- data/examples/blog/app/posts/index.rb +38 -0
- data/examples/blog/app/posts/new.rb +29 -0
- data/examples/mcp-oauth/.ruby-version +1 -0
- data/examples/mcp-oauth/Gemfile +8 -0
- data/examples/mcp-oauth/README.md +128 -0
- data/examples/mcp-oauth/app/.well-known/oauth-authorization-server.rb +18 -0
- data/examples/mcp-oauth/app/.well-known/oauth-protected-resource.rb +10 -0
- data/examples/mcp-oauth/app/_lib/auth_store.rb +23 -0
- data/examples/mcp-oauth/app/index.md +1 -0
- data/examples/mcp-oauth/app/mcp.rb +85 -0
- data/examples/mcp-oauth/app/oauth/authorize.rb +18 -0
- data/examples/mcp-oauth/app/oauth/consent.rb +86 -0
- data/examples/mcp-oauth/app/oauth/register.rb +14 -0
- data/examples/mcp-oauth/app/oauth/token.rb +79 -0
- data/examples/mcp-oauth/app/signin.rb +85 -0
- data/examples/mcp-oauth/test/helper.rb +9 -0
- data/examples/mcp-oauth/test/test_app.rb +27 -0
- data/examples/mcp-oauth/test/test_oauth.rb +628 -0
- data/lib/syntropy/app.rb +34 -9
- data/lib/syntropy/applets/builtin/default_error_handler.rb +3 -3
- data/lib/syntropy/applets/builtin/req.rb +1 -1
- data/lib/syntropy/db/connection_pool.rb +71 -0
- data/lib/syntropy/db/schema.rb +92 -0
- data/lib/syntropy/db/store.rb +31 -0
- data/lib/syntropy/dev_mode.rb +1 -1
- data/lib/syntropy/errors.rb +6 -0
- data/lib/syntropy/http/client.rb +43 -0
- data/lib/syntropy/http/client_connection.rb +36 -0
- data/lib/syntropy/http/io_extensions.rb +176 -0
- data/lib/syntropy/http/server.rb +5 -5
- data/lib/syntropy/http/{connection.rb → server_connection.rb} +15 -91
- data/lib/syntropy/http.rb +3 -1
- data/lib/syntropy/logger.rb +5 -1
- data/lib/syntropy/{module.rb → module_loader.rb} +47 -8
- data/lib/syntropy/papercraft_extensions.rb +1 -1
- data/lib/syntropy/request/mock_adapter.rb +2 -0
- data/lib/syntropy/request/request_info.rb +22 -4
- data/lib/syntropy/request/response.rb +2 -2
- data/lib/syntropy/request/validation.rb +11 -5
- data/lib/syntropy/routing_tree.rb +2 -1
- data/lib/syntropy/test.rb +77 -0
- data/lib/syntropy/version.rb +1 -1
- data/lib/syntropy.rb +5 -23
- data/syntropy.gemspec +3 -3
- data/test/app/.well-known/foo.rb +3 -0
- data/test/app/_hook.rb +1 -1
- data/test/app/by_method.rb +9 -0
- data/test/app_setup/_setup.rb +7 -0
- data/test/app_setup/index.rb +1 -0
- data/test/app_with_schema/_schema/2026-01-02-foo.rb +12 -0
- data/test/app_with_schema/_schema/2026-05-30-bar.rb +7 -0
- data/test/helper.rb +1 -25
- data/test/schema/2026-01-02-foo.rb +12 -0
- data/test/schema/2026-05-30-bar.rb +7 -0
- data/test/test_app.rb +110 -70
- data/test/test_caching.rb +1 -1
- data/test/{test_connection_pool.rb → test_db_connection_pool.rb} +7 -2
- data/test/test_db_schema.rb +96 -0
- data/test/test_db_store.rb +24 -0
- data/test/test_http_client.rb +52 -0
- data/test/test_http_client_connection.rb +43 -0
- data/test/test_http_protocol.rb +250 -0
- data/test/{test_connection.rb → test_http_server_connection.rb} +39 -48
- data/test/test_json_api.rb +5 -5
- data/test/{test_module.rb → test_module_loader.rb} +31 -0
- data/test/{test_request_extensions.rb → test_request.rb} +153 -18
- data/test/test_routing_tree.rb +15 -3
- data/test/test_server.rb +9 -13
- metadata +84 -36
- data/lib/syntropy/connection_pool.rb +0 -61
- data/test/test_request_info.rb +0 -90
- /data/examples/{bad.rb → basic/bad.rb} +0 -0
- /data/examples/{card.rb → basic/card.rb} +0 -0
- /data/examples/{counter.js → basic/counter.js} +0 -0
- /data/examples/{counter_api.rb → basic/counter_api.rb} +0 -0
- /data/examples/{favicon.ico → basic/favicon.ico} +0 -0
- /data/examples/{index.md → basic/index.md} +0 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'syntropy/errors'
|
|
4
|
+
require 'syntropy/http/io_extensions'
|
|
4
5
|
|
|
5
6
|
module Syntropy
|
|
6
7
|
module HTTP
|
|
@@ -9,16 +10,15 @@ module Syntropy
|
|
|
9
10
|
# body is sent exclusively using chunked transfer encoding. Request bodies are
|
|
10
11
|
# accepted using either fixed length (Content-Length header) or chunked
|
|
11
12
|
# transfer encoding.
|
|
12
|
-
class
|
|
13
|
+
class ServerConnection
|
|
13
14
|
attr_reader :fd, :response_headers, :logger
|
|
14
15
|
|
|
15
|
-
def initialize(
|
|
16
|
-
@server = server
|
|
16
|
+
def initialize(machine, fd, env, io_mode: :socket, &app)
|
|
17
17
|
@machine = machine
|
|
18
18
|
@fd = fd
|
|
19
19
|
@env = env
|
|
20
20
|
@logger = env[:logger]
|
|
21
|
-
@io = machine.io(fd,
|
|
21
|
+
@io = machine.io(fd, io_mode)
|
|
22
22
|
@app = app
|
|
23
23
|
|
|
24
24
|
@done = nil
|
|
@@ -48,13 +48,17 @@ module Syntropy
|
|
|
48
48
|
# connection should be persisted.
|
|
49
49
|
def serve_request
|
|
50
50
|
@closed = nil
|
|
51
|
-
headers =
|
|
51
|
+
headers = @io.http_read_request_headers
|
|
52
52
|
return false if !headers
|
|
53
53
|
|
|
54
54
|
request = Syntropy::Request.new(headers, self)
|
|
55
55
|
|
|
56
56
|
@app.call(request)
|
|
57
|
-
persist_connection?(headers)
|
|
57
|
+
persist = persist_connection?(headers)
|
|
58
|
+
if persist && !headers[':body-done-reading'] && (headers['content-length'] || headers['transfer-encoding'])
|
|
59
|
+
get_body(request)
|
|
60
|
+
end
|
|
61
|
+
persist
|
|
58
62
|
rescue StandardError => e
|
|
59
63
|
handle_error(request, e)
|
|
60
64
|
false
|
|
@@ -96,46 +100,17 @@ module Syntropy
|
|
|
96
100
|
headers = req.headers
|
|
97
101
|
return nil if headers[':body-done-reading']
|
|
98
102
|
|
|
99
|
-
|
|
100
|
-
if
|
|
101
|
-
|
|
102
|
-
chunk = @io.read(content_length.to_i)
|
|
103
|
-
headers[':body-done-reading'] = true
|
|
104
|
-
return chunk
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
chunked_encoding = headers['transfer-encoding']&.downcase == 'chunked'
|
|
108
|
-
if chunked_encoding
|
|
109
|
-
buf = +''
|
|
110
|
-
while (chunk = read_chunk(headers, nil))
|
|
111
|
-
buf << chunk
|
|
112
|
-
end
|
|
113
|
-
headers[':body-done-reading'] = true
|
|
114
|
-
return buf
|
|
115
|
-
end
|
|
116
|
-
|
|
117
|
-
nil
|
|
103
|
+
body = @io.http_read_body(headers)
|
|
104
|
+
headers[':body-done-reading'] = true if body
|
|
105
|
+
body
|
|
118
106
|
end
|
|
119
107
|
|
|
120
108
|
def get_body_chunk(req)
|
|
121
109
|
headers = req.headers
|
|
122
|
-
content_length = headers['content-length']
|
|
123
|
-
if content_length
|
|
124
|
-
return nil if headers[':body-done-reading']
|
|
125
|
-
|
|
126
|
-
chunk = @io.read(content_length.to_i)
|
|
127
|
-
headers[':body-done-reading'] = true
|
|
128
|
-
return chunk
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
chunked_encoding = headers['transfer-encoding']&.downcase == 'chunked'
|
|
132
|
-
return read_chunk(headers, nil) if chunked_encoding
|
|
133
|
-
|
|
134
110
|
return nil if headers[':body-done-reading']
|
|
135
111
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
headers[':body-done-reading'] = true
|
|
112
|
+
chunk = @io.http_read_body_chunk(headers)
|
|
113
|
+
headers[':body-done-reading'] = true if !chunk
|
|
139
114
|
chunk
|
|
140
115
|
end
|
|
141
116
|
|
|
@@ -293,57 +268,6 @@ module Syntropy
|
|
|
293
268
|
return connection != 'close'
|
|
294
269
|
end
|
|
295
270
|
|
|
296
|
-
def parse_headers
|
|
297
|
-
headers = get_request_line(MAX_REQUEST_LINE_LEN)
|
|
298
|
-
return nil if !headers
|
|
299
|
-
|
|
300
|
-
loop do
|
|
301
|
-
line = @io.read_line(MAX_HEADER_LINE_LEN)
|
|
302
|
-
break if line.nil? || line.empty?
|
|
303
|
-
|
|
304
|
-
m = line.match(RE_HEADER_LINE)
|
|
305
|
-
raise ProtocolError, "Invalid header: #{line[0..2047].inspect}" if !m
|
|
306
|
-
|
|
307
|
-
headers[m[1].downcase] = m[2]
|
|
308
|
-
end
|
|
309
|
-
|
|
310
|
-
headers
|
|
311
|
-
end
|
|
312
|
-
|
|
313
|
-
def get_request_line(buf)
|
|
314
|
-
line = @io.read_line(MAX_REQUEST_LINE_LEN)
|
|
315
|
-
return nil if !line
|
|
316
|
-
|
|
317
|
-
m = line.match(RE_REQUEST_LINE)
|
|
318
|
-
raise ProtocolError, 'Invalid request line' if !m
|
|
319
|
-
|
|
320
|
-
http_version = m[3]
|
|
321
|
-
raise UnsupportedHTTPVersionError, 'HTTP version not supported' if http_version != '1.1'
|
|
322
|
-
|
|
323
|
-
{
|
|
324
|
-
':method' => m[1].downcase,
|
|
325
|
-
':path' => m[2],
|
|
326
|
-
':protocol' => 'http/1.1'
|
|
327
|
-
}
|
|
328
|
-
end
|
|
329
|
-
|
|
330
|
-
def read_chunk(headers, buffer)
|
|
331
|
-
chunk_size_str = @io.read_line(MAX_CHUNK_SIZE_LEN)
|
|
332
|
-
return nil if !chunk_size_str
|
|
333
|
-
|
|
334
|
-
chunk_size = chunk_size_str.to_i(16)
|
|
335
|
-
if chunk_size == 0
|
|
336
|
-
headers[':body-done-reading'] = true
|
|
337
|
-
@io.read_line(0)
|
|
338
|
-
return nil
|
|
339
|
-
end
|
|
340
|
-
|
|
341
|
-
chunk = @io.read(chunk_size)
|
|
342
|
-
@io.read_line(0)
|
|
343
|
-
|
|
344
|
-
buffer ? (buffer << chunk) : chunk
|
|
345
|
-
end
|
|
346
|
-
|
|
347
271
|
INTERNAL_HEADER_REGEXP = /^:/
|
|
348
272
|
|
|
349
273
|
# Formats response headers into an array. If empty_response is true(thy),
|
data/lib/syntropy/http.rb
CHANGED
data/lib/syntropy/logger.rb
CHANGED
|
@@ -67,7 +67,7 @@ module Syntropy
|
|
|
67
67
|
request = o[:request]
|
|
68
68
|
request_headers = request.headers
|
|
69
69
|
response_headers = o[:response_headers]
|
|
70
|
-
elapsed =
|
|
70
|
+
elapsed = monotonic_clock - request.start_stamp
|
|
71
71
|
{
|
|
72
72
|
level: level.to_s,
|
|
73
73
|
ts: (t = Time.now; t.to_i),
|
|
@@ -82,6 +82,10 @@ module Syntropy
|
|
|
82
82
|
}
|
|
83
83
|
end
|
|
84
84
|
|
|
85
|
+
def monotonic_clock
|
|
86
|
+
::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
87
|
+
end
|
|
88
|
+
|
|
85
89
|
def make_hash_entry(level, hash)
|
|
86
90
|
{
|
|
87
91
|
level: level.to_s,
|
|
@@ -27,8 +27,8 @@ module Syntropy
|
|
|
27
27
|
# @param env [Hash] environment hash
|
|
28
28
|
# @return [void]
|
|
29
29
|
def initialize(env)
|
|
30
|
-
@root_dir = env[:root_dir]
|
|
31
30
|
@env = env
|
|
31
|
+
@root_dir = env[:root_dir]
|
|
32
32
|
@modules = {} # maps ref to module entry
|
|
33
33
|
@fn_map = {} # maps filename to ref
|
|
34
34
|
end
|
|
@@ -37,13 +37,27 @@ module Syntropy
|
|
|
37
37
|
#
|
|
38
38
|
# @param ref [String] module reference
|
|
39
39
|
# @return [any] export value
|
|
40
|
-
def load(ref)
|
|
40
|
+
def load(ref, raise_on_missing: true)
|
|
41
41
|
ref = "/#{ref}" if ref !~ /^\//
|
|
42
42
|
|
|
43
|
-
entry = (
|
|
43
|
+
entry = load_module(ref, raise_on_missing:)
|
|
44
|
+
return if !entry
|
|
45
|
+
|
|
46
|
+
@modules[ref] ||= entry
|
|
44
47
|
entry[:export_value]
|
|
45
48
|
end
|
|
46
49
|
|
|
50
|
+
# Returns a list of modules found in the given relative path. The module
|
|
51
|
+
# references are returned as absolute paths (relative to the module loader
|
|
52
|
+
# root directory).
|
|
53
|
+
#
|
|
54
|
+
# @param dir [String] relative module directory
|
|
55
|
+
# @return [Array<String>] list of modules
|
|
56
|
+
def list(dir)
|
|
57
|
+
fns = Dir[File.join(@root_dir, dir, '*.rb')]
|
|
58
|
+
fns.map { it.match(/^#{@root_dir}\/(.+)\.rb$/)[1] }.sort
|
|
59
|
+
end
|
|
60
|
+
|
|
47
61
|
# Invalidates a module by its filename, normally following a change to the
|
|
48
62
|
# underlying file (in order to cause reloading of the module). The module
|
|
49
63
|
# will be removed from the modules map, as well as modules dependending on
|
|
@@ -101,17 +115,23 @@ module Syntropy
|
|
|
101
115
|
#
|
|
102
116
|
# @param ref [String] module reference
|
|
103
117
|
# @return [Hash] module entry
|
|
104
|
-
def load_module(ref)
|
|
118
|
+
def load_module(ref, raise_on_missing: true)
|
|
105
119
|
ref = "/#{ref}" if ref !~ /^\//
|
|
106
120
|
fn = File.expand_path(File.join(@root_dir, "#{ref}.rb"))
|
|
107
|
-
|
|
121
|
+
if !File.file?(fn)
|
|
122
|
+
raise Syntropy::Error, "File not found #{fn}" if raise_on_missing
|
|
123
|
+
|
|
124
|
+
return
|
|
125
|
+
end
|
|
108
126
|
|
|
109
127
|
@fn_map[fn] = ref
|
|
110
128
|
code = IO.read(fn)
|
|
111
129
|
env = @env.merge(module_loader: self, ref: clean_ref(ref))
|
|
112
130
|
mod = Syntropy::Module.load(env, code, fn)
|
|
113
131
|
add_dependencies(ref, mod.__dependencies__)
|
|
114
|
-
export_value = transform_module_export_value(
|
|
132
|
+
export_value = transform_module_export_value(
|
|
133
|
+
mod.__export_value__, fn, raise_on_missing:
|
|
134
|
+
)
|
|
115
135
|
|
|
116
136
|
{
|
|
117
137
|
fn: fn,
|
|
@@ -133,10 +153,10 @@ module Syntropy
|
|
|
133
153
|
#
|
|
134
154
|
# @param export_value [any] module's export value
|
|
135
155
|
# @return [any] transformed value
|
|
136
|
-
def transform_module_export_value(export_value)
|
|
156
|
+
def transform_module_export_value(export_value, fn, raise_on_missing:)
|
|
137
157
|
case export_value
|
|
138
158
|
when nil
|
|
139
|
-
raise Syntropy::Error,
|
|
159
|
+
raise Syntropy::Error, "No export found in #{fn}" if raise_on_missing
|
|
140
160
|
when String
|
|
141
161
|
->(req) { req.respond(export_value) }
|
|
142
162
|
when Class
|
|
@@ -278,9 +298,28 @@ module Syntropy
|
|
|
278
298
|
# environment is based on the module's env merged with the given parameters.
|
|
279
299
|
#
|
|
280
300
|
# @param env [Hash] environment
|
|
301
|
+
# @return [Syntropy::App]
|
|
281
302
|
def app(**env)
|
|
282
303
|
env = @env.merge(env)
|
|
283
304
|
Syntropy::App.new(**env)
|
|
284
305
|
end
|
|
306
|
+
|
|
307
|
+
# Returns a request handler that handles requests by calling the appropriate
|
|
308
|
+
# module method (e.g. get, post, etc.)
|
|
309
|
+
#
|
|
310
|
+
# @return [Proc]
|
|
311
|
+
def http_methods
|
|
312
|
+
->(req) { route_by_http_method(req) }
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Handles the given request by calling the module method corresponding to
|
|
316
|
+
# the request's HTTP method. If no method is found, raises a
|
|
317
|
+
# method_not_allowed error.
|
|
318
|
+
def route_by_http_method(req)
|
|
319
|
+
sym = req.method.to_sym
|
|
320
|
+
raise Syntropy::Error.method_not_allowed if !respond_to?(sym)
|
|
321
|
+
|
|
322
|
+
send(sym, req)
|
|
323
|
+
end
|
|
285
324
|
end
|
|
286
325
|
end
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'uri'
|
|
4
|
-
require 'escape_utils'
|
|
5
4
|
|
|
6
5
|
module Syntropy
|
|
7
6
|
module RequestInfoMethods
|
|
@@ -34,6 +33,16 @@ module Syntropy
|
|
|
34
33
|
@scheme ||= @headers[':scheme']
|
|
35
34
|
end
|
|
36
35
|
|
|
36
|
+
def content_type
|
|
37
|
+
ct = @headers['content-type']
|
|
38
|
+
return nil if !ct
|
|
39
|
+
|
|
40
|
+
m = ct.match(/^([^;]+)/)
|
|
41
|
+
return nil if !m
|
|
42
|
+
|
|
43
|
+
m[1].strip
|
|
44
|
+
end
|
|
45
|
+
|
|
37
46
|
# Rewrites the request path by replacing the given src with the given
|
|
38
47
|
# replacement.
|
|
39
48
|
#
|
|
@@ -77,7 +86,7 @@ module Syntropy
|
|
|
77
86
|
def parse_query(query)
|
|
78
87
|
query.split('&').each_with_object({}) do |kv, h|
|
|
79
88
|
k, v = kv.match(QUERY_KV_REGEXP)[1..2]
|
|
80
|
-
h[k
|
|
89
|
+
h[k] = v ? URI.decode_www_form_component(v) : true
|
|
81
90
|
end
|
|
82
91
|
end
|
|
83
92
|
|
|
@@ -112,7 +121,7 @@ module Syntropy
|
|
|
112
121
|
raise BadRequestError, 'Invalid cookie format' unless c.strip =~ COOKIE_RE
|
|
113
122
|
|
|
114
123
|
key, value = Regexp.last_match[1..2]
|
|
115
|
-
h[key] =
|
|
124
|
+
h[key] = URI.decode_www_form_component(value)
|
|
116
125
|
end
|
|
117
126
|
end
|
|
118
127
|
|
|
@@ -147,6 +156,15 @@ module Syntropy
|
|
|
147
156
|
@accept_parts.include?(mime_type)
|
|
148
157
|
end
|
|
149
158
|
|
|
159
|
+
def auth_bearer_token
|
|
160
|
+
auth = headers['authorization']
|
|
161
|
+
if auth && (m = auth.match(/Bearer\s+([^\w]+)/))
|
|
162
|
+
return m[1]
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
nil
|
|
166
|
+
end
|
|
167
|
+
|
|
150
168
|
private
|
|
151
169
|
|
|
152
170
|
def parse_accept_parts(accept)
|
|
@@ -229,7 +247,7 @@ module Syntropy
|
|
|
229
247
|
v = Regexp.last_match(2)
|
|
230
248
|
raise BadRequestError, 'Invalid parameter size' if v && v.size > MAX_PARAMETER_VALUE_SIZE
|
|
231
249
|
|
|
232
|
-
m[
|
|
250
|
+
m[URI.decode_www_form_component(k)] = v ? URI.decode_www_form_component(v) : true
|
|
233
251
|
end
|
|
234
252
|
end
|
|
235
253
|
end
|
|
@@ -179,7 +179,7 @@ module Syntropy
|
|
|
179
179
|
end
|
|
180
180
|
end
|
|
181
181
|
|
|
182
|
-
def
|
|
182
|
+
def respond_html(html, **headers)
|
|
183
183
|
respond(
|
|
184
184
|
html,
|
|
185
185
|
'Content-Type' => 'text/html; charset=utf-8',
|
|
@@ -187,7 +187,7 @@ module Syntropy
|
|
|
187
187
|
)
|
|
188
188
|
end
|
|
189
189
|
|
|
190
|
-
def
|
|
190
|
+
def respond_json(obj, **headers)
|
|
191
191
|
respond(
|
|
192
192
|
JSON.dump(obj),
|
|
193
193
|
'Content-Type' => 'application/json; charset=utf-8',
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'uri'
|
|
4
|
-
require 'escape_utils'
|
|
5
4
|
|
|
6
5
|
module Syntropy
|
|
7
6
|
module RequestValidationMethods
|
|
@@ -18,6 +17,13 @@ module Syntropy
|
|
|
18
17
|
raise Syntropy::Error.method_not_allowed
|
|
19
18
|
end
|
|
20
19
|
|
|
20
|
+
def validate_content_type(*accepted)
|
|
21
|
+
ct = content_type
|
|
22
|
+
return ct if accepted.include?(ct)
|
|
23
|
+
|
|
24
|
+
raise Syntropy::InvalidRequestContentTypeError
|
|
25
|
+
end
|
|
26
|
+
|
|
21
27
|
# Validates and optionally converts request parameter value for the given
|
|
22
28
|
# parameter name against the given clauses. If no clauses are given,
|
|
23
29
|
# verifies the parameter value is not nil. A clause can be a class, such as
|
|
@@ -33,7 +39,7 @@ module Syntropy
|
|
|
33
39
|
# @clauses [Array] one or more validation clauses
|
|
34
40
|
# @return [any] validated parameter value
|
|
35
41
|
def validate_param(name, *clauses)
|
|
36
|
-
validate(query[name], *clauses)
|
|
42
|
+
validate(query[name.to_s], *clauses)
|
|
37
43
|
end
|
|
38
44
|
|
|
39
45
|
# Validates and optionally converts a value against the given clauses. If no
|
|
@@ -47,12 +53,12 @@ module Syntropy
|
|
|
47
53
|
# @param value [any] value
|
|
48
54
|
# @clauses [Array] one or more validation clauses
|
|
49
55
|
# @return [any] validated value
|
|
50
|
-
def validate(value, *clauses)
|
|
51
|
-
raise Syntropy::ValidationError,
|
|
56
|
+
def validate(value, *clauses, message: 'Validation error')
|
|
57
|
+
raise Syntropy::ValidationError, message if clauses.empty? && !value
|
|
52
58
|
|
|
53
59
|
clauses.each do |c|
|
|
54
60
|
valid = param_is_valid?(value, c)
|
|
55
|
-
raise(Syntropy::ValidationError,
|
|
61
|
+
raise(Syntropy::ValidationError, message) if !valid
|
|
56
62
|
|
|
57
63
|
value = param_convert(value, c)
|
|
58
64
|
end
|
|
@@ -260,7 +260,8 @@ module Syntropy
|
|
|
260
260
|
# @param dir [String] directory path
|
|
261
261
|
# @return [Array<String>] array of file entries
|
|
262
262
|
def file_search(dir)
|
|
263
|
-
|
|
263
|
+
spec = File.join(dir.gsub(/[\[\]]/) { "\\#{it}"}, '{*,.*}')
|
|
264
|
+
Dir[spec].reject { it =~ /\/\.$/ }
|
|
264
265
|
end
|
|
265
266
|
|
|
266
267
|
# Computes a route entry and/or target for the given file path.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'syntropy'
|
|
4
|
+
require 'syntropy/request/mock_adapter'
|
|
5
|
+
require 'minitest'
|
|
6
|
+
|
|
7
|
+
module Syntropy
|
|
8
|
+
class TestHarness
|
|
9
|
+
def initialize(app)
|
|
10
|
+
@app = app
|
|
11
|
+
@app.raise_internal_server_error = true if @app.respond_to?(:raise_internal_server_error=)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def request(headers, body = nil)
|
|
15
|
+
req = mock_req(headers, body)
|
|
16
|
+
@app.call(req)
|
|
17
|
+
req
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def no_raise_internal_server_error
|
|
21
|
+
return yield if !@app.respond_to?(:raise_internal_server_error=)
|
|
22
|
+
|
|
23
|
+
begin
|
|
24
|
+
@app.raise_internal_server_error = false
|
|
25
|
+
yield
|
|
26
|
+
ensure
|
|
27
|
+
@app.raise_internal_server_error = true
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def mock_req(headers, body = nil)
|
|
35
|
+
Syntropy::MockAdapter.mock(headers, body)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
class Request
|
|
40
|
+
def response_headers
|
|
41
|
+
adapter.response_headers
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def response_status
|
|
45
|
+
adapter.status
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def response_body
|
|
49
|
+
adapter.response_body
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def response_json
|
|
53
|
+
raise if response_content_type != 'application/json'
|
|
54
|
+
JSON.parse(response_body)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def response_content_type
|
|
58
|
+
ct = response_headers['Content-Type']
|
|
59
|
+
return nil if !ct
|
|
60
|
+
|
|
61
|
+
m = ct.match(/^([^;]+)/)
|
|
62
|
+
return nil if !m
|
|
63
|
+
|
|
64
|
+
m[1]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def response_cookie(name)
|
|
68
|
+
sc = response_headers['Set-Cookie']
|
|
69
|
+
return nil if !sc
|
|
70
|
+
|
|
71
|
+
m = sc.match(/#{name}=([^\s]+)$/)
|
|
72
|
+
return nil if !m
|
|
73
|
+
|
|
74
|
+
m[1]
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
data/lib/syntropy/version.rb
CHANGED
data/lib/syntropy.rb
CHANGED
|
@@ -8,10 +8,12 @@ require 'syntropy/logger'
|
|
|
8
8
|
require 'syntropy/http'
|
|
9
9
|
require 'syntropy/mime_types'
|
|
10
10
|
require 'syntropy/app'
|
|
11
|
-
require 'syntropy/connection_pool'
|
|
11
|
+
require 'syntropy/db/connection_pool'
|
|
12
|
+
require 'syntropy/db/schema'
|
|
13
|
+
require 'syntropy/db/store'
|
|
12
14
|
require 'syntropy/errors'
|
|
13
15
|
require 'syntropy/markdown'
|
|
14
|
-
require 'syntropy/
|
|
16
|
+
require 'syntropy/module_loader'
|
|
15
17
|
require 'syntropy/papercraft_extensions'
|
|
16
18
|
require 'syntropy/routing_tree'
|
|
17
19
|
require 'syntropy/json_api'
|
|
@@ -32,25 +34,6 @@ module Syntropy
|
|
|
32
34
|
end
|
|
33
35
|
end
|
|
34
36
|
|
|
35
|
-
def colorize(color_code)
|
|
36
|
-
"\e[#{color_code}m#{self}\e[0m"
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
GREEN = "\e[32m"
|
|
40
|
-
CLEAR = "\e[0m"
|
|
41
|
-
YELLOW = "\e[33m"
|
|
42
|
-
|
|
43
|
-
BANNER =
|
|
44
|
-
"\n"\
|
|
45
|
-
" #{GREEN}\n"\
|
|
46
|
-
" #{GREEN} ooo\n"\
|
|
47
|
-
" #{GREEN}ooooo\n"\
|
|
48
|
-
" #{GREEN} ooo vvv #{CLEAR}Syntropy - a web framework for Ruby\n"\
|
|
49
|
-
" #{GREEN} o vvvvv #{CLEAR}--------------------------------------\n"\
|
|
50
|
-
" #{GREEN} #{YELLOW}|#{GREEN} vvv o #{CLEAR}https://github.com/digital-fabric/syntropy\n"\
|
|
51
|
-
" #{GREEN} :#{YELLOW}|#{GREEN}:::#{YELLOW}|#{GREEN}::#{YELLOW}|#{GREEN}:\n"\
|
|
52
|
-
"#{YELLOW}+++++++++++++++++++++++++++++++++++++++++++++++++++++++++\e[0m\n\n"
|
|
53
|
-
|
|
54
37
|
class << self
|
|
55
38
|
def run(env = {}, &app)
|
|
56
39
|
if @in_run
|
|
@@ -63,11 +46,10 @@ module Syntropy
|
|
|
63
46
|
begin
|
|
64
47
|
@in_run = true
|
|
65
48
|
machine = env[:machine] || UM.new
|
|
66
|
-
machine.puts(env[:banner]) if env[:banner]
|
|
67
49
|
|
|
68
50
|
env[:logger]&.info(message: "Running Syntropy #{Syntropy::VERSION}, UringMachine #{UM::VERSION}, Ruby #{RUBY_VERSION}")
|
|
69
51
|
|
|
70
|
-
server = Server.new(machine, env, &app)
|
|
52
|
+
server = HTTP::Server.new(machine, env, &app)
|
|
71
53
|
|
|
72
54
|
setup_signal_handling(machine, Fiber.current)
|
|
73
55
|
server.run
|
data/syntropy.gemspec
CHANGED
|
@@ -23,13 +23,13 @@ Gem::Specification.new do |s|
|
|
|
23
23
|
|
|
24
24
|
s.add_dependency 'extralite', '~>2.14'
|
|
25
25
|
s.add_dependency 'papercraft', '~>3.2.0'
|
|
26
|
-
s.add_dependency 'uringmachine', '~>1.0.
|
|
27
|
-
s.add_dependency 'cgi'
|
|
28
|
-
s.add_dependency 'escape_utils', '1.3.0'
|
|
26
|
+
s.add_dependency 'uringmachine', '~>1.0.2'
|
|
29
27
|
|
|
30
28
|
s.add_dependency 'json'
|
|
31
29
|
s.add_dependency 'logger'
|
|
30
|
+
s.add_dependency 'irb'
|
|
32
31
|
|
|
33
32
|
s.add_development_dependency 'minitest', '~>6.0.1'
|
|
34
33
|
s.add_development_dependency 'rake', '~>13.3.1'
|
|
34
|
+
s.add_development_dependency 'solargraph'
|
|
35
35
|
end
|
data/test/app/_hook.rb
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export ->(req) { req.respond('foo') }
|