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.
Files changed (97) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +7 -0
  3. data/cmd/console.rb +18 -7
  4. data/cmd/serve.rb +26 -18
  5. data/cmd/test.rb +37 -24
  6. data/examples/blog/.gitignore +1 -0
  7. data/examples/blog/app/_lib/database.rb +13 -0
  8. data/examples/blog/app/_lib/{post_store.rb → posts.rb} +3 -1
  9. data/examples/blog/app/posts/[id]/edit.rb +2 -2
  10. data/examples/blog/app/posts/[id]/index.rb +4 -4
  11. data/examples/blog/app/posts/index.rb +4 -4
  12. data/examples/blog/app/posts/new.rb +1 -1
  13. data/examples/blog/config/development.rb +5 -0
  14. data/examples/blog/config/production.rb +4 -0
  15. data/examples/blog/config/test.rb +5 -0
  16. data/examples/blog/test/test_posts.rb +65 -0
  17. data/examples/mcp-oauth/app/oauth/token.rb +1 -1
  18. data/lib/syntropy/app.rb +48 -40
  19. data/lib/syntropy/applets/builtin/auto_refresh/watch.sse.rb +1 -1
  20. data/lib/syntropy/db/schema.rb +1 -1
  21. data/lib/syntropy/db/store.rb +2 -0
  22. data/lib/syntropy/errors.rb +6 -2
  23. data/lib/syntropy/http/client.rb +1 -0
  24. data/lib/syntropy/http/server_connection.rb +0 -4
  25. data/lib/syntropy/json_api.rb +27 -1
  26. data/lib/syntropy/logger.rb +81 -27
  27. data/lib/syntropy/markdown.rb +61 -32
  28. data/lib/syntropy/mime_types.rb +9 -5
  29. data/lib/syntropy/module_loader.rb +20 -9
  30. data/lib/syntropy/papercraft_extensions.rb +2 -2
  31. data/lib/syntropy/request/mock_adapter.rb +10 -8
  32. data/lib/syntropy/request/request_info.rb +91 -0
  33. data/lib/syntropy/request/response.rb +1 -12
  34. data/lib/syntropy/request/validation.rb +1 -0
  35. data/lib/syntropy/request.rb +51 -19
  36. data/lib/syntropy/routing_tree.rb +27 -28
  37. data/lib/syntropy/session.rb +198 -0
  38. data/lib/syntropy/side_run.rb +25 -2
  39. data/lib/syntropy/test.rb +105 -10
  40. data/lib/syntropy/utils.rb +53 -18
  41. data/lib/syntropy/version.rb +1 -1
  42. data/lib/syntropy.rb +44 -10
  43. data/test/bm_router_proc.rb +4 -4
  44. data/test/fixtures/app/class_instance.rb +5 -0
  45. data/test/fixtures/app/http.rb +5 -0
  46. data/test/fixtures/app/post_ct.rb +5 -0
  47. data/test/fixtures/app/singleton.rb +3 -0
  48. data/test/test_app.rb +13 -52
  49. data/test/test_caching.rb +2 -2
  50. data/test/test_db_schema.rb +1 -1
  51. data/test/test_http_server_connection.rb +3 -3
  52. data/test/test_module_loader.rb +5 -2
  53. data/test/test_response.rb +0 -19
  54. data/test/test_routing_tree.rb +69 -69
  55. data/test/test_server.rb +5 -9
  56. data/test/test_test.rb +70 -0
  57. metadata +52 -42
  58. data/examples/blog/app/_setup.rb +0 -4
  59. data/lib/syntropy/request/session.rb +0 -113
  60. /data/test/{app → fixtures/app}/.well-known/foo.rb +0 -0
  61. /data/test/{app → fixtures/app}/_hook.rb +0 -0
  62. /data/test/{app → fixtures/app}/_layout/default.rb +0 -0
  63. /data/test/{app → fixtures/app}/_lib/callable.rb +0 -0
  64. /data/test/{app → fixtures/app}/_lib/dep.rb +0 -0
  65. /data/test/{app → fixtures/app}/_lib/env.rb +0 -0
  66. /data/test/{app → fixtures/app}/_lib/klass.rb +0 -0
  67. /data/test/{app → fixtures/app}/_lib/missing-export.rb +0 -0
  68. /data/test/{app → fixtures/app}/_lib/self.rb +0 -0
  69. /data/test/{app → fixtures/app}/about/_error.rb +0 -0
  70. /data/test/{app → fixtures/app}/about/foo.md +0 -0
  71. /data/test/{app → fixtures/app}/about/index.rb +0 -0
  72. /data/test/{app → fixtures/app}/about/raise.rb +0 -0
  73. /data/test/{app → fixtures/app}/api+.rb +0 -0
  74. /data/test/{app → fixtures/app}/assets/style.css +0 -0
  75. /data/test/{app → fixtures/app}/bad_mod.rb +0 -0
  76. /data/test/{app → fixtures/app}/bar.rb +0 -0
  77. /data/test/{app → fixtures/app}/baz.rb +0 -0
  78. /data/test/{app → fixtures/app}/by_method.rb +0 -0
  79. /data/test/{app → fixtures/app}/deps.rb +0 -0
  80. /data/test/{app → fixtures/app}/index.html +0 -0
  81. /data/test/{app → fixtures/app}/mod/bar/index+.rb +0 -0
  82. /data/test/{app → fixtures/app}/mod/foo/index.rb +0 -0
  83. /data/test/{app → fixtures/app}/mod/path/a.rb +0 -0
  84. /data/test/{app → fixtures/app}/mod/path/b.rb +0 -0
  85. /data/test/{app → fixtures/app}/params/[foo].rb +0 -0
  86. /data/test/{app → fixtures/app}/rss.rb +0 -0
  87. /data/test/{app → fixtures/app}/tmp.rb +0 -0
  88. /data/test/{app_custom → fixtures/app_custom}/_site.rb +0 -0
  89. /data/test/{app_multi_site → fixtures/app_multi_site}/_site.rb +0 -0
  90. /data/test/{app_multi_site → fixtures/app_multi_site}/bar.baz/index.html +0 -0
  91. /data/test/{app_multi_site → fixtures/app_multi_site}/foo.bar/index.html +0 -0
  92. /data/test/{app_setup → fixtures/app_setup}/_setup.rb +0 -0
  93. /data/test/{app_setup → fixtures/app_setup}/index.rb +0 -0
  94. /data/test/{app_with_schema → fixtures/app_with_schema}/_schema/2026-01-02-foo.rb +0 -0
  95. /data/test/{app_with_schema → fixtures/app_with_schema}/_schema/2026-05-30-bar.rb +0 -0
  96. /data/test/{schema → fixtures/schema}/2026-01-02-foo.rb +0 -0
  97. /data/test/{schema → fixtures/schema}/2026-05-30-bar.rb +0 -0
