syntropy 0.33.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 +7 -0
- data/cmd/console.rb +18 -7
- data/cmd/serve.rb +26 -18
- data/cmd/test.rb +37 -24
- 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 +4 -4
- data/examples/blog/app/posts/index.rb +4 -4
- 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/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 +0 -4
- 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 +20 -9
- 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 +1 -12
- data/lib/syntropy/request/validation.rb +1 -0
- data/lib/syntropy/request.rb +51 -19
- 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 +105 -10
- data/lib/syntropy/utils.rb +53 -18
- data/lib/syntropy/version.rb +1 -1
- data/lib/syntropy.rb +44 -10
- 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 +3 -3
- data/test/test_module_loader.rb +5 -2
- 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 +52 -42
- data/examples/blog/app/_setup.rb +0 -4
- data/lib/syntropy/request/session.rb +0 -113
- /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
data/lib/syntropy/app.rb
CHANGED
|
@@ -11,35 +11,52 @@ require 'syntropy/routing_tree'
|
|
|
11
11
|
require 'syntropy/mime_types'
|
|
12
12
|
|
|
13
13
|
module Syntropy
|
|
14
|
+
# The App implements a Syntropy application. It is responsible for handling
|
|
15
|
+
# incoming HTTP requests, routing them to the correct handler, and maintaining
|
|
16
|
+
# application state.
|
|
14
17
|
class App
|
|
15
18
|
class << self
|
|
19
|
+
# Creates an app instance based on the given environment hash.
|
|
20
|
+
#
|
|
21
|
+
# @param env [Hash] environment hash
|
|
22
|
+
# @return [Syntropy::App]
|
|
16
23
|
def load(env)
|
|
17
24
|
site_file_app(env) || default_app(env)
|
|
18
25
|
end
|
|
19
26
|
|
|
20
27
|
private
|
|
21
28
|
|
|
22
|
-
#
|
|
29
|
+
# Creates a multi-hostname app if a _site.rb file is detected.
|
|
30
|
+
#
|
|
31
|
+
# @param env [Hash] environment hash
|
|
32
|
+
# @return [Syntropy::App]
|
|
23
33
|
def site_file_app(env)
|
|
24
|
-
fn = File.join(env[:
|
|
34
|
+
fn = File.join(env[:app_root], '_site.rb')
|
|
25
35
|
return nil if !File.file?(fn)
|
|
26
36
|
|
|
27
37
|
loader = Syntropy::ModuleLoader.new(env)
|
|
28
38
|
loader.load('_site')
|
|
29
39
|
end
|
|
30
40
|
|
|
31
|
-
#
|
|
41
|
+
# Creates a normal Syntropy app.
|
|
42
|
+
#
|
|
43
|
+
# @param env [Hash] environment hash
|
|
44
|
+
# @return [Syntropy::App]
|
|
32
45
|
def default_app(env)
|
|
33
46
|
new(**env)
|
|
34
47
|
end
|
|
35
48
|
end
|
|
36
49
|
|
|
37
|
-
attr_reader :module_loader, :routing_tree, :
|
|
50
|
+
attr_reader :module_loader, :routing_tree, :app_root, :mount_path, :env
|
|
38
51
|
attr_accessor :raise_on_internal_server_error
|
|
39
52
|
|
|
53
|
+
# Initializes the app instance.
|
|
54
|
+
#
|
|
55
|
+
# @param env [Hash] environment hash
|
|
56
|
+
# @return [void]
|
|
40
57
|
def initialize(**env)
|
|
41
58
|
@machine = env[:machine]
|
|
42
|
-
@
|
|
59
|
+
@app_root = File.expand_path(env[:app_root])
|
|
43
60
|
@mount_path = env[:mount_path]
|
|
44
61
|
@env = env
|
|
45
62
|
@logger = env[:logger]
|
|
@@ -77,12 +94,14 @@ module Syntropy
|
|
|
77
94
|
proc = route[:proc] ||= compute_route_proc(route)
|
|
78
95
|
proc.(req)
|
|
79
96
|
rescue ScriptError, StandardError => e
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
97
|
+
if Error.log_error?(e)
|
|
98
|
+
@logger&.error(
|
|
99
|
+
message: "Error while serving request: #{e.message}",
|
|
100
|
+
method: req.method,
|
|
101
|
+
path: path,
|
|
102
|
+
error: e
|
|
103
|
+
)
|
|
104
|
+
end
|
|
86
105
|
error_handler = get_error_handler(route)
|
|
87
106
|
error_handler.(req, e)
|
|
88
107
|
end
|
|
@@ -102,21 +121,6 @@ module Syntropy
|
|
|
102
121
|
route
|
|
103
122
|
end
|
|
104
123
|
|
|
105
|
-
def setup_db(db_path:, schema_root: '_schema')
|
|
106
|
-
@env[:db_path] = db_path
|
|
107
|
-
@env[:schema_root] = schema_root
|
|
108
|
-
|
|
109
|
-
class << self
|
|
110
|
-
def connection_pool
|
|
111
|
-
@connection_pool ||= DB::ConnectionPool.new(@machine, @env[:db_path], 4)
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
def schema
|
|
115
|
-
@schema ||= DB::Schema.new(module_loader: @module_loader, schema_root: @env[:schema_root])
|
|
116
|
-
end
|
|
117
|
-
end
|
|
118
|
-
end
|
|
119
|
-
|
|
120
124
|
private
|
|
121
125
|
|
|
122
126
|
# Handles a not found error, taking into account hooks up the tree from the
|
|
@@ -134,7 +138,10 @@ module Syntropy
|
|
|
134
138
|
end
|
|
135
139
|
end
|
|
136
140
|
|
|
137
|
-
#
|
|
141
|
+
# Finds the first up-tree route entry for the given path.
|
|
142
|
+
#
|
|
143
|
+
# @param path [String] path
|
|
144
|
+
# @return [Hash] route entry
|
|
138
145
|
def find_first_uptree_route(path)
|
|
139
146
|
route = @router_proc.(path, {})
|
|
140
147
|
if !route && path != '/'
|
|
@@ -149,7 +156,7 @@ module Syntropy
|
|
|
149
156
|
# @return [void]
|
|
150
157
|
def setup_routing_tree
|
|
151
158
|
@routing_tree = Syntropy::RoutingTree.new(
|
|
152
|
-
|
|
159
|
+
app_root: @app_root, mount_path: @mount_path, **@env
|
|
153
160
|
)
|
|
154
161
|
mount_builtin_applet if @env[:builtin_applet_path]
|
|
155
162
|
@router_proc = @routing_tree.router_proc
|
|
@@ -164,6 +171,10 @@ module Syntropy
|
|
|
164
171
|
@routing_tree.mount_applet(path, @builtin_applet)
|
|
165
172
|
end
|
|
166
173
|
|
|
174
|
+
# Sets and returns the not found proc for the given route entry.
|
|
175
|
+
#
|
|
176
|
+
# @param route [Hash] route entry
|
|
177
|
+
# @return [Proc] not found proc
|
|
167
178
|
def route_not_found_proc(route)
|
|
168
179
|
route[:not_found_proc] ||= compose_up_tree_hooks(route, ->(req) {
|
|
169
180
|
raise Syntropy::Error.not_found('Not found')
|
|
@@ -233,7 +244,7 @@ module Syntropy
|
|
|
233
244
|
req.validate_cache(**cache_opts) {
|
|
234
245
|
req.respond(target[:content], 'Content-Type' => target[:mime_type])
|
|
235
246
|
}
|
|
236
|
-
rescue => e
|
|
247
|
+
rescue StandardError => e
|
|
237
248
|
p e
|
|
238
249
|
p e.backtrace
|
|
239
250
|
exit!
|
|
@@ -299,7 +310,7 @@ module Syntropy
|
|
|
299
310
|
# @param route [Hash] route entry
|
|
300
311
|
# @return [String] rendered HTML
|
|
301
312
|
def render_markdown(route)
|
|
302
|
-
atts, md = Syntropy.
|
|
313
|
+
atts, md = Syntropy::Markdown.parse(route[:target][:fn], @env)
|
|
303
314
|
|
|
304
315
|
layout = compute_markdown_layout(route, atts)
|
|
305
316
|
Papercraft.html(layout, md:, **atts)
|
|
@@ -337,7 +348,7 @@ module Syntropy
|
|
|
337
348
|
}
|
|
338
349
|
body {
|
|
339
350
|
markdown md
|
|
340
|
-
auto_refresh! if
|
|
351
|
+
auto_refresh! if Syntropy.dev_mode
|
|
341
352
|
}
|
|
342
353
|
}
|
|
343
354
|
}
|
|
@@ -482,6 +493,9 @@ module Syntropy
|
|
|
482
493
|
req.respond(msg, ':status' => status) rescue nil
|
|
483
494
|
}
|
|
484
495
|
|
|
496
|
+
# Returns the default error handler for the app.
|
|
497
|
+
#
|
|
498
|
+
# @return [Proc] error handler
|
|
485
499
|
def default_error_handler
|
|
486
500
|
@default_error_handler ||= begin
|
|
487
501
|
if @builtin_applet
|
|
@@ -507,7 +521,7 @@ module Syntropy
|
|
|
507
521
|
@machine.sleep 0.1
|
|
508
522
|
route_count = @routing_tree.static_map.size + @routing_tree.dynamic_map.size
|
|
509
523
|
@logger&.info(
|
|
510
|
-
message: "Serving from #{@
|
|
524
|
+
message: "Serving from #{@app_root} (#{route_count} routes found)"
|
|
511
525
|
)
|
|
512
526
|
|
|
513
527
|
file_watcher_loop if @env[:watch_files]
|
|
@@ -519,18 +533,12 @@ module Syntropy
|
|
|
519
533
|
#
|
|
520
534
|
# @return [void]
|
|
521
535
|
def file_watcher_loop
|
|
522
|
-
@machine.file_watch(@
|
|
536
|
+
@machine.file_watch(@app_root, UM::IN_CREATE | UM::IN_DELETE | UM::IN_CLOSE_WRITE) { |e|
|
|
523
537
|
fn = e[:fn]
|
|
524
538
|
@logger&.info(message: 'File change detected', fn: fn)
|
|
525
539
|
@module_loader.invalidate_fn(fn)
|
|
526
540
|
debounce_file_change
|
|
527
541
|
}
|
|
528
|
-
|
|
529
|
-
# Syntropy.file_watch(@machine, @root_dir, period: period) do |event, fn|
|
|
530
|
-
# @logger&.info(message: 'File change detected', fn: fn)
|
|
531
|
-
# @module_loader.invalidate_fn(fn)
|
|
532
|
-
# debounce_file_change
|
|
533
|
-
# end
|
|
534
542
|
rescue Exception => e
|
|
535
543
|
p e
|
|
536
544
|
p e.backtrace
|
|
@@ -564,7 +572,7 @@ module Syntropy
|
|
|
564
572
|
|
|
565
573
|
watcher_mod = watcher_route[:proc]
|
|
566
574
|
watcher_mod.signal!
|
|
567
|
-
rescue => e
|
|
575
|
+
rescue StandardError => e
|
|
568
576
|
@logger&.error(
|
|
569
577
|
message: 'Unexpected error while signalling auto refresh watcher',
|
|
570
578
|
error: e
|
|
@@ -36,7 +36,7 @@ rescue Timeout::Error
|
|
|
36
36
|
req.send_chunk("retry: 0\n\n", done: true) rescue nil
|
|
37
37
|
rescue SystemCallError
|
|
38
38
|
# ignore
|
|
39
|
-
rescue => e
|
|
39
|
+
rescue StandardError => e
|
|
40
40
|
@logger&.error(
|
|
41
41
|
message: 'Unexpected error encountered while serving auto refresh watcher',
|
|
42
42
|
error: e
|
data/lib/syntropy/db/schema.rb
CHANGED
|
@@ -53,7 +53,7 @@ module Syntropy
|
|
|
53
53
|
connection_pool.with_db do |db|
|
|
54
54
|
current_version = get_schema_version(db)
|
|
55
55
|
migrations_keys = @migrations.keys.sort
|
|
56
|
-
migrations_keys.select { it > current_version } if current_version
|
|
56
|
+
migrations_keys.select! { it > current_version } if current_version
|
|
57
57
|
|
|
58
58
|
migrations_keys.each do |key|
|
|
59
59
|
db.transaction do
|
data/lib/syntropy/db/store.rb
CHANGED
data/lib/syntropy/errors.rb
CHANGED
|
@@ -43,8 +43,6 @@ module Syntropy
|
|
|
43
43
|
# @return [Syntropy::Error]
|
|
44
44
|
def self.teapot(msg = 'I\'m a teapot') = new(msg, HTTP::TEAPOT)
|
|
45
45
|
|
|
46
|
-
attr_reader :http_status
|
|
47
|
-
|
|
48
46
|
# Initializes a Syntropy error with the given HTTP status and message.
|
|
49
47
|
#
|
|
50
48
|
# @param http_status [Integer, String] HTTP status
|
|
@@ -70,21 +68,27 @@ module Syntropy
|
|
|
70
68
|
end
|
|
71
69
|
end
|
|
72
70
|
|
|
71
|
+
# ProtocolError is raised when an HTTP protocol error is encountered.
|
|
73
72
|
class ProtocolError < Error
|
|
74
73
|
def http_status
|
|
75
74
|
HTTP::BAD_REQUEST
|
|
76
75
|
end
|
|
77
76
|
end
|
|
78
77
|
|
|
78
|
+
# UnsupportedHTTPVersionError is raised when an invalid protocol specified in
|
|
79
|
+
# a request's request line.
|
|
79
80
|
class UnsupportedHTTPVersionError < ProtocolError
|
|
80
81
|
def http_status
|
|
81
82
|
HTTP::HTTP_VERSION_NOT_SUPPORTED
|
|
82
83
|
end
|
|
83
84
|
end
|
|
84
85
|
|
|
86
|
+
# BadRequestError is raised when a request contains invalid information.
|
|
85
87
|
class BadRequestError < Error
|
|
86
88
|
end
|
|
87
89
|
|
|
90
|
+
# InvalidRequestContentTypeError is raised when a request has an invalid
|
|
91
|
+
# content type.
|
|
88
92
|
class InvalidRequestContentTypeError < Error
|
|
89
93
|
def http_status
|
|
90
94
|
HTTP::UNSUPPORTED_MEDIA_TYPE
|
data/lib/syntropy/http/client.rb
CHANGED
|
@@ -156,7 +156,6 @@ module Syntropy
|
|
|
156
156
|
|
|
157
157
|
formatted_headers = format_headers(headers, body)
|
|
158
158
|
@response_headers = headers
|
|
159
|
-
request&.tx_incr(formatted_headers.bytesize + (body ? body.bytesize : 0))
|
|
160
159
|
if body
|
|
161
160
|
chunk_prelude = "#{body.bytesize.to_s(16)}\r\n"
|
|
162
161
|
@machine.sendv(@fd, formatted_headers, chunk_prelude, body, CHUNKED_ENCODING_POSTLUDE)
|
|
@@ -175,7 +174,6 @@ module Syntropy
|
|
|
175
174
|
# @return [void]
|
|
176
175
|
def send_headers(request, headers, empty_response: false)
|
|
177
176
|
formatted_headers = format_headers(headers, !empty_response)
|
|
178
|
-
request.tx_incr(formatted_headers.bytesize)
|
|
179
177
|
@machine.send(@fd, formatted_headers, formatted_headers.bytesize, SEND_FLAGS)
|
|
180
178
|
@response_headers = headers
|
|
181
179
|
end
|
|
@@ -193,7 +191,6 @@ module Syntropy
|
|
|
193
191
|
data << EMPTY_CHUNK if done
|
|
194
192
|
return if data.empty?
|
|
195
193
|
|
|
196
|
-
request.tx_incr(data.bytesize)
|
|
197
194
|
@machine.send(@fd, data, data.bytesize, SEND_FLAGS)
|
|
198
195
|
return if @done || !done
|
|
199
196
|
|
|
@@ -205,7 +202,6 @@ module Syntropy
|
|
|
205
202
|
# default headers are sent using #send_headers.
|
|
206
203
|
# @return [void]
|
|
207
204
|
def finish(request)
|
|
208
|
-
request.tx_incr(EMPTY_CHUNK_LEN)
|
|
209
205
|
@machine.send(@fd, EMPTY_CHUNK, EMPTY_CHUNK_LEN, SEND_FLAGS)
|
|
210
206
|
return if @done
|
|
211
207
|
|
data/lib/syntropy/json_api.rb
CHANGED
|
@@ -4,11 +4,20 @@ require 'syntropy/errors'
|
|
|
4
4
|
require 'json'
|
|
5
5
|
|
|
6
6
|
module Syntropy
|
|
7
|
+
# JSONAPI is a controller that implements a JSON API.
|
|
7
8
|
class JSONAPI
|
|
9
|
+
# Initializes the controller.
|
|
10
|
+
#
|
|
11
|
+
# @param env [Hash] app environment
|
|
12
|
+
# @return [void]
|
|
8
13
|
def initialize(env)
|
|
9
14
|
@env = env
|
|
10
15
|
end
|
|
11
16
|
|
|
17
|
+
# Processes the given request.
|
|
18
|
+
#
|
|
19
|
+
# @param req [Syntropy::Request]
|
|
20
|
+
# @return [void]
|
|
12
21
|
def call(req)
|
|
13
22
|
response, status = __invoke__(req)
|
|
14
23
|
req.respond(
|
|
@@ -20,6 +29,9 @@ module Syntropy
|
|
|
20
29
|
|
|
21
30
|
private
|
|
22
31
|
|
|
32
|
+
# Processes the request by invoking the corresponding object method.
|
|
33
|
+
#
|
|
34
|
+
# @param req [Syntropy::Request]
|
|
23
35
|
def __invoke__(req)
|
|
24
36
|
q = req.validate_param(:q, String).to_sym
|
|
25
37
|
response = case req.method
|
|
@@ -31,7 +43,7 @@ module Syntropy
|
|
|
31
43
|
raise Syntropy::Error.method_not_allowed
|
|
32
44
|
end
|
|
33
45
|
[{ status: 'OK', response: response }, HTTP::OK]
|
|
34
|
-
rescue => e
|
|
46
|
+
rescue StandardError => e
|
|
35
47
|
if !e.is_a?(Syntropy::Error)
|
|
36
48
|
p e
|
|
37
49
|
p e.backtrace
|
|
@@ -39,6 +51,11 @@ module Syntropy
|
|
|
39
51
|
__error_response__(e)
|
|
40
52
|
end
|
|
41
53
|
|
|
54
|
+
# Processes a GET request.
|
|
55
|
+
#
|
|
56
|
+
# @param sym [Symbol] object method
|
|
57
|
+
# @param req [Syntropy::Request] request
|
|
58
|
+
# @return [any] method call return value
|
|
42
59
|
def __invoke_get__(sym, req)
|
|
43
60
|
return send(sym, req) if respond_to?(sym)
|
|
44
61
|
|
|
@@ -46,6 +63,11 @@ module Syntropy
|
|
|
46
63
|
raise err
|
|
47
64
|
end
|
|
48
65
|
|
|
66
|
+
# Processes a POST request.
|
|
67
|
+
#
|
|
68
|
+
# @param sym [Symbol] object method
|
|
69
|
+
# @param req [Syntropy::Request] request
|
|
70
|
+
# @return [any] method call return value
|
|
49
71
|
def __invoke_post__(sym, req)
|
|
50
72
|
sym_post = :"#{sym}!"
|
|
51
73
|
return send(sym_post, req) if respond_to?(sym_post)
|
|
@@ -54,6 +76,10 @@ module Syntropy
|
|
|
54
76
|
raise err
|
|
55
77
|
end
|
|
56
78
|
|
|
79
|
+
# Generates an error response in case of exception.
|
|
80
|
+
#
|
|
81
|
+
# @param err [Exception] raised Exception
|
|
82
|
+
# @return [Hash] error response
|
|
57
83
|
def __error_response__(err)
|
|
58
84
|
http_status = err.respond_to?(:http_status) ? err.http_status : HTTP::INTERNAL_SERVER_ERROR
|
|
59
85
|
error_name = err.class.name.split('::').last
|
data/lib/syntropy/logger.rb
CHANGED
|
@@ -3,29 +3,51 @@
|
|
|
3
3
|
require 'json'
|
|
4
4
|
|
|
5
5
|
module Syntropy
|
|
6
|
+
# The Logger class implements a logger with support for structured logging.
|
|
6
7
|
class Logger
|
|
8
|
+
# Initializes the logger.
|
|
9
|
+
#
|
|
10
|
+
# @param machine [UringMachine] machine instance
|
|
11
|
+
# @param fd [Integer] file descriptor for writing log messages
|
|
12
|
+
# @param opts [Hash] logger options
|
|
13
|
+
# @return [void]
|
|
7
14
|
def initialize(machine, fd = $stdout.fileno, **opts)
|
|
8
15
|
@machine = machine
|
|
9
16
|
@fd = fd
|
|
10
17
|
@opts = opts
|
|
11
18
|
end
|
|
12
19
|
|
|
20
|
+
# Logs an INFO entry.
|
|
21
|
+
#
|
|
22
|
+
# @param o [Hash] log entry
|
|
23
|
+
# @return [void]
|
|
13
24
|
def info(o)
|
|
14
25
|
call(:INFO, o)
|
|
15
26
|
end
|
|
16
27
|
|
|
28
|
+
# Logs an WARN entry.
|
|
29
|
+
#
|
|
30
|
+
# @param o [Hash] log entry
|
|
31
|
+
# @return [void]
|
|
17
32
|
def warn(o)
|
|
18
33
|
call(:WARN, o)
|
|
19
34
|
end
|
|
20
35
|
|
|
36
|
+
# Logs an ERROR entry.
|
|
37
|
+
#
|
|
38
|
+
# @param o [Hash] log entry
|
|
39
|
+
# @return [void]
|
|
21
40
|
def error(o)
|
|
22
41
|
call(:ERROR, o)
|
|
23
42
|
end
|
|
24
43
|
|
|
25
44
|
private
|
|
26
45
|
|
|
27
|
-
#
|
|
28
|
-
#
|
|
46
|
+
# Writes a log entry.
|
|
47
|
+
#
|
|
48
|
+
# @param level [Symbol] log level
|
|
49
|
+
# @param o [Hash] entry
|
|
50
|
+
# @return [void]
|
|
29
51
|
def call(level, o)
|
|
30
52
|
emit(make_entry(level, o))
|
|
31
53
|
rescue StandardError => e
|
|
@@ -35,10 +57,20 @@ module Syntropy
|
|
|
35
57
|
exit
|
|
36
58
|
end
|
|
37
59
|
|
|
60
|
+
# Emits an entry to the associated output.
|
|
61
|
+
#
|
|
62
|
+
# @param entry [Hash] log entry
|
|
63
|
+
# @return [void]
|
|
38
64
|
def emit(entry)
|
|
39
65
|
@machine.write_async(@fd, "#{entry.to_json}\n")
|
|
40
66
|
end
|
|
41
67
|
|
|
68
|
+
# Transforms raw entry into a log entry. Additional information is added
|
|
69
|
+
# dependending on the kind of entry.
|
|
70
|
+
#
|
|
71
|
+
# @param level [Symbol] log level
|
|
72
|
+
# @param o [Hash] raw entry
|
|
73
|
+
# @return [Hash] log entry
|
|
42
74
|
def make_entry(level, o)
|
|
43
75
|
if o[:request]
|
|
44
76
|
make_request_entry(level, o)
|
|
@@ -49,52 +81,74 @@ module Syntropy
|
|
|
49
81
|
end
|
|
50
82
|
end
|
|
51
83
|
|
|
84
|
+
# Makes an error log entry.
|
|
85
|
+
#
|
|
86
|
+
# @param level [Symbol] log level
|
|
87
|
+
# @param o [Hash] input entry
|
|
88
|
+
# @return [Hash] output entry
|
|
52
89
|
def make_error_entry(level, o)
|
|
53
90
|
err = o[:error]
|
|
91
|
+
t = Time.now
|
|
54
92
|
{
|
|
55
|
-
level:
|
|
56
|
-
ts:
|
|
57
|
-
ts_s:
|
|
58
|
-
}
|
|
59
|
-
.merge(o)
|
|
60
|
-
.merge(
|
|
93
|
+
level: level.to_s,
|
|
94
|
+
ts: t.to_i,
|
|
95
|
+
ts_s: t.iso8601
|
|
96
|
+
}.merge(o).merge(
|
|
61
97
|
error: "#{err.class}: #{err.message}",
|
|
62
98
|
backtrace: err.backtrace
|
|
63
99
|
)
|
|
64
100
|
end
|
|
65
101
|
|
|
102
|
+
# Makes a request log entry.
|
|
103
|
+
#
|
|
104
|
+
# @param level [Symbol] log level
|
|
105
|
+
# @param o [Hash] input entry
|
|
106
|
+
# @return [Hash] output entry
|
|
66
107
|
def make_request_entry(level, o)
|
|
67
108
|
request = o[:request]
|
|
68
109
|
request_headers = request.headers
|
|
69
110
|
response_headers = o[:response_headers]
|
|
70
111
|
elapsed = monotonic_clock - request.start_stamp
|
|
112
|
+
t = Time.now
|
|
71
113
|
{
|
|
72
|
-
level:
|
|
73
|
-
ts:
|
|
74
|
-
ts_s:
|
|
75
|
-
message:
|
|
76
|
-
client_ip:
|
|
77
|
-
http_method:
|
|
78
|
-
user_agent:
|
|
79
|
-
uri:
|
|
80
|
-
status:
|
|
81
|
-
elapsed:
|
|
114
|
+
level: level.to_s,
|
|
115
|
+
ts: t.to_i,
|
|
116
|
+
ts_s: t.iso8601,
|
|
117
|
+
message: o[:message] || 'HTTP request done',
|
|
118
|
+
client_ip: request.forwarded_for || '?',
|
|
119
|
+
http_method: request_headers[':method'].upcase,
|
|
120
|
+
user_agent: request_headers['user-agent'],
|
|
121
|
+
uri: full_uri(request_headers),
|
|
122
|
+
status: response_headers[':status'] || '200',
|
|
123
|
+
elapsed: elapsed
|
|
82
124
|
}
|
|
83
125
|
end
|
|
84
126
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
127
|
+
# Makes a request log entry.
|
|
128
|
+
#
|
|
129
|
+
# @param level [Symbol] log level
|
|
130
|
+
# @param o [Hash] input entry
|
|
131
|
+
# @return [Hash] output entry
|
|
89
132
|
def make_hash_entry(level, hash)
|
|
133
|
+
t = Time.now
|
|
90
134
|
{
|
|
91
|
-
level:
|
|
92
|
-
ts:
|
|
93
|
-
ts_s:
|
|
94
|
-
}
|
|
95
|
-
|
|
135
|
+
level: level.to_s,
|
|
136
|
+
ts: t.to_i,
|
|
137
|
+
ts_s: t.iso8601
|
|
138
|
+
}.merge(hash)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Returns the monotonic clock.
|
|
142
|
+
#
|
|
143
|
+
# @return [Float]
|
|
144
|
+
def monotonic_clock
|
|
145
|
+
::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
96
146
|
end
|
|
97
147
|
|
|
148
|
+
# Returns the full request URI for the given request headers.
|
|
149
|
+
#
|
|
150
|
+
# @param headers [Hash] request headers
|
|
151
|
+
# @return [String] request URI
|
|
98
152
|
def full_uri(headers)
|
|
99
153
|
format(
|
|
100
154
|
'%<scheme>s://%<host>s%<path>s',
|
data/lib/syntropy/markdown.rb
CHANGED
|
@@ -3,39 +3,68 @@
|
|
|
3
3
|
require 'yaml'
|
|
4
4
|
|
|
5
5
|
module Syntropy
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
# Parses the markdown file at the given path.
|
|
14
|
-
#
|
|
15
|
-
# @param path [String] file path
|
|
16
|
-
# @return [Array] an tuple containing properties<Hash>, contents<String>
|
|
17
|
-
def self.parse_markdown_file(path, env)
|
|
18
|
-
content = IO.read(path) || ''
|
|
19
|
-
atts = {}
|
|
20
|
-
|
|
21
|
-
# Parse date from file name
|
|
22
|
-
m = path.match(DATE_REGEXP)
|
|
23
|
-
atts[:date] ||= Date.parse(m[1]) if m
|
|
24
|
-
|
|
25
|
-
if (m = content.match(FRONT_MATTER_REGEXP))
|
|
26
|
-
front_matter = m[1]
|
|
27
|
-
content = m.post_match
|
|
28
|
-
|
|
29
|
-
yaml = YAML.safe_load(front_matter, **YAML_OPTS)
|
|
30
|
-
atts = atts.merge(yaml)
|
|
31
|
-
end
|
|
6
|
+
# Markdown parsing.
|
|
7
|
+
module Markdown
|
|
8
|
+
FRONT_MATTER_REGEXP = /\A(---\s*\n.*?\n?)^((---|\.\.\.)\s*$\n?)/m
|
|
9
|
+
YAML_OPTS = {
|
|
10
|
+
permitted_classes: [Date],
|
|
11
|
+
symbolize_names: true
|
|
12
|
+
}.freeze
|
|
32
13
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
14
|
+
class << self
|
|
15
|
+
# Parses the markdown file at the given path.
|
|
16
|
+
#
|
|
17
|
+
# @param path [String] file path
|
|
18
|
+
# @return [Array] an tuple containing properties<Hash>, contents<String>
|
|
19
|
+
def parse(path, env)
|
|
20
|
+
content = IO.read(path) || ''
|
|
21
|
+
atts = {}
|
|
22
|
+
|
|
23
|
+
parse_date(path, atts)
|
|
24
|
+
content = parse_content(content, atts)
|
|
25
|
+
atts[:url] = path_to_url(path, env[:app_root]) if env[:app_root]
|
|
26
|
+
|
|
27
|
+
[atts, content]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
38
31
|
|
|
39
|
-
|
|
32
|
+
# Parses date information from the given path.
|
|
33
|
+
#
|
|
34
|
+
# @param path [String] file path
|
|
35
|
+
# @param atts [Hash] file attributes
|
|
36
|
+
# @return [void]
|
|
37
|
+
def parse_date(path, atts)
|
|
38
|
+
# Parse date from file name
|
|
39
|
+
if (m = path.match(/(\d{4}-\d{2}-\d{2})/))
|
|
40
|
+
atts[:date] ||= Date.parse(m[1])
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Parses the markdown content and front matter attributes from the given content.
|
|
45
|
+
#
|
|
46
|
+
# @param content [String] file content
|
|
47
|
+
# @param atts [Hash] file attributes
|
|
48
|
+
# @return [String] parsed markdown content
|
|
49
|
+
def parse_content(content, atts)
|
|
50
|
+
if (m = content.match(FRONT_MATTER_REGEXP))
|
|
51
|
+
front_matter = m[1]
|
|
52
|
+
content = m.post_match
|
|
53
|
+
|
|
54
|
+
yaml = YAML.safe_load(front_matter, **YAML_OPTS)
|
|
55
|
+
atts.merge!(yaml)
|
|
56
|
+
end
|
|
57
|
+
content
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Converts the markdown file path to URL
|
|
61
|
+
#
|
|
62
|
+
# @param path [String] file path
|
|
63
|
+
# @param app_root [String] app root directory
|
|
64
|
+
# @return [String] url
|
|
65
|
+
def path_to_url(path, app_root)
|
|
66
|
+
path.gsub(/#{app_root}/, '').gsub(/\.md$/, '')
|
|
67
|
+
end
|
|
68
|
+
end
|
|
40
69
|
end
|
|
41
70
|
end
|
data/lib/syntropy/mime_types.rb
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Syntropy
|
|
4
|
-
#
|
|
4
|
+
# The MimeTypes module maps file extensions to MIME types.
|
|
5
5
|
module MimeTypes
|
|
6
6
|
TYPES = {
|
|
7
7
|
'html' => 'text/html',
|
|
@@ -21,16 +21,20 @@ module Syntropy
|
|
|
21
21
|
|
|
22
22
|
EXT_REGEXP = /\.?([^\.]+)$/.freeze
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
# Returns the mime type for the given file extension.
|
|
25
|
+
#
|
|
26
|
+
# @param ext [String, Symbol] file extension
|
|
27
|
+
# @return [String, nil] MIME type
|
|
28
|
+
def self.[](ext)
|
|
29
|
+
case ext
|
|
26
30
|
when Symbol
|
|
27
|
-
TYPES[
|
|
31
|
+
TYPES[ext.to_s]
|
|
28
32
|
when EXT_REGEXP
|
|
29
33
|
TYPES[Regexp.last_match(1)]
|
|
30
34
|
when ''
|
|
31
35
|
nil
|
|
32
36
|
else
|
|
33
|
-
raise "Invalid argument #{
|
|
37
|
+
raise "Invalid argument #{ext.inspect}"
|
|
34
38
|
end
|
|
35
39
|
end
|
|
36
40
|
end
|