openui-rails 0.1.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 +7 -0
- data/AUDIT.md +102 -0
- data/CHANGELOG.md +71 -0
- data/CODE_OF_CONDUCT.md +37 -0
- data/CONFIGURATION.md +79 -0
- data/CONTRIBUTING.md +66 -0
- data/LICENSE +22 -0
- data/README.md +471 -0
- data/RELEASING.md +90 -0
- data/SECURITY.md +48 -0
- data/THIRD_PARTY_NOTICES.md +45 -0
- data/UPGRADING.md +26 -0
- data/app/controllers/openui/rails/actions_controller.rb +77 -0
- data/app/controllers/openui/rails/application_controller.rb +10 -0
- data/app/javascript/openui/rails/openui_controller.js +347 -0
- data/app/javascript/openui/rails/openui_envelope_controller.js +38 -0
- data/config/routes.rb +5 -0
- data/lib/generators/openui/install/install_generator.rb +62 -0
- data/lib/generators/openui/install/templates/config/initializers/openui.rb.tt +30 -0
- data/lib/generators/openui/install/templates/config/openui/README.md +24 -0
- data/lib/openui/rails/actions/dispatcher.rb +194 -0
- data/lib/openui/rails/actions/idempotency/memory_store.rb +64 -0
- data/lib/openui/rails/actions/invocation.rb +16 -0
- data/lib/openui/rails/actions/registry.rb +113 -0
- data/lib/openui/rails/actions/schema.rb +115 -0
- data/lib/openui/rails/actions/submission.rb +102 -0
- data/lib/openui/rails/actions.rb +18 -0
- data/lib/openui/rails/adapters/ruby_llm.rb +113 -0
- data/lib/openui/rails/configuration.rb +282 -0
- data/lib/openui/rails/engine.rb +46 -0
- data/lib/openui/rails/errors.rb +41 -0
- data/lib/openui/rails/json_value.rb +33 -0
- data/lib/openui/rails/library_manifest.rb +234 -0
- data/lib/openui/rails/message.rb +139 -0
- data/lib/openui/rails/prompt_builder.rb +115 -0
- data/lib/openui/rails/runtime_asset.rb +65 -0
- data/lib/openui/rails/runtime_package.rb +74 -0
- data/lib/openui/rails/stream/accumulator.rb +86 -0
- data/lib/openui/rails/stream/bridge.rb +193 -0
- data/lib/openui/rails/stream/broadcaster.rb +106 -0
- data/lib/openui/rails/stream/envelope.rb +159 -0
- data/lib/openui/rails/stream/transports/action_cable.rb +50 -0
- data/lib/openui/rails/stream/transports/memory.rb +33 -0
- data/lib/openui/rails/stream/transports/turbo_streams.rb +61 -0
- data/lib/openui/rails/stream/transports.rb +41 -0
- data/lib/openui/rails/surface.rb +150 -0
- data/lib/openui/rails/version.rb +5 -0
- data/lib/openui/rails/view_helper.rb +56 -0
- data/lib/openui/rails.rb +33 -0
- data/lib/tasks/openui.rake +16 -0
- metadata +125 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openui/rails/stream/bridge"
|
|
4
|
+
|
|
5
|
+
module OpenUI
|
|
6
|
+
module Rails
|
|
7
|
+
module Adapters
|
|
8
|
+
# Optional RubyLLM adapter. It owns RubyLLM-specific event normalization
|
|
9
|
+
# and delegates sequencing, coalescing and size limits to the Stream
|
|
10
|
+
# bridge, so a host keeps using RubyLLM's own Rails streaming pattern
|
|
11
|
+
# (`chat.complete { |chunk| ... }` in a job) instead of replacing it.
|
|
12
|
+
#
|
|
13
|
+
# `ruby_llm` is never required by the Gem and no `RubyLLM` constant is
|
|
14
|
+
# referenced: failures are classified by class *name*, so this unit loads
|
|
15
|
+
# and is tested without the dependency installed. The supported RubyLLM
|
|
16
|
+
# version is pinned in the README compatibility matrix.
|
|
17
|
+
module RubyLLM
|
|
18
|
+
STRATEGIES = %i[complete ask].freeze
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
# Stream a RubyLLM completion, yielding ordered {Stream::Envelope}s.
|
|
22
|
+
#
|
|
23
|
+
# @param chat [#complete, #ask] a RubyLLM chat (or a duck type)
|
|
24
|
+
# @param question [String] the user prompt
|
|
25
|
+
# @param surface_id [String, #to_s] the target surface
|
|
26
|
+
# @param strategy [Symbol] :complete (default) or :ask
|
|
27
|
+
# @yieldparam envelope [Stream::Envelope]
|
|
28
|
+
# @return [Stream::Bridge::Writer] for inspection in tests
|
|
29
|
+
def stream(chat:, question:, surface_id:, message_id: nil, library_version: nil,
|
|
30
|
+
instruction: nil, strategy: :complete,
|
|
31
|
+
configuration: OpenUI::Rails.configuration, &block)
|
|
32
|
+
raise ConfigurationError, "surface_id is required" if surface_id.nil?
|
|
33
|
+
raise ConfigurationError, "a block is required" unless block
|
|
34
|
+
unless STRATEGIES.include?(strategy)
|
|
35
|
+
raise ConfigurationError, "unsupported strategy #{strategy.inspect}; expected one of #{STRATEGIES.inspect}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
instruction ||= PromptBuilder.load(configuration).system_instruction
|
|
39
|
+
library_version ||= configuration.library_fingerprint
|
|
40
|
+
chat = apply_instruction(chat, instruction)
|
|
41
|
+
|
|
42
|
+
writer = Stream::Bridge.new(
|
|
43
|
+
message_id: message_id || surface_id.to_s,
|
|
44
|
+
library_version: library_version,
|
|
45
|
+
configuration: configuration
|
|
46
|
+
).writer
|
|
47
|
+
|
|
48
|
+
begin
|
|
49
|
+
consumer_failed = false
|
|
50
|
+
deliver = lambda do |envelope|
|
|
51
|
+
begin
|
|
52
|
+
block.call(envelope)
|
|
53
|
+
rescue StandardError
|
|
54
|
+
consumer_failed = true
|
|
55
|
+
raise
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
consume(chat, question, strategy) do |chunk|
|
|
59
|
+
text = normalize_chunk(chunk)
|
|
60
|
+
next if text.nil?
|
|
61
|
+
|
|
62
|
+
writer.push(text).each(&deliver)
|
|
63
|
+
end
|
|
64
|
+
writer.finish.each(&deliver)
|
|
65
|
+
rescue StandardError => e
|
|
66
|
+
raise if consumer_failed
|
|
67
|
+
|
|
68
|
+
envelope = writer.error_for(e, code: error_code_for(e))
|
|
69
|
+
block.call(envelope) if envelope
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
writer
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def apply_instruction(chat, instruction)
|
|
78
|
+
return chat unless chat.respond_to?(:with_instructions)
|
|
79
|
+
|
|
80
|
+
chat.with_instructions(instruction)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def consume(chat, question, strategy, &block)
|
|
84
|
+
case strategy
|
|
85
|
+
when :complete
|
|
86
|
+
# RubyLLM::Chat#complete only accepts a block. A user message is
|
|
87
|
+
# added explicitly; #ask is the convenient alternative strategy.
|
|
88
|
+
chat.add_message(role: :user, content: question) unless question.nil?
|
|
89
|
+
chat.complete(&block)
|
|
90
|
+
when :ask then chat.ask(question, &block)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def normalize_chunk(chunk)
|
|
95
|
+
return chunk if chunk.is_a?(String)
|
|
96
|
+
return nil unless chunk.respond_to?(:content)
|
|
97
|
+
|
|
98
|
+
content = chunk.content
|
|
99
|
+
content.nil? ? nil : content.to_s
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def error_code_for(error)
|
|
103
|
+
name = error.class.name
|
|
104
|
+
return "stream_cancelled" if name == "RubyLLM::CancelledError"
|
|
105
|
+
return "provider_rejected" if name&.start_with?("RubyLLM::")
|
|
106
|
+
|
|
107
|
+
"stream_failed"
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenUI
|
|
4
|
+
module Rails
|
|
5
|
+
# Global, process-wide configuration for the gem.
|
|
6
|
+
#
|
|
7
|
+
# OpenUI::Rails.configure do |config|
|
|
8
|
+
# config.transport = :turbo
|
|
9
|
+
# config.library_spec_path = Rails.root.join("config/openui/library.spec.json")
|
|
10
|
+
# config.runtime_assets = :self_hosted
|
|
11
|
+
# end
|
|
12
|
+
class Configuration
|
|
13
|
+
# Transport names accepted by the host preference setting.
|
|
14
|
+
TRANSPORTS = %i[turbo action_cable none].freeze
|
|
15
|
+
|
|
16
|
+
# Runtime asset strategies supported by the gem.
|
|
17
|
+
ASSET_STRATEGIES = %i[self_hosted external].freeze
|
|
18
|
+
|
|
19
|
+
# Maximum response size passed through the stream bridge, in bytes.
|
|
20
|
+
DEFAULT_STREAM_BYTE_LIMIT = 1_048_576 # 1 MiB of OpenUI Lang per message
|
|
21
|
+
|
|
22
|
+
# Buffer size before the bridge emits a delta, in bytes.
|
|
23
|
+
DEFAULT_PENDING_BYTE_LIMIT = 32_768 # flush threshold while streaming
|
|
24
|
+
|
|
25
|
+
# Lifetime of an action idempotency claim, in seconds.
|
|
26
|
+
DEFAULT_IDEMPOTENCY_TTL = 15 * 60 # seconds
|
|
27
|
+
|
|
28
|
+
# Default engine endpoint for action submissions.
|
|
29
|
+
DEFAULT_ACTIONS_ENDPOINT = "/openui-rails/actions"
|
|
30
|
+
|
|
31
|
+
attr_reader :transport, :runtime_assets
|
|
32
|
+
attr_accessor :library_spec_path, :prompt_path, :library_name,
|
|
33
|
+
:actions_endpoint, :surface_id_param,
|
|
34
|
+
:stream_byte_limit, :pending_byte_limit, :idempotency_ttl,
|
|
35
|
+
:authorize_action, :on_error, :action_registry, :surface_resolver, :actor_resolver,
|
|
36
|
+
:runtime_version, :runtime_url, :runtime_integrity, :local_runtime_path,
|
|
37
|
+
:idempotency_scope, :runtime_stylesheet_url, :runtime_stylesheet_integrity
|
|
38
|
+
|
|
39
|
+
# Create the default integration settings.
|
|
40
|
+
#
|
|
41
|
+
# @return [Configuration]
|
|
42
|
+
def initialize
|
|
43
|
+
@transport = :turbo
|
|
44
|
+
@runtime_assets = :self_hosted
|
|
45
|
+
@library_spec_path = nil
|
|
46
|
+
@prompt_path = nil
|
|
47
|
+
@library_name = :chat
|
|
48
|
+
@actions_endpoint = DEFAULT_ACTIONS_ENDPOINT
|
|
49
|
+
@surface_id_param = :surface_id
|
|
50
|
+
@stream_byte_limit = DEFAULT_STREAM_BYTE_LIMIT
|
|
51
|
+
@pending_byte_limit = DEFAULT_PENDING_BYTE_LIMIT
|
|
52
|
+
@idempotency_ttl = DEFAULT_IDEMPOTENCY_TTL
|
|
53
|
+
@idempotency_store = nil
|
|
54
|
+
@authorize_action = nil
|
|
55
|
+
@on_error = nil
|
|
56
|
+
@action_registry = nil
|
|
57
|
+
@surface_resolver = nil
|
|
58
|
+
@actor_resolver = nil
|
|
59
|
+
@runtime_version = nil
|
|
60
|
+
@runtime_url = nil
|
|
61
|
+
@runtime_integrity = nil
|
|
62
|
+
@local_runtime_path = "/openui"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Set the preferred host stream transport.
|
|
66
|
+
#
|
|
67
|
+
# @param value [Symbol] `:turbo`, `:action_cable`, or `:none`
|
|
68
|
+
#
|
|
69
|
+
# @return [Symbol]
|
|
70
|
+
def transport=(value)
|
|
71
|
+
value = value.to_sym if value.respond_to?(:to_sym)
|
|
72
|
+
unless TRANSPORTS.include?(value)
|
|
73
|
+
raise ConfigurationError, "unsupported transport #{value.inspect}; expected one of #{TRANSPORTS.inspect}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
@transport = value
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Choose self-hosted or externally served runtime assets.
|
|
80
|
+
#
|
|
81
|
+
# @param value [Symbol] `:self_hosted` or `:external`
|
|
82
|
+
#
|
|
83
|
+
# @return [Symbol]
|
|
84
|
+
def runtime_assets=(value)
|
|
85
|
+
value = value.to_sym if value.respond_to?(:to_sym)
|
|
86
|
+
unless ASSET_STRATEGIES.include?(value)
|
|
87
|
+
raise ConfigurationError,
|
|
88
|
+
"unsupported runtime_assets #{value.inspect}; expected one of #{ASSET_STRATEGIES.inspect}"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
@runtime_assets = value
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Set the store used to claim and record action idempotency keys.
|
|
95
|
+
# A shared store must implement the atomic contract described in the
|
|
96
|
+
# configuration guide.
|
|
97
|
+
#
|
|
98
|
+
# @param store [#claim, #record, nil]
|
|
99
|
+
#
|
|
100
|
+
# @return [#claim, #record]
|
|
101
|
+
def idempotency_store=(store)
|
|
102
|
+
unless store.nil? || (store.respond_to?(:claim) && store.respond_to?(:record))
|
|
103
|
+
raise ConfigurationError, "idempotency_store must respond to #claim and #record"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
@idempotency_store = store
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Set the manifest path and clear the cached manifest.
|
|
110
|
+
#
|
|
111
|
+
# @param path [String, Pathname, nil]
|
|
112
|
+
#
|
|
113
|
+
# @return [String, Pathname, nil]
|
|
114
|
+
def library_spec_path=(path)
|
|
115
|
+
@library_spec_path = path
|
|
116
|
+
reset!
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Set a callable that returns a stable actor and tenant idempotency scope.
|
|
120
|
+
#
|
|
121
|
+
# @param callable [#call, nil] receives an Actions::Invocation
|
|
122
|
+
#
|
|
123
|
+
# @return [#call, nil]
|
|
124
|
+
def idempotency_scope=(callable)
|
|
125
|
+
unless callable.nil? || callable.respond_to?(:call)
|
|
126
|
+
raise ConfigurationError, "idempotency_scope must respond to #call"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
@idempotency_scope = callable
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Set the global authorizer used when an action has no local authorizer.
|
|
133
|
+
#
|
|
134
|
+
# @param callable [#call, nil] receives an Actions::Invocation
|
|
135
|
+
#
|
|
136
|
+
# @return [#call, nil]
|
|
137
|
+
def authorize_action=(callable)
|
|
138
|
+
unless callable.nil? || callable.respond_to?(:call)
|
|
139
|
+
raise ConfigurationError, "authorize_action must respond to #call"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
@authorize_action = callable
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Set the resolver that loads a surface inside the actor's permitted scope.
|
|
146
|
+
#
|
|
147
|
+
# @param callable [#call, nil] receives surface_id, actor, and context
|
|
148
|
+
#
|
|
149
|
+
# @return [#call, nil]
|
|
150
|
+
def surface_resolver=(callable)
|
|
151
|
+
unless callable.nil? || callable.respond_to?(:call)
|
|
152
|
+
raise ConfigurationError, "surface_resolver must respond to #call"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
@surface_resolver = callable
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Set the callable that resolves the current actor from the engine controller.
|
|
159
|
+
#
|
|
160
|
+
# @param callable [#call, nil] receives the engine controller
|
|
161
|
+
#
|
|
162
|
+
# @return [#call, nil]
|
|
163
|
+
def actor_resolver=(callable)
|
|
164
|
+
unless callable.nil? || callable.respond_to?(:call)
|
|
165
|
+
raise ConfigurationError, "actor_resolver must respond to #call"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
@actor_resolver = callable
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Return the configured store, creating a process-local memory store by default.
|
|
172
|
+
#
|
|
173
|
+
# @return [#claim, #record]
|
|
174
|
+
def idempotency_store
|
|
175
|
+
@idempotency_store ||= Actions::Idempotency::MemoryStore.new
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# The registry of callable actions. Lazily created so require order does
|
|
179
|
+
# not matter and host apps can replace it if they wish.
|
|
180
|
+
#
|
|
181
|
+
# @return [Actions::Registry]
|
|
182
|
+
def action_registry
|
|
183
|
+
@action_registry ||= Actions::Registry.new
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Fingerprint of the pinned library manifest, or nil when unconfigured.
|
|
187
|
+
#
|
|
188
|
+
# @return [String, nil]
|
|
189
|
+
def library_fingerprint
|
|
190
|
+
library_manifest&.fingerprint
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Memoized manifest for the configured `library_spec_path`.
|
|
194
|
+
#
|
|
195
|
+
# @return [LibraryManifest, nil]
|
|
196
|
+
def library_manifest
|
|
197
|
+
return @library_manifest if defined?(@library_manifest) && @library_manifest
|
|
198
|
+
|
|
199
|
+
path = library_spec_path
|
|
200
|
+
return nil if path.nil?
|
|
201
|
+
|
|
202
|
+
@library_manifest = LibraryManifest.load(path)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Drop memoized derived state (used after config changes and in tests).
|
|
206
|
+
# The action registry and idempotency store are runtime state and are
|
|
207
|
+
# preserved so re-configuring does not silently unregister actions.
|
|
208
|
+
#
|
|
209
|
+
# @return [self]
|
|
210
|
+
def reset!
|
|
211
|
+
remove_instance_variable(:@library_manifest) if defined?(@library_manifest)
|
|
212
|
+
self
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Explicit host readiness check. Not run during boot: the generator and
|
|
216
|
+
# runtime-fetch task must work before library files have been created.
|
|
217
|
+
#
|
|
218
|
+
# @return [true]
|
|
219
|
+
#
|
|
220
|
+
# @raise [ConfigurationError, ManifestError] when required settings or files are invalid
|
|
221
|
+
def validate!
|
|
222
|
+
raise ConfigurationError, "library_spec_path is not configured" if library_spec_path.nil?
|
|
223
|
+
|
|
224
|
+
if runtime_assets == :external && (runtime_url.nil? || runtime_url.empty?)
|
|
225
|
+
raise ConfigurationError, "runtime_assets :external requires an explicit, version-pinned runtime_url"
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
unless pending_byte_limit.is_a?(Integer) && stream_byte_limit.is_a?(Integer) &&
|
|
229
|
+
pending_byte_limit > 0 && stream_byte_limit > 0
|
|
230
|
+
raise ConfigurationError, "stream byte limits must be positive"
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
if pending_byte_limit > stream_byte_limit
|
|
234
|
+
raise ConfigurationError, "pending_byte_limit must not exceed stream_byte_limit"
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
unless idempotency_ttl.is_a?(Numeric) && idempotency_ttl.finite? && idempotency_ttl > 0
|
|
238
|
+
raise ConfigurationError, "idempotency_ttl must be positive"
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
reset!
|
|
242
|
+
library_manifest
|
|
243
|
+
PromptBuilder.load(self) if prompt_path
|
|
244
|
+
true
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
class << self
|
|
249
|
+
# Return the process-wide OpenUI Rails configuration.
|
|
250
|
+
#
|
|
251
|
+
# @return [Configuration]
|
|
252
|
+
def configuration
|
|
253
|
+
@configuration ||= Configuration.new
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Configure the process-wide integration and clear derived manifest state.
|
|
257
|
+
#
|
|
258
|
+
# @yieldparam config [Configuration]
|
|
259
|
+
#
|
|
260
|
+
# @return [Configuration]
|
|
261
|
+
def configure
|
|
262
|
+
yield configuration
|
|
263
|
+
configuration.reset!
|
|
264
|
+
configuration
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Discard the current configuration object. Primarily useful in tests.
|
|
268
|
+
#
|
|
269
|
+
# @return [nil]
|
|
270
|
+
def reset_configuration!
|
|
271
|
+
@configuration = nil
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Shorthand for the action registry.
|
|
275
|
+
#
|
|
276
|
+
# @return [Actions::Registry]
|
|
277
|
+
def actions
|
|
278
|
+
configuration.action_registry
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails"
|
|
4
|
+
require "active_support/inflector"
|
|
5
|
+
require "rails/engine"
|
|
6
|
+
|
|
7
|
+
# Register the acronym before the engine class is defined: `isolate_namespace`
|
|
8
|
+
# computes the routing module scope with `underscore` at class-definition time,
|
|
9
|
+
# so it must already know that `OpenUI` is `openui` (not `open_ui`).
|
|
10
|
+
ActiveSupport::Inflector.inflections(:en) do |inflect|
|
|
11
|
+
inflect.acronym "OpenUI"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
module OpenUI
|
|
15
|
+
module Rails
|
|
16
|
+
class Engine < ::Rails::Engine
|
|
17
|
+
isolate_namespace OpenUI::Rails
|
|
18
|
+
|
|
19
|
+
# Inside `module OpenUI`, a bare `Rails` resolves to `OpenUI::Rails`, so
|
|
20
|
+
# every reference to the framework must be written `::Rails`.
|
|
21
|
+
#
|
|
22
|
+
# Zeitwerk would infer `Openui` from the `openui` path segment; the
|
|
23
|
+
# acronym must be declared explicitly before the main autoloader is set
|
|
24
|
+
# up, otherwise constants such as `OpenUI::Rails::ActionsController`
|
|
25
|
+
# cannot be resolved.
|
|
26
|
+
initializer "openui_rails.inflections", before: :setup_main_autoloader do
|
|
27
|
+
::Rails.autoloaders.main.inflector.inflect("openui" => "OpenUI")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Expose the surface helper to every host view.
|
|
31
|
+
initializer "openui_rails.view_helper" do
|
|
32
|
+
ActiveSupport.on_load(:action_view) do
|
|
33
|
+
include OpenUI::Rails::ViewHelper
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Make the Stimulus controller reachable by Propshaft/Sprockets/importmap.
|
|
38
|
+
initializer "openui_rails.assets" do |app|
|
|
39
|
+
next unless app.config.respond_to?(:assets)
|
|
40
|
+
|
|
41
|
+
assets = app.config.assets
|
|
42
|
+
assets.paths << root.join("app/javascript") if assets.respond_to?(:paths)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenUI
|
|
4
|
+
module Rails
|
|
5
|
+
# Base class for every error raised by openui-rails. Rescuing
|
|
6
|
+
# `OpenUI::Rails::Error` rescues the whole taxonomy.
|
|
7
|
+
class Error < StandardError; end
|
|
8
|
+
|
|
9
|
+
# Raised when configuration is invalid or an unsupported option is set.
|
|
10
|
+
class ConfigurationError < Error; end
|
|
11
|
+
|
|
12
|
+
# Raised when a library manifest cannot be loaded, parsed or validated.
|
|
13
|
+
class ManifestError < Error; end
|
|
14
|
+
|
|
15
|
+
# Raised when a browser-rendered response targets a library version that
|
|
16
|
+
# does not match the pinned library fingerprint.
|
|
17
|
+
class LibraryVersionError < Error; end
|
|
18
|
+
|
|
19
|
+
# Raised when an action name is not present in the registry.
|
|
20
|
+
class UnknownActionError < Error; end
|
|
21
|
+
|
|
22
|
+
# Raised when the host authorizer denies an action.
|
|
23
|
+
class UnauthorizedActionError < Error; end
|
|
24
|
+
|
|
25
|
+
# Raised when an action payload fails schema/presence validation.
|
|
26
|
+
class InvalidActionError < Error; end
|
|
27
|
+
|
|
28
|
+
# Raised when a submitted action targets a surface revision that is no
|
|
29
|
+
# longer current.
|
|
30
|
+
class StaleSurfaceError < Error; end
|
|
31
|
+
|
|
32
|
+
# Raised when an action submission replays a known idempotency key.
|
|
33
|
+
class ReplayError < Error; end
|
|
34
|
+
|
|
35
|
+
# Raised for malformed or unsafe stream payloads.
|
|
36
|
+
class StreamError < Error; end
|
|
37
|
+
|
|
38
|
+
# Raised when a caller requests an unsupported protocol/library version.
|
|
39
|
+
class UnsupportedVersionError < Error; end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenUI
|
|
4
|
+
module Rails
|
|
5
|
+
# Internal canonical JSON copy. Never freezes caller-owned values or lets
|
|
6
|
+
# mutable input change a fingerprint after validation.
|
|
7
|
+
module JsonValue # :nodoc:
|
|
8
|
+
def self.copy(value, depth = 0)
|
|
9
|
+
raise ArgumentError, "JSON nesting exceeds 100 levels" if depth > 100
|
|
10
|
+
|
|
11
|
+
case value
|
|
12
|
+
when Hash
|
|
13
|
+
pairs = value.map do |key, entry|
|
|
14
|
+
raise ArgumentError, "JSON object keys must be strings or symbols" unless key.is_a?(String) || key.is_a?(Symbol)
|
|
15
|
+
|
|
16
|
+
[ key.to_s, entry ]
|
|
17
|
+
end
|
|
18
|
+
raise ArgumentError, "duplicate JSON object keys" unless pairs.map(&:first).uniq.size == pairs.size
|
|
19
|
+
|
|
20
|
+
pairs.sort_by(&:first).to_h.transform_values { |entry| copy(entry, depth + 1) }.freeze
|
|
21
|
+
when Array then value.map { |entry| copy(entry, depth + 1) }.freeze
|
|
22
|
+
when String then value.dup.freeze
|
|
23
|
+
when Integer, TrueClass, FalseClass, NilClass then value
|
|
24
|
+
when Float
|
|
25
|
+
raise ArgumentError, "JSON numbers must be finite" unless value.finite?
|
|
26
|
+
|
|
27
|
+
value
|
|
28
|
+
else raise ArgumentError, "unsupported JSON value #{value.class}"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|