@@ -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 (root_dir) and constructs a
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 :root_dir, :mount_path, :static_map, :dynamic_map, :root
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 root_dir [String] root directory of file tree
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(root_dir:, mount_path:, **env)
52
- @root_dir = root_dir
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(@root_dir, '')
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(@root_dir)}\//, '').sub(/\.[^\.]+$/, '')
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: @root_dir, rel_path: '/', parent: nil)
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) ? ({ kind: :module, fn: }) : nil
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
- compute_route_file(fn:, rel_path:, parent:)
251
- elsif File.directory?(fn)
252
- compute_route_directory(dir: fn, rel_path:, parent:)
253
- end
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 is_parametric_route?(parent) || handle_subtree
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 is_parametric_route?(entry) || entry[:handle_subtree]
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 is_parametric_route?(entry)
398
- entry[:param] || (entry[:parent] && is_parametric_route?(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#.tap { puts '*' * 40; puts it; puts }
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 is_void_route?(entry)
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]&.values&.each { |e|
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 is_void_route?(entry)
595
+ def void_route?(entry)
597
596
  return false if entry[:param] || entry[:target]
597
+ return true if entry[:static]
598
598
 
599
- if entry[:children]
600
- return true if !entry[:children]['[]'] && entry[:children]&.values&.all? { is_void_route?(it) }
601
- else
602
- return true if entry[:static]
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 is_void_route?(child_entry) && !param_entry
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)
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'json'
5
+ require 'securerandom'
6
+
7
+ module Syntropy
8
+ # A Session object serves as storage for data associated with the user's
9
+ # browser session, such as flash data (for communicating notices and alerts).
10
+ # The session is modeled as a key-value store, where keys are strings, and
11
+ # values can be any value that can be represented in JSON, including arrays
12
+ # and hashes.
13
+ #
14
+ # The session data is stored as an HTTP cookie.
15
+ class Session
16
+ # Initializes the session.
17
+ #
18
+ # @param request [Syntropy::Request] associated request
19
+ # @return [void]
20
+ def initialize(request)
21
+ @request = request
22
+ @data = nil
23
+ end
24
+
25
+ # Returns the value associated with the given key.
26
+ #
27
+ # @param key [String]
28
+ # @return [any] value
29
+ def [](key)
30
+ @data ||= load
31
+ @data[key]
32
+ end
33
+
34
+ # Sets the value for the given key and updates the response session cookie.
35
+ #
36
+ # @param key [String]
37
+ # @param value [any]
38
+ # @return [void]
39
+ def []=(key, value)
40
+ @data ||= load
41
+ @data[key] = value
42
+ save(@data)
43
+ end
44
+
45
+ # Deletes the given key-value pair and updates the response session cookie.
46
+ #
47
+ # @param key [String]
48
+ # @return [any] deleted value
49
+ def delete(key)
50
+ @data ||= load
51
+ value = @data.delete(key)
52
+ save(@data.empty? ? nil : @data)
53
+ value
54
+ end
55
+
56
+ # Discards the session data, updating the response session cookie by
57
+ # emptying it.
58
+ #
59
+ # @return [void]
60
+ def discard
61
+ save(nil)
62
+ end
63
+
64
+ # Returns the flash storage for the session.
65
+ #
66
+ # @return [Syntropy::Session::Flash]
67
+ def flash
68
+ @data ||= load
69
+ @flash ||= Flash.new(self)
70
+ end
71
+
72
+ private
73
+
74
+ # Loads session data from the request session cookie.
75
+ #
76
+ # @return [Hash] session data
77
+ def load
78
+ data = @request.cookies['__syntropy_session__']
79
+ return {} if !data
80
+
81
+ JSON.parse(Base64.decode64(data))
82
+ rescue JSON::ParserError
83
+ {}
84
+ ensure
85
+ @loaded = true
86
+ end
87
+
88
+ # Saves session data to the response session cookie.
89
+ #
90
+ # @param data [Hash] session data
91
+ # @return [void]
92
+ def save(data)
93
+ cookie = data ? "#{Base64.strict_encode64(JSON.dump(data))}; Path=/; HttpOnly" : nil
94
+ @request.set_cookie('__syntropy_session__', cookie)
95
+ end
96
+ end
97
+
98
+ # NowFlash holds flash data for the current request.
99
+ class NowFlash
100
+ def initialize
101
+ @data = {}
102
+ end
103
+
104
+ # Returns the value for the given key.
105
+ #
106
+ # @param key [Symbol]
107
+ # @return [any] flash value
108
+ def [](key)
109
+ @data[key.to_s]
110
+ end
111
+
112
+ # Sets the value for the given key.
113
+ #
114
+ # @param key [Symbol]
115
+ # @param value [any]
116
+ # @return [any] value
117
+ def []=(key, value)
118
+ @data[key.to_s] = value
119
+ end
120
+
121
+ # Iterates through the flash storage, yielding each key-value pair to the
122
+ # given block.
123
+ #
124
+ # @return [void]
125
+ def each(&block)
126
+ @data.each { |k, v| block.(k.to_sym, v) }
127
+ end
128
+ end
129
+
130
+ # Flash acts as a special storage mechanism for transient information that
131
+ # can be passsed between consecutive requests in the same session. Flash
132
+ # values can be set in order to be retrieved in the next response. Reading
133
+ # from flash storage will return data that was set in the previous request.
134
+ # Data written to the flash storage will only be available to the next
135
+ # request. You can also set flash data that will be available to the current
136
+ # request by using Flash#now.
137
+ #
138
+ # In order to keep the read flash data (set in the previous request) and
139
+ # make it available to the next request, use Flash#keep.
140
+ class Flash
141
+ # Initializes the flash storage.
142
+ #
143
+ # @return [void]
144
+ def initialize(session)
145
+ @session = session
146
+ @current_flash_data = @session['_flash']
147
+ @session.delete('_flash') if @current_flash_data
148
+ @current_flash_data ||= {}
149
+ @future_flash_data = {}
150
+ @now_flash_data = NowFlash.new
151
+ end
152
+
153
+ # Reads data from flash storage for the given key. The value would have
154
+ # been set in the previous request.
155
+ #
156
+ # @param key [Symbol]
157
+ # @return [any] value
158
+ def [](key)
159
+ key = key.to_s
160
+ @now_flash_data[key] || @current_flash_data[key]
161
+ end
162
+
163
+ # Sets the flash storage value for the given key. The value would be
164
+ # available to the next request.
165
+ #
166
+ # @param key [Symbol]
167
+ # @param value [any]
168
+ # @return [void]
169
+ def []=(key, value)
170
+ key = key.to_s
171
+ @future_flash_data[key] = value
172
+ @session['_flash'] = @future_flash_data
173
+ end
174
+
175
+ # Iterates through the flash storage, passing each key-value pair to the
176
+ # given block.
177
+ #
178
+ # @return [void]
179
+ def each(&block)
180
+ @current_flash_data.each_pair { |k, v| block.(k.to_sym, v) }
181
+ end
182
+
183
+ # Persists any flash data set in the previous request to the next request.
184
+ #
185
+ # @return [void]
186
+ def keep
187
+ @future_flash_data = @current_flash_data.merge!(@future_flash_data)
188
+ @session['_flash'] = @future_flash_data
189
+ end
190
+
191
+ # Returns the flash storage for the current request.
192
+ #
193
+ # @return [Syntropy::NowFlash]
194
+ def now
195
+ @now_flash_data
196
+ end
197
+ end
198
+ end
@@ -3,8 +3,16 @@
3
3
  require 'etc'
4
4
 
5
5
  module Syntropy
6
+ # SideRun implements running an operation on a separate thread.
6
7
  module SideRun
7
8
  class << self
9
+ # Runs the given block on a separate thread, using UringMachine to wait
10
+ # for the operation to complete. If the operation results in a raised
11
+ # exception, that exception will be reraised in the context of the waiting
12
+ # fiber.
13
+ #
14
+ # @param machine [UringMachine] machine instance
15
+ # @return [any] operation return value
8
16
  def call(machine, &block)
9
17
  setup if !@queue
10
18
 
@@ -15,6 +23,11 @@ module Syntropy
15
23
  result.is_a?(Exception) ? (raise result) : result
16
24
  end
17
25
 
26
+ private
27
+
28
+ # Sets up a thread pool for side-running operations.
29
+ #
30
+ # @return [void]
18
31
  def setup
19
32
  @queue = UM::Queue.new
20
33
  count = (Etc.nprocessors - 1).clamp(2..6)
@@ -23,9 +36,13 @@ module Syntropy
23
36
  }
