rooibos 0.5.0 → 0.6.1
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/.builds/ruby-3.2.yml +9 -5
- data/.builds/ruby-3.3.yml +9 -5
- data/.builds/ruby-3.4.yml +9 -5
- data/.builds/ruby-4.0.0.yml +9 -5
- data/AGENTS.md +1 -1
- data/CHANGELOG.md +57 -0
- data/README.md +2 -2
- data/README.rdoc +374 -0
- data/REUSE.toml +5 -0
- data/Rakefile +1 -1
- data/doc/best_practices/forms_and_validation.md +20 -0
- data/doc/best_practices/http_workflows.md +20 -0
- data/doc/best_practices/index.md +26 -0
- data/doc/best_practices/lists_and_tables.md +20 -0
- data/doc/best_practices/modal_dialogs.md +20 -0
- data/doc/best_practices/no_stateful_widgets.md +184 -0
- data/doc/best_practices/orchestration.md +20 -0
- data/doc/best_practices/streaming_data.md +20 -0
- data/doc/contributors/design/commands_and_outlets.md +1 -1
- data/doc/contributors/documentation_plan.md +616 -0
- data/doc/contributors/documentation_stub_audit.md +112 -0
- data/doc/contributors/documentation_style.md +275 -0
- data/doc/contributors/e2e_pty.md +168 -0
- data/doc/contributors/specs/earliest_tutorial_steps_per_story.md +70 -0
- data/doc/contributors/specs/file_browser.md +789 -0
- data/doc/contributors/specs/file_browser_stories.md +774 -0
- data/doc/contributors/specs/tutorials_to_stories.rb +167 -0
- data/doc/contributors/todo/scrollbar.md +118 -0
- data/doc/contributors/tutorial_old/01_project_setup.md +20 -0
- data/doc/contributors/tutorial_old/02_hello_world.md +24 -0
- data/doc/contributors/tutorial_old/03_adding_state.md +26 -0
- data/doc/contributors/tutorial_old/06_organizing_your_code.md +20 -0
- data/doc/contributors/tutorial_old/07_your_first_command.md +21 -0
- data/doc/contributors/tutorial_old/08_the_preview_pane.md +20 -0
- data/doc/contributors/tutorial_old/09_loading_states.md +20 -0
- data/doc/contributors/tutorial_old/10_testing_your_app.md +20 -0
- data/doc/contributors/tutorial_old/11_polish_and_refine.md +20 -0
- data/doc/contributors/tutorial_old/12_going_further.md +20 -0
- data/doc/contributors/tutorial_old/index.md +20 -0
- data/doc/essentials/commands.md +20 -0
- data/doc/essentials/index.md +31 -0
- data/doc/essentials/messages.md +21 -0
- data/doc/essentials/models.md +21 -0
- data/doc/essentials/shortcuts.md +19 -0
- data/doc/essentials/the_elm_architecture.md +24 -0
- data/doc/essentials/the_runtime.md +21 -0
- data/doc/essentials/update_functions.md +20 -0
- data/doc/essentials/views.md +22 -0
- data/doc/getting_started/for_go_developers.md +16 -0
- data/doc/getting_started/for_python_developers.md +16 -0
- data/doc/getting_started/for_rails_developers.md +17 -0
- data/doc/getting_started/for_ratatui_ruby_developers.md +17 -0
- data/doc/getting_started/for_react_developers.md +17 -0
- data/doc/getting_started/index.md +52 -0
- data/doc/getting_started/install.md +20 -0
- data/doc/getting_started/quickstart.md +9 -45
- data/doc/getting_started/ruby_primer.md +19 -0
- data/doc/getting_started/why_rooibos.md +20 -0
- data/doc/index.md +79 -11
- data/doc/scaling_up/async_patterns.md +20 -0
- data/doc/scaling_up/command_composition.md +20 -0
- data/doc/scaling_up/custom_commands.md +21 -0
- data/doc/scaling_up/fractal_architecture.md +20 -0
- data/doc/scaling_up/index.md +30 -0
- data/doc/scaling_up/message_routing.md +20 -0
- data/doc/scaling_up/ractor_safety.md +20 -0
- data/doc/scaling_up/testing.md +21 -0
- data/doc/troubleshooting/common_errors.md +20 -0
- data/doc/troubleshooting/debugging.md +21 -0
- data/doc/troubleshooting/index.md +23 -0
- data/doc/troubleshooting/performance.md +20 -0
- data/doc/tutorial/01_project_setup.md +44 -0
- data/doc/tutorial/02_hello_world.md +45 -0
- data/doc/tutorial/03_static_file_list.md +44 -0
- data/doc/tutorial/04_arrow_navigation.md +47 -0
- data/doc/tutorial/05_real_files.md +45 -0
- data/doc/tutorial/06_safe_refactoring.md +21 -0
- data/doc/tutorial/07_red_first_tdd.md +26 -0
- data/doc/tutorial/08_file_metadata.md +42 -0
- data/doc/tutorial/09_text_preview.md +44 -0
- data/doc/tutorial/10_directory_tree.md +42 -0
- data/doc/tutorial/11_pane_focus.md +40 -0
- data/doc/tutorial/12_sorting.md +41 -0
- data/doc/tutorial/13_filtering.md +43 -0
- data/doc/tutorial/14_toggle_hidden.md +41 -0
- data/doc/tutorial/15_text_input_widget.md +43 -0
- data/doc/tutorial/16_rename_files.md +42 -0
- data/doc/tutorial/17_confirmation_dialogs.md +43 -0
- data/doc/tutorial/18_progress_indicators.md +43 -0
- data/doc/tutorial/19_atomic_operations.md +42 -0
- data/doc/tutorial/20_external_editor.md +42 -0
- data/doc/tutorial/21_modal_overlays.md +41 -0
- data/doc/tutorial/22_error_handling.md +43 -0
- data/doc/tutorial/23_terminal_capabilities.md +53 -0
- data/doc/tutorial/24_mouse_events.md +43 -0
- data/doc/tutorial/25_resize_events.md +43 -0
- data/doc/tutorial/26_loading_states.md +42 -0
- data/doc/tutorial/27_performance.md +43 -0
- data/doc/tutorial/28_color_schemes.md +47 -0
- data/doc/tutorial/29_configuration.md +124 -0
- data/doc/tutorial/30_going_further.md +17 -0
- data/doc/tutorial/index.md +17 -0
- data/examples/app_file_browser/app.rb +40 -0
- data/examples/app_fractal_dashboard/dashboard/update_manual.rb +7 -7
- data/examples/app_fractal_dashboard/fragments/custom_shell_input.rb +5 -5
- data/examples/app_fractal_dashboard/fragments/custom_shell_modal.rb +1 -1
- data/examples/app_fractal_dashboard/fragments/disk_usage.rb +2 -2
- data/examples/app_fractal_dashboard/fragments/network_panel.rb +4 -4
- data/examples/app_fractal_dashboard/fragments/ping.rb +2 -2
- data/examples/app_fractal_dashboard/fragments/stats_panel.rb +4 -4
- data/examples/app_fractal_dashboard/fragments/system_info.rb +2 -2
- data/examples/app_fractal_dashboard/fragments/uptime.rb +2 -2
- data/examples/verify_website_first_app/app.rb +85 -0
- data/examples/verify_website_hello_mvu/app.rb +31 -0
- data/examples/widget_command_system/app.rb +15 -13
- data/exe/rooibos +10 -0
- data/generate_tutorial_stubs.rb +126 -0
- data/lib/rooibos/cli/commands/new.rb +373 -0
- data/lib/rooibos/cli/commands/run.rb +98 -0
- data/lib/rooibos/cli.rb +78 -0
- data/lib/rooibos/command/all.rb +76 -23
- data/lib/rooibos/command/batch.rb +61 -34
- data/lib/rooibos/command/custom.rb +84 -1
- data/lib/rooibos/command/http.rb +121 -55
- data/lib/rooibos/command/lifecycle.rb +5 -5
- data/lib/rooibos/command/open.rb +93 -0
- data/lib/rooibos/command/outlet.rb +105 -3
- data/lib/rooibos/command/wait.rb +9 -6
- data/lib/rooibos/command.rb +114 -89
- data/lib/rooibos/message/batch.rb +39 -0
- data/lib/rooibos/message/canceled.rb +51 -0
- data/lib/rooibos/message/error.rb +48 -0
- data/lib/rooibos/message/open.rb +30 -0
- data/lib/rooibos/message.rb +84 -4
- data/lib/rooibos/router.rb +11 -14
- data/lib/rooibos/runtime.rb +40 -43
- data/lib/rooibos/shortcuts.rb +47 -0
- data/lib/rooibos/test_helper.rb +71 -6
- data/lib/rooibos/version.rb +1 -1
- data/lib/rooibos/welcome.rb +237 -0
- data/lib/rooibos.rb +4 -3
- data/mise.toml +1 -1
- data/rbs_collection.lock.yaml +2 -2
- data/sig/concurrent.rbs +4 -0
- data/sig/gem.rbs +20 -0
- data/sig/rooibos/cli.rbs +42 -0
- data/sig/rooibos/command.rbs +59 -7
- data/sig/rooibos/message.rbs +66 -2
- data/sig/rooibos/shortcuts.rbs +14 -0
- data/sig/rooibos/test_helper.rbs +6 -2
- data/sig/rooibos/welcome.rbs +75 -0
- data/tasks/install.rake +29 -0
- data/tasks/resources/build.yml.erb +2 -0
- metadata +274 -38
- data/doc/concepts/application_architecture.md +0 -197
- data/doc/concepts/application_testing.md +0 -49
- data/doc/concepts/async_work.md +0 -164
- data/doc/concepts/commands.md +0 -530
- data/doc/concepts/message_processing.md +0 -51
- data/doc/contributors/WIP/decomposition_strategies_analysis.md +0 -258
- data/doc/contributors/WIP/implementation_plan.md +0 -409
- data/doc/contributors/WIP/init_callable_proposal.md +0 -344
- data/doc/contributors/WIP/runtime_refactoring_status.md +0 -47
- data/doc/contributors/WIP/task.md +0 -36
- data/doc/contributors/WIP/v0.4.0_todo.md +0 -468
- data/doc/contributors/kit-no-outlet.md +0 -238
- data/doc/contributors/priorities.md +0 -38
- data/doc/images/.gitkeep +0 -0
- data/exe/.gitkeep +0 -0
- /data/doc/contributors/{WIP → design}/mvu_tea_implementations_research.md +0 -0
data/lib/rooibos/command/all.rb
CHANGED
|
@@ -7,39 +7,92 @@
|
|
|
7
7
|
|
|
8
8
|
module Rooibos
|
|
9
9
|
module Command
|
|
10
|
-
#
|
|
11
|
-
|
|
10
|
+
# Aggregates parallel commands and returns all results together.
|
|
11
|
+
#
|
|
12
|
+
# Dashboards load user profiles, settings, and stats before rendering.
|
|
13
|
+
# Fetching sequentially is slow. Fire-and-forget batches lose correlation
|
|
14
|
+
# between commands and their results.
|
|
15
|
+
#
|
|
16
|
+
# This command runs children in parallel and collects their results into
|
|
17
|
+
# a single <tt>Message::All</tt> response. Pattern-match on the envelope
|
|
18
|
+
# to correlate results. Each result appears in the same order as commands.
|
|
19
|
+
#
|
|
20
|
+
# Use it for coordinated fetches where you need all results before proceeding.
|
|
21
|
+
#
|
|
22
|
+
# Prefer the <tt>Command.all</tt> factory method for convenience.
|
|
23
|
+
#
|
|
24
|
+
# === Example
|
|
25
|
+
#
|
|
26
|
+
# # Using the factory method (recommended)
|
|
27
|
+
# Command.all(:dashboard,
|
|
28
|
+
# Command.http(:get, "/users", :_),
|
|
29
|
+
# Command.http(:get, "/stats", :_),
|
|
30
|
+
# )
|
|
31
|
+
#
|
|
32
|
+
# # Using the class directly
|
|
33
|
+
# All.new(:dashboard,
|
|
34
|
+
# Command.http(:get, "/users", :_),
|
|
35
|
+
# Command.http(:get, "/stats", :_),
|
|
36
|
+
# )
|
|
37
|
+
#
|
|
38
|
+
# # Pattern-match on the aggregated result
|
|
39
|
+
# def update(message, model)
|
|
40
|
+
# case message
|
|
41
|
+
# in { type: :all, envelope: :dashboard, results: [users, stats] }
|
|
42
|
+
# model.with(users:, stats:, loading: false)
|
|
43
|
+
# end
|
|
44
|
+
# end
|
|
45
|
+
class All < Data.define(:envelope, :commands, :nested)
|
|
12
46
|
include Custom
|
|
13
47
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
48
|
+
class << self
|
|
49
|
+
undef_method :new
|
|
50
|
+
|
|
51
|
+
# Creates an aggregating parallel command.
|
|
52
|
+
#
|
|
53
|
+
# [tag] Symbol to tag the result message.
|
|
54
|
+
# [args] Commands to run in parallel. Pass as multiple arguments
|
|
55
|
+
# or a single array.
|
|
56
|
+
#
|
|
57
|
+
# === Example
|
|
58
|
+
#
|
|
59
|
+
# All.new(:dashboard,
|
|
60
|
+
# Command.http(:get, "/users", :_),
|
|
61
|
+
# Command.http(:get, "/stats", :_),
|
|
62
|
+
# )
|
|
63
|
+
def new(tag, *args)
|
|
64
|
+
# DWIM: flatten single-array arg to support both call patterns
|
|
65
|
+
nested = args.size == 1 && args.first.is_a?(Array)
|
|
66
|
+
commands = [args].flatten(2)
|
|
23
67
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
68
|
+
if RatatuiRuby::Debug.enabled?
|
|
69
|
+
commands.each do |cmd|
|
|
70
|
+
unless Ractor.shareable?(cmd)
|
|
71
|
+
raise Rooibos::Error::Invariant,
|
|
72
|
+
"Command is not Ractor-shareable: #{cmd.inspect}\n" \
|
|
73
|
+
"Use Ractor.make_shareable or a Data.define command."
|
|
74
|
+
end
|
|
30
75
|
end
|
|
31
76
|
end
|
|
32
|
-
end
|
|
33
77
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
78
|
+
instance = allocate
|
|
79
|
+
instance.__send__(:initialize, envelope: tag, commands: commands.freeze, nested:)
|
|
80
|
+
instance
|
|
81
|
+
end
|
|
37
82
|
end
|
|
38
83
|
|
|
84
|
+
# Executes all child commands in parallel and aggregates results.
|
|
85
|
+
#
|
|
86
|
+
# Sends <tt>Message::All</tt> when all children complete. Results appear
|
|
87
|
+
# in the same order as commands. If canceled, sends <tt>Message::Canceled</tt>.
|
|
88
|
+
#
|
|
89
|
+
# [out] Outlet for sending messages.
|
|
90
|
+
# [token] Cancellation token from the runtime.
|
|
39
91
|
def call(out, token)
|
|
40
92
|
# Early return for empty commands - prevents hang from zip_futures([])
|
|
41
93
|
if commands.empty?
|
|
42
|
-
|
|
94
|
+
results = [] #: Array[Object]
|
|
95
|
+
response = Message::All.new(envelope:, results: results.freeze, nested:)
|
|
43
96
|
out.put(Ractor.make_shareable(response))
|
|
44
97
|
return
|
|
45
98
|
end
|
|
@@ -58,7 +111,7 @@ module Rooibos
|
|
|
58
111
|
all_done = Concurrent::Promises.zip_futures(*futures)
|
|
59
112
|
Concurrent::Promises.any_event(all_done, token.origin).wait
|
|
60
113
|
|
|
61
|
-
return out.put(
|
|
114
|
+
return out.put(Message::Canceled.new(command: self)) if token.canceled?
|
|
62
115
|
|
|
63
116
|
shareable_results = Ractor.make_shareable(all_done.value!)
|
|
64
117
|
response = Message::All.new(envelope:, results: shareable_results, nested:)
|
|
@@ -15,61 +15,88 @@ module Rooibos
|
|
|
15
15
|
#
|
|
16
16
|
# This command runs children in parallel. Each child sends its own messages
|
|
17
17
|
# independently. The batch completes when all children finish or when
|
|
18
|
-
# cancellation fires. On cancellation, emits <tt>
|
|
18
|
+
# cancellation fires. On cancellation, emits <tt>Message::Canceled</tt>.
|
|
19
19
|
#
|
|
20
20
|
# Use it for parallel fetches, concurrent refreshes, or any work that
|
|
21
21
|
# does not need coordinated results.
|
|
22
22
|
#
|
|
23
|
+
# Prefer the <tt>Command.batch</tt> factory method for convenience.
|
|
24
|
+
#
|
|
23
25
|
# === Example
|
|
24
26
|
#
|
|
27
|
+
# # Using the factory method (recommended)
|
|
28
|
+
# Command.batch(
|
|
29
|
+
# Command.http(:get, "/users", :users),
|
|
30
|
+
# Command.http(:get, "/stats", :stats),
|
|
31
|
+
# )
|
|
32
|
+
#
|
|
33
|
+
# # Using the class directly
|
|
34
|
+
# Batch.new(
|
|
35
|
+
# Command.http(:get, "/users", :users),
|
|
36
|
+
# Command.http(:get, "/stats", :stats),
|
|
37
|
+
# )
|
|
38
|
+
#
|
|
39
|
+
# # Handle each response independently
|
|
25
40
|
# def update(msg, model)
|
|
26
41
|
# case msg
|
|
27
|
-
# in :
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
# )
|
|
32
|
-
# [model.with(loading: true), batch]
|
|
33
|
-
# in :users | :stats
|
|
34
|
-
# [model.with(msg => data), nil]
|
|
42
|
+
# in { type: :http, envelope: :users, body: }
|
|
43
|
+
# model.with(users: JSON.parse(body))
|
|
44
|
+
# in { type: :http, envelope: :stats, body: }
|
|
45
|
+
# model.with(stats: JSON.parse(body))
|
|
35
46
|
# end
|
|
36
47
|
# end
|
|
37
48
|
class Batch < Data.define(:commands) do
|
|
38
49
|
include Custom
|
|
39
50
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
# DWIM: accept (cmd1, cmd2) or ([cmd1, cmd2])
|
|
43
|
-
commands = (args.size == 1 && args.first.is_a?(Array)) ? args.first : args
|
|
51
|
+
class << self
|
|
52
|
+
undef_method :new
|
|
44
53
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
54
|
+
# Creates a parallel batch command.
|
|
55
|
+
#
|
|
56
|
+
# [args] Commands to run in parallel. Pass as multiple arguments
|
|
57
|
+
# or a single array.
|
|
58
|
+
#
|
|
59
|
+
# === Example
|
|
60
|
+
#
|
|
61
|
+
# Batch.new(cmd1, cmd2, cmd3)
|
|
62
|
+
# Batch.new([cmd1, cmd2, cmd3])
|
|
63
|
+
def new(*args)
|
|
64
|
+
# DWIM: accept (cmd1, cmd2) or ([cmd1, cmd2])
|
|
65
|
+
commands = (args.size == 1 && args.first.is_a?(Array)) ? args.first : args
|
|
66
|
+
|
|
67
|
+
if RatatuiRuby::Debug.enabled?
|
|
68
|
+
commands.each do |cmd|
|
|
69
|
+
unless Ractor.shareable?(cmd)
|
|
70
|
+
raise Rooibos::Error::Invariant,
|
|
71
|
+
"Command is not Ractor-shareable: #{cmd.inspect}\n" \
|
|
72
|
+
"Use Ractor.make_shareable or a Data.define command."
|
|
73
|
+
end
|
|
51
74
|
end
|
|
52
75
|
end
|
|
53
|
-
end
|
|
54
76
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
77
|
+
instance = allocate
|
|
78
|
+
instance.__send__(:initialize, commands: commands.freeze)
|
|
79
|
+
instance
|
|
80
|
+
end
|
|
58
81
|
end
|
|
59
82
|
|
|
60
|
-
#
|
|
83
|
+
# Executes all child commands in parallel.
|
|
84
|
+
#
|
|
85
|
+
# Each child sends its results independently via the runtime.
|
|
86
|
+
# When all complete, sends <tt>Message::Batch</tt>. If canceled,
|
|
87
|
+
# sends <tt>Message::Canceled</tt> instead.
|
|
88
|
+
#
|
|
89
|
+
# [out] Outlet for sending messages.
|
|
90
|
+
# [token] Cancellation token from the runtime.
|
|
61
91
|
def call(out, token)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
all_done = Concurrent::Promises.zip_futures(*futures)
|
|
67
|
-
Concurrent::Promises.any_event(all_done, token.origin).wait
|
|
92
|
+
handles = commands.map { |cmd| out.standing(cmd, token) }
|
|
93
|
+
out.wait(*handles, token:)
|
|
68
94
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
95
|
+
if token.canceled?
|
|
96
|
+
out.put(Message::Canceled.new(command: self))
|
|
97
|
+
else
|
|
98
|
+
out.put(Message::Batch.new(command: self))
|
|
99
|
+
end
|
|
73
100
|
end
|
|
74
101
|
end
|
|
75
102
|
end
|
|
@@ -71,7 +71,7 @@ module Rooibos
|
|
|
71
71
|
# it calls <tt>token.cancel!</tt> and waits this long for your command to stop.
|
|
72
72
|
# If your command does not exit within this window, it is force-killed.
|
|
73
73
|
#
|
|
74
|
-
# *This is NOT a lifetime limit.* Your command runs indefinitely until
|
|
74
|
+
# *This is NOT a lifetime limit.* Your command runs indefinitely until canceled.
|
|
75
75
|
# A WebSocket open for 15 minutes is fine. This timeout only applies to the
|
|
76
76
|
# cleanup phase after cancellation is requested.
|
|
77
77
|
#
|
|
@@ -99,6 +99,89 @@ module Rooibos
|
|
|
99
99
|
def rooibos_cancellation_grace_period
|
|
100
100
|
0.1
|
|
101
101
|
end
|
|
102
|
+
|
|
103
|
+
# Infrastructure methods to exclude from introspection.
|
|
104
|
+
# Computed once from bare prototypes.
|
|
105
|
+
INFRASTRUCTURE_METHODS = begin
|
|
106
|
+
bare_data = Data.define(:_)
|
|
107
|
+
bare_struct = Struct.new(:_)
|
|
108
|
+
|
|
109
|
+
methods = Object.public_instance_methods +
|
|
110
|
+
bare_data.public_instance_methods +
|
|
111
|
+
bare_struct.public_instance_methods
|
|
112
|
+
|
|
113
|
+
# PP methods can error when called on objects without pretty_print override
|
|
114
|
+
methods += %i[
|
|
115
|
+
pretty_print
|
|
116
|
+
pretty_print_cycle
|
|
117
|
+
pretty_print_instance_variables
|
|
118
|
+
pretty_print_inspect
|
|
119
|
+
]
|
|
120
|
+
|
|
121
|
+
methods.uniq.freeze
|
|
122
|
+
end
|
|
123
|
+
private_constant :INFRASTRUCTURE_METHODS
|
|
124
|
+
|
|
125
|
+
# Deconstructs for hash-based pattern matching.
|
|
126
|
+
#
|
|
127
|
+
# Introspects public query methods (CQS: zero-arity, no side effects) and
|
|
128
|
+
# returns a hash suitable for +case+/+in+ matching. Excludes infrastructure
|
|
129
|
+
# methods from Object, Data, and Struct.
|
|
130
|
+
#
|
|
131
|
+
# Always includes +:type+ as a snake_case symbol of the class name.
|
|
132
|
+
# Anonymous classes default to +:custom+.
|
|
133
|
+
#
|
|
134
|
+
# Data.define members are automatically included since they generate
|
|
135
|
+
# public accessor methods.
|
|
136
|
+
#
|
|
137
|
+
# This is a naive but practical default. Override for:
|
|
138
|
+
# - Hot paths (introspects methods on every call)
|
|
139
|
+
# - Ghost methods via +method_missing+/+respond_to_missing?+
|
|
140
|
+
# - Methods with optional arguments (only zero-arity detected)
|
|
141
|
+
#
|
|
142
|
+
# @param keys [Array<Symbol>, nil] Limit output to specific keys for performance.
|
|
143
|
+
# Pass +nil+ to include all keys.
|
|
144
|
+
# @return [Hash{Symbol => Object}] Deconstructed hash with +:type+ discriminator.
|
|
145
|
+
#
|
|
146
|
+
# @example Pattern matching with Data.define command
|
|
147
|
+
# case msg
|
|
148
|
+
# in { type: :http_response, envelope: :users, status: 200 }
|
|
149
|
+
# # handle success
|
|
150
|
+
# end
|
|
151
|
+
def deconstruct_keys(keys)
|
|
152
|
+
class_name = self.class.name&.split("::")&.last
|
|
153
|
+
type_name = if class_name
|
|
154
|
+
class_name
|
|
155
|
+
.gsub(/([a-z])([A-Z])/, '\1_\2')
|
|
156
|
+
.downcase
|
|
157
|
+
.to_sym
|
|
158
|
+
else
|
|
159
|
+
:custom
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
result = { type: type_name }
|
|
163
|
+
|
|
164
|
+
# Include Data.define/Struct members
|
|
165
|
+
if self.class.respond_to?(:members)
|
|
166
|
+
klass = self.class #: Class & _HasMembers
|
|
167
|
+
klass.members.each do |member|
|
|
168
|
+
next if keys && !keys.include?(member)
|
|
169
|
+
result[member] = public_send(member)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Include public zero-arity query methods (excluding infrastructure)
|
|
174
|
+
# Use Kernel#public_method to avoid collision with Data.define :method member
|
|
175
|
+
get_method = Kernel.instance_method(:public_method)
|
|
176
|
+
(public_methods - INFRASTRUCTURE_METHODS).each do |method_name|
|
|
177
|
+
next if method_name.to_s.end_with?("=", "!")
|
|
178
|
+
next unless get_method.bind_call(self, method_name).arity.zero?
|
|
179
|
+
next if keys && !keys.include?(method_name)
|
|
180
|
+
result[method_name] = public_send(method_name)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
result
|
|
184
|
+
end
|
|
102
185
|
end
|
|
103
186
|
end
|
|
104
187
|
end
|
data/lib/rooibos/command/http.rb
CHANGED
|
@@ -14,80 +14,135 @@ module Rooibos
|
|
|
14
14
|
# New code should use Rooibos::Message::HttpResponse.
|
|
15
15
|
HttpResponse = Message::HttpResponse
|
|
16
16
|
|
|
17
|
-
#
|
|
18
|
-
|
|
17
|
+
# Performs HTTP requests and sends the response as a message.
|
|
18
|
+
#
|
|
19
|
+
# Applications fetch data from APIs. Users expect responsive interfaces
|
|
20
|
+
# while requests complete. Managing HTTP connections, timeouts, and
|
|
21
|
+
# threading manually is error-prone.
|
|
22
|
+
#
|
|
23
|
+
# This command executes HTTP requests off the main thread. The runtime
|
|
24
|
+
# dispatches it and routes the response back to your update function
|
|
25
|
+
# as a <tt>Message::HttpResponse</tt>.
|
|
26
|
+
#
|
|
27
|
+
# Use it to fetch API data, post forms, or interact with web services.
|
|
28
|
+
#
|
|
29
|
+
# Prefer the <tt>Command.http</tt> factory method for convenience.
|
|
30
|
+
# The constructor supports flexible DWIM (Do What I Mean) arity.
|
|
31
|
+
#
|
|
32
|
+
# === Example
|
|
33
|
+
#
|
|
34
|
+
# # Using the factory method (recommended)
|
|
35
|
+
# Command.http(:get, "/api/users", :users)
|
|
36
|
+
# Command.http(get: "/api/users", envelope: :users)
|
|
37
|
+
# Command.http(:post, "/api/users", '{"name":"Jo"}', :created)
|
|
38
|
+
#
|
|
39
|
+
# # Using the class directly
|
|
40
|
+
# Http.new(:get, "/api/users", :users)
|
|
41
|
+
#
|
|
42
|
+
# # Pattern-match on the response
|
|
43
|
+
# def update(message, model)
|
|
44
|
+
# case message
|
|
45
|
+
# in { type: :http, envelope: :users, status: 200, body: }
|
|
46
|
+
# model.with(users: JSON.parse(body))
|
|
47
|
+
# in { type: :http, envelope: :users, error: }
|
|
48
|
+
# model.with(error:)
|
|
49
|
+
# end
|
|
50
|
+
# end
|
|
51
|
+
class Http < Data.define(:method, :url, :envelope, :headers, :body, :timeout, :parser)
|
|
19
52
|
include Custom
|
|
20
53
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
)
|
|
24
|
-
# Auto-splat single hash argument
|
|
25
|
-
return new(**args.first) if args.size == 1 && args.first.is_a?(Hash)
|
|
54
|
+
class << self
|
|
55
|
+
undef_method :new
|
|
26
56
|
|
|
27
|
-
#
|
|
28
|
-
|
|
57
|
+
# Creates an HTTP request command.
|
|
58
|
+
#
|
|
59
|
+
# Supports flexible DWIM arity for convenience:
|
|
60
|
+
# <tt>Http.new("url")</tt>:: GET, URL as envelope
|
|
61
|
+
# <tt>Http.new("url", :tag)</tt>:: GET, custom envelope
|
|
62
|
+
# <tt>Http.new(:post, "url")</tt>:: POST, URL as envelope
|
|
63
|
+
# <tt>Http.new(:post, "url", :tag)</tt>:: POST, custom envelope
|
|
64
|
+
# <tt>Http.new(:post, "url", "body", :tag)</tt>:: POST with body
|
|
65
|
+
# <tt>Http.new(get: "url")</tt>:: keyword shortcut
|
|
66
|
+
#
|
|
67
|
+
# [method] HTTP method symbol: <tt>:get</tt>, <tt>:post</tt>,
|
|
68
|
+
# <tt>:put</tt>, <tt>:patch</tt>, or <tt>:delete</tt>.
|
|
69
|
+
# [url] Request URL (String).
|
|
70
|
+
# [envelope] Symbol to tag the response message.
|
|
71
|
+
# [headers] Optional hash of HTTP headers.
|
|
72
|
+
# [body] Optional request body (String).
|
|
73
|
+
# [timeout] Optional timeout in seconds (default 10).
|
|
74
|
+
# [parser] Optional callable to transform response body.
|
|
75
|
+
def new(*args, method: nil, url: nil, envelope: nil, headers: nil, body: nil, timeout: nil, parser: nil,
|
|
76
|
+
get: nil, post: nil, put: nil, patch: nil, delete: nil
|
|
77
|
+
)
|
|
78
|
+
# Auto-splat single hash argument
|
|
79
|
+
return new(**args.first) if args.size == 1 && args.first.is_a?(Hash)
|
|
29
80
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
method, url, envelope, body = parse_dwim_args(args, method, url, envelope, body, method_keywords)
|
|
81
|
+
# Auto-spread single array argument
|
|
82
|
+
return new(*args.first) if args.size == 1 && args.first.is_a?(Array)
|
|
33
83
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
"URL is not Ractor-shareable: #{url.inspect}\n" \
|
|
38
|
-
"Use a frozen string or Ractor.make_shareable."
|
|
39
|
-
end
|
|
84
|
+
# DWIM: parse positional args and keyword method shortcuts
|
|
85
|
+
method_keywords = { get:, post:, put:, patch:, delete: }.compact
|
|
86
|
+
method, url, envelope, body = parse_dwim_args(args, method, url, envelope, body, method_keywords)
|
|
40
87
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
"
|
|
45
|
-
|
|
88
|
+
# Ractor validation
|
|
89
|
+
if RatatuiRuby::Debug.enabled? && !Ractor.shareable?(url)
|
|
90
|
+
raise Rooibos::Error::Invariant,
|
|
91
|
+
"URL is not Ractor-shareable: #{url.inspect}\n" \
|
|
92
|
+
"Use a frozen string or Ractor.make_shareable."
|
|
93
|
+
end
|
|
46
94
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
95
|
+
if RatatuiRuby::Debug.enabled? && headers && !Ractor.shareable?(headers)
|
|
96
|
+
raise Rooibos::Error::Invariant,
|
|
97
|
+
"Headers are not Ractor-shareable: #{headers.inspect}\n" \
|
|
98
|
+
"Use Ractor.make_shareable or freeze the hash and its contents."
|
|
99
|
+
end
|
|
52
100
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
101
|
+
if RatatuiRuby::Debug.enabled? && body && !Ractor.shareable?(body)
|
|
102
|
+
raise Rooibos::Error::Invariant,
|
|
103
|
+
"Body is not Ractor-shareable: #{body.inspect}\n" \
|
|
104
|
+
"Use a frozen string or Ractor.make_shareable."
|
|
105
|
+
end
|
|
58
106
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
107
|
+
if RatatuiRuby::Debug.enabled? && envelope && !Ractor.shareable?(envelope)
|
|
108
|
+
raise Rooibos::Error::Invariant,
|
|
109
|
+
"Envelope is not Ractor-shareable: #{envelope.inspect}\n" \
|
|
110
|
+
"Use a frozen string, symbol, or Ractor.make_shareable."
|
|
111
|
+
end
|
|
64
112
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
113
|
+
if RatatuiRuby::Debug.enabled? && timeout && !Ractor.shareable?(timeout)
|
|
114
|
+
raise Rooibos::Error::Invariant,
|
|
115
|
+
"Timeout is not Ractor-shareable: #{timeout.inspect}\n" \
|
|
116
|
+
"Use a number or Ractor.make_shareable."
|
|
117
|
+
end
|
|
69
118
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
"
|
|
73
|
-
|
|
74
|
-
end
|
|
119
|
+
# Parser validation
|
|
120
|
+
if parser && !parser.respond_to?(:call)
|
|
121
|
+
raise ArgumentError, "parser: must respond to :call"
|
|
122
|
+
end
|
|
75
123
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
124
|
+
if RatatuiRuby::Debug.enabled? && parser && !Ractor.shareable?(parser)
|
|
125
|
+
raise Rooibos::Error::Invariant,
|
|
126
|
+
"Parser is not Ractor-shareable: #{parser.inspect}\n" \
|
|
127
|
+
"Use a frozen Method object or Ractor.make_shareable."
|
|
128
|
+
end
|
|
80
129
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
130
|
+
# Method validation
|
|
131
|
+
unless %i[get post put patch delete].include?(method)
|
|
132
|
+
raise ArgumentError, "Unsupported HTTP method: #{method.inspect}"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
instance = allocate
|
|
136
|
+
instance.__send__(:initialize, method:, url:, envelope:, headers:, body:, timeout: timeout || 10, parser:)
|
|
137
|
+
instance
|
|
138
|
+
end
|
|
84
139
|
end
|
|
85
140
|
|
|
86
141
|
# Net::HTTP is blocking; no cooperative cancellation possible.
|
|
87
142
|
# Grace period = 0 means runtime can force-kill immediately.
|
|
88
143
|
def rooibos_cancellation_grace_period = 0
|
|
89
144
|
|
|
90
|
-
def self.parse_dwim_args(args, method_kw, url_kw, envelope_kw, body_kw, method_keywords)
|
|
145
|
+
def self.parse_dwim_args(args, method_kw, url_kw, envelope_kw, body_kw, method_keywords) # :nodoc:
|
|
91
146
|
# Handle keyword method shortcuts: get: 'url'
|
|
92
147
|
if method_keywords.any?
|
|
93
148
|
method_key, url = method_keywords.first
|
|
@@ -130,6 +185,17 @@ module Rooibos
|
|
|
130
185
|
end
|
|
131
186
|
private_class_method :parse_dwim_args
|
|
132
187
|
|
|
188
|
+
# Executes the HTTP request and sends the response.
|
|
189
|
+
#
|
|
190
|
+
# Sends <tt>Message::HttpResponse</tt> with status, body, and headers.
|
|
191
|
+
# On network errors, sends the same message type with <tt>error</tt>
|
|
192
|
+
# populated instead.
|
|
193
|
+
#
|
|
194
|
+
# Note: Ruby's <tt>Net::HTTP</tt> blocks until completion. Cancellation
|
|
195
|
+
# cannot interrupt a request in progress. The grace period is 0.
|
|
196
|
+
#
|
|
197
|
+
# [out] Outlet for sending messages.
|
|
198
|
+
# [token] Cancellation token from the runtime.
|
|
133
199
|
def call(out, token)
|
|
134
200
|
return if token.canceled?
|
|
135
201
|
|
|
@@ -19,8 +19,7 @@ module Rooibos
|
|
|
19
19
|
#
|
|
20
20
|
# The framework creates one instance at startup. All outlets share it.
|
|
21
21
|
class Lifecycle
|
|
22
|
-
# :nodoc: Internal representation of a tracked async command.
|
|
23
|
-
Entry = Data.define(:future, :origin)
|
|
22
|
+
Entry = Data.define(:future, :origin) # :nodoc: Internal representation of a tracked async command.
|
|
24
23
|
|
|
25
24
|
# Creates a lifecycle manager.
|
|
26
25
|
#
|
|
@@ -40,7 +39,7 @@ module Rooibos
|
|
|
40
39
|
# [token] Parent's cancellation token.
|
|
41
40
|
# [timeout] Max wait seconds for the result.
|
|
42
41
|
#
|
|
43
|
-
# Returns the child's message, or <tt>nil</tt> if
|
|
42
|
+
# Returns the child's message, or <tt>nil</tt> if canceled or timed out.
|
|
44
43
|
# Raises if the child raised.
|
|
45
44
|
def run_sync(command, token, timeout:)
|
|
46
45
|
return nil if token.canceled?
|
|
@@ -76,7 +75,7 @@ module Rooibos
|
|
|
76
75
|
#
|
|
77
76
|
# Spawns a future that executes the command. Tracks the command in the
|
|
78
77
|
# active map for cancellation support. Errors are pushed to the channel
|
|
79
|
-
# as <tt>
|
|
78
|
+
# as <tt>Message::Error</tt> messages.
|
|
80
79
|
#
|
|
81
80
|
# [command] Callable with <tt>call(out, token)</tt>.
|
|
82
81
|
# [channel] Channel to push results and errors to.
|
|
@@ -89,7 +88,7 @@ module Rooibos
|
|
|
89
88
|
future = Concurrent::Promises.future do
|
|
90
89
|
command.call(outlet, cancellation)
|
|
91
90
|
rescue => e
|
|
92
|
-
channel.push
|
|
91
|
+
channel.push Message::Error.new(command:, exception: e)
|
|
93
92
|
end
|
|
94
93
|
|
|
95
94
|
entry = Entry.new(future:, origin:)
|
|
@@ -114,6 +113,7 @@ module Rooibos
|
|
|
114
113
|
entry.future.wait(grace.finite? ? grace : nil)
|
|
115
114
|
|
|
116
115
|
@active.delete(command)
|
|
116
|
+
entry # Return so caller can remove from pending_futures
|
|
117
117
|
end
|
|
118
118
|
|
|
119
119
|
# Cancels all active commands and waits for them to complete.
|