24
37
  end
25
38
 
39
+ # Runs worker loop for running side-run operations.
40
+ #
41
+ # @param queue [UringMachine::Queue] queue for pulling operations
42
+ # @return [void]
26
43
  def side_run_worker(queue)
27
44
  machine = UM.new
28
- loop { handle_request(machine, queue) }
45
+ loop { run_op(machine, queue) }
29
46
  rescue UM::Terminate
30
47
  # # We can also add a timeout here
31
48
  # t0 = Time.now
@@ -34,7 +51,13 @@ module Syntropy
34
51
  # end
35
52
  end
36
53
 
37
- def handle_request(machine, queue)
54
+ # Pulls an operation from the given queue and runs it, pushing its return
55
+ # value to the corresponding mailbox.
56
+ #
57
+ # @param machine [UringMachine] machine instance
58
+ # @param queue [UringMachine::Queue] op queue
59
+ # @return [void]
60
+ def run_op(machine, queue)
38
61
  response_mailbox, closure = machine.shift(queue)
39
62
  result = closure.call
40
63
  machine.push(response_mailbox, result)
data/lib/syntropy/test.rb CHANGED
@@ -7,27 +7,49 @@ require 'json'
7
7
  require 'uri'
8
8
 
9
9
  module Syntropy
10
+ # Test provides a class for testing a Syntropy app, based on Minitest.
10
11
  class Test < Minitest::Test
11
12
  HTTP = Syntropy::HTTP
12
13
 
14
+ # Sets the app environment for all Syntropy tests.
15
+ #
16
+ # @param env [Hash] app environment hash
17
+ # @return [void]
13
18
  def self.env=(env)
14
19
  @@env = env
15
20
  end
16
21
 
17
22
  attr_reader :machine, :app
18
23
 
24
+ # Returns the test environment.
25
+ #
26
+ # @return [Hash] test app environment
19
27
  def env
20
28
  @@env
21
29
  end
22
30
 
23
- def load_module(ref)
24
- app.module_loader.load(ref)
31
+ # Loads and returns a module with the given reference.
32
+ #
33
+ # @param ref [String] module reference
34
+ # @return [any] module
35
+ def load_module(ref, raise_on_missing: true)
36
+ app.module_loader.load(ref, raise_on_missing:)
25
37
  end
26
38
 
39
+ # Makes an HTTP request to the test app.
40
+ #
41
+ # @param headers [Hash] request headers
42
+ # @param body [String, nil] request body
43
+ # @return [Syntropy::Request]
27
44
  def http_request(headers, body = nil)
28
45
  @test_harness.request(headers, body)
29
46
  end
30
47
 
48
+ # Makes an HTTP GET request to the test app.
49
+ #
50
+ # @param path [String] request path
51
+ # @param headers [Hash] request headers
52
+ # @return [Syntropy::Request]
31
53
  def get(path, **headers)
32
54
  http_request(
33
55
  headers.merge(
@@ -37,6 +59,13 @@ module Syntropy
37
59
  )
38
60
  end
39
61
 
62
+ # Makes an HTTP POST request to the test app.
63
+ #
64
+ # @param path [String] request path
65
+ # @param content_type [String, nil] content MIME type
66
+ # @param body [String] request body
67
+ # @param headers [Hash] request headers
68
+ # @return [Syntropy::Request]
40
69
  def post(path, content_type, body, **headers)
41
70
  headers = headers.merge('content-type' => content_type) if content_type
42
71
  http_request(
@@ -50,26 +79,52 @@ module Syntropy
50
79
  )
51
80
  end
52
81
 
53
- def post_json(path, obj, **)
54
- post(path, 'application/json', JSON.dump(obj), **)
82
+ # Makes an HTTP POST request to the test app with a "application/json"
83
+ # content type. The given object is converted to JSON and sent as the
84
+ # request body.
85
+ #
86
+ # @param path [String] request path
87
+ # @param data [any] data
88
+ # @return [Syntropy::Request]
89
+ def post_json(path, data, **)
90
+ post(path, 'application/json', JSON.dump(data), **)
55
91
  end
56
92
 
57
- def post_form(path, form, **)
58
- post(path, 'application/x-www-form-urlencoded', URI.encode_www_form(form), **)
93
+ # Makes an HTTP POST request to the test app with a
94
+ # "application/x-www-form-urlencoded" content type. The given data is
95
+ # converted to URL Encoded form format and sent as the request body.
96
+ #
97
+ # @param path [String] request path
98
+ # @param data [Hash] form data
99
+ # @return [Syntropy::Request]
100
+ def post_form(path, data, **)
101
+ post(path, 'application/x-www-form-urlencoded', URI.encode_www_form(data), **)
59
102
  end
60
103
 
104
+ # Sets up a test instance.
105
+ #
106
+ # @return [void]
61
107
  def setup
62
108
  raise 'Environment not set' if !@@env
63
109
 
110
+ Syntropy.load_config(@@env)
111
+
64
112
  @machine = UM.new
65
113
  @app = Syntropy::App.new(
66
- root_dir: @@env[:root_dir],
67
- mount_path: '/',
68
- machine: @machine
114
+ **@@env.merge(
115
+ machine: @machine,
116
+ test_mode: true
117
+ )
69
118
  )
70
119
  @test_harness = Syntropy::TestHarness.new(@app)
120
+
121
+ @db = load_module('/_lib/database', raise_on_missing: false)
122
+ @db&.migrate!
71
123
  end
72
124
 
125
+ # Cleans up a test instance.
126
+ #
127
+ # @return [void]
73
128
  def teardown
74
129
  @machine = nil
75
130
  @app = nil
@@ -77,18 +132,31 @@ module Syntropy
77
132
  end
78
133
  end
79
134
 
135
+ # TestHarness provides glue code for performing HTTP requests against a
136
+ # Syntropy app.
80
137
  class TestHarness
138
+ # Initializes the test harness with the given app.
139
+ #
140
+ # @param app [Syntropy::App]
141
+ # @return [void]
81
142
  def initialize(app)
82
143
  @app = app
83
144
  @app.raise_internal_server_error = true if @app.respond_to?(:raise_internal_server_error=)
84
145
  end
85
146
 
147
+ # Perfrms a request against the associated app.
148
+ #
149
+ # @param headers [Hash] request headers
150
+ # @param body [String, nil] request body
151
+ # @return [Syntropy::Request]
86
152
  def request(headers, body = nil)
87
153
  req = mock_req(headers, body)
88
154
  @app.call(req)
89
155
  req
90
156
  end
91
157
 
158
+ # Temporarily disables raising an exception in case of an internal server
159
+ # error while running the given block.
92
160
  def no_raise_internal_server_error
93
161
  return yield if !@app.respond_to?(:raise_internal_server_error=)
94
162
 
@@ -102,29 +170,50 @@ module Syntropy
102
170
 
103
171
  private
104
172
 
173
+ # Creates a Syntropy request running on a mock adapter.
174
+ #
175
+ # @param headers [Hash] request headers
176
+ # @param body [String, nil] request body
177
+ # @return [Syntropy::Request]
105
178
  def mock_req(headers, body = nil)
106
179
  Syntropy::MockAdapter.mock(headers, body)
107
180
  end
108
181
  end
109
182
 
110
- class Request
183
+ # Extensions to Syntropy::Request for testing.
184
+ module TestRequestExtensions
185
+ # Returns the response headers.
186
+ #
187
+ # @return [Hash]
111
188
  def response_headers
112
189
  adapter.response_headers
113
190
  end
114
191
 
192
+ # Returns the response status
193
+ #
194
+ # @return [Integer]
115
195
  def response_status
116
196
  adapter.status
117
197
  end
118
198
 
199
+ # Returns the response body
200
+ #
201
+ # @return [String, nil]
119
202
  def response_body
120
203
  adapter.response_body
121
204
  end
122
205
 
206
+ # Parses the response body from JSON.
207
+ #
208
+ # @return [any] parsed JSON object
123
209
  def response_json
124
210
  raise if response_content_type != 'application/json'
125
211
  JSON.parse(response_body)
126
212
  end
127
213
 
214
+ # Returns the response content MIME type.
215
+ #
216
+ # @return [String, nil]
128
217
  def response_content_type
129
218
  ct = response_headers['Content-Type']
130
219
  return nil if !ct
@@ -135,6 +224,10 @@ module Syntropy
135
224
  m[1]
136
225
  end
137
226
 
227
+ # Returns the cookie value for the given cookie name from the response.
228
+ #
229
+ # @param name [String, Symbol] cookie name
230
+ # @return [String, nil] cookie value
138
231
  def response_cookie(name)
139
232
  sc = response_headers['Set-Cookie']
140
233
  return nil if !sc
@@ -145,4 +238,6 @@ module Syntropy
145
238
  m[1]
146
239
  end
147
240
  end
241
+
242
+ Request.include TestRequestExtensions
148
243
  end