funicular 0.1.0 → 0.2.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/CHANGELOG.md +24 -0
- data/README.md +12 -4
- data/Rakefile +29 -0
- data/docs/architecture.md +113 -404
- data/lib/funicular/assets/funicular.css +23 -0
- data/lib/funicular/compiler.rb +25 -18
- data/lib/funicular/helpers/picoruby_helper.rb +65 -3
- data/lib/funicular/middleware.rb +34 -9
- data/lib/funicular/plugin.rb +147 -0
- data/lib/funicular/schema.rb +167 -0
- data/lib/funicular/ssr/runtime.rb +101 -0
- data/lib/funicular/ssr.rb +51 -0
- data/lib/funicular/testing/node_runner.mjs +293 -0
- data/lib/funicular/testing/node_runner.rb +190 -0
- data/lib/funicular/testing.rb +22 -0
- data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
- data/lib/funicular/vendor/picoruby/debug/picoruby.js +115 -87
- data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby/dist/picoruby.js +1 -1
- data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -0
- data/lib/funicular/vendor/picoruby-test-node/picoruby.js +6908 -0
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +1 -0
- data/lib/funicular/version.rb +1 -1
- data/lib/funicular.rb +3 -0
- data/lib/generators/funicular/chat/chat_generator.rb +104 -0
- data/lib/generators/funicular/chat/templates/application_cable_channel.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/application_cable_connection.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/create_funicular_chat_messages.rb.tt +10 -0
- data/lib/generators/funicular/chat/templates/funicular_chat.css.tt +141 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_channel.rb.tt +5 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +135 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_component_picotest.rb.tt +64 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_controller.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_message.rb.tt +13 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_messages_controller.rb.tt +23 -0
- data/lib/generators/funicular/chat/templates/initializer.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/show.html.erb.tt +6 -0
- data/lib/tasks/funicular.rake +87 -4
- data/minitest/fixtures/funicular_app/components/greeting_component.rb +16 -0
- data/minitest/fixtures/funicular_app/initializer.rb +5 -0
- data/minitest/form_for_test.rb +106 -0
- data/minitest/hydration_test.rb +87 -0
- data/minitest/plugin_test.rb +51 -0
- data/minitest/schema_test.rb +106 -0
- data/minitest/ssr_test.rb +94 -0
- data/minitest/validations_test.rb +183 -0
- data/mrbgem.rake +1 -0
- data/mrblib/0_validations.rb +206 -0
- data/mrblib/1_validators.rb +180 -0
- data/mrblib/cable.rb +24 -9
- data/mrblib/component.rb +246 -47
- data/mrblib/debug.rb +3 -0
- data/mrblib/differ.rb +47 -37
- data/mrblib/file_upload.rb +9 -1
- data/mrblib/form_builder.rb +28 -6
- data/mrblib/funicular.rb +97 -8
- data/mrblib/html_serializer.rb +121 -0
- data/mrblib/http.rb +123 -29
- data/mrblib/model.rb +50 -0
- data/mrblib/patcher.rb +78 -8
- data/mrblib/router.rb +40 -3
- data/mrblib/store.rb +304 -0
- data/mrblib/store_collection.rb +171 -0
- data/mrblib/store_singleton.rb +79 -0
- data/mrblib/vdom.rb +2 -0
- data/sig/cable.rbs +1 -0
- data/sig/component.rbs +13 -5
- data/sig/funicular.rbs +14 -1
- data/sig/html_serializer.rbs +20 -0
- data/sig/http.rbs +21 -6
- data/sig/model.rbs +6 -1
- data/sig/patcher.rbs +4 -1
- data/sig/router.rbs +3 -2
- data/sig/store.rbs +89 -0
- data/sig/store_collection.rbs +43 -0
- data/sig/store_singleton.rbs +19 -0
- data/sig/validations.rbs +103 -0
- data/sig/vdom.rbs +6 -6
- metadata +48 -12
- data/docs/README.md +0 -419
- data/docs/advanced-features.md +0 -632
- data/docs/components-and-state.md +0 -539
- data/docs/data-fetching.md +0 -528
- data/docs/forms.md +0 -446
- data/docs/rails-integration.md +0 -426
- data/docs/realtime.md +0 -543
- data/docs/routing-and-navigation.md +0 -427
- data/docs/styling.md +0 -285
data/mrblib/funicular.rb
CHANGED
|
@@ -15,12 +15,28 @@ rescue LoadError
|
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
module Funicular
|
|
18
|
-
|
|
18
|
+
# Guard against redefinition: when the mrblib runtime is loaded into a
|
|
19
|
+
# CRuby/Rails process for SSR, lib/funicular/version.rb has already defined
|
|
20
|
+
# VERSION for the CRuby gem. In the wasm build VERSION is undefined here.
|
|
21
|
+
VERSION = '0.1.0' unless Funicular.const_defined?(:VERSION)
|
|
19
22
|
|
|
20
23
|
def self.version
|
|
21
24
|
VERSION
|
|
22
25
|
end
|
|
23
26
|
|
|
27
|
+
# True when the runtime is loaded under CRuby on the server (SSR) rather
|
|
28
|
+
# than running as PicoRuby.wasm in the browser. JS-dependent entry points
|
|
29
|
+
# become no-ops in this mode. Defaults to false (browser).
|
|
30
|
+
@server = false
|
|
31
|
+
|
|
32
|
+
def self.server?
|
|
33
|
+
@server
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.server=(value)
|
|
37
|
+
@server = value ? true : false
|
|
38
|
+
end
|
|
39
|
+
|
|
24
40
|
def self.env
|
|
25
41
|
@env ||= EnvironmentInquirer.new(ENV['FUNICULAR_ENV'] || ENV['RAILS_ENV'] || 'development')
|
|
26
42
|
end
|
|
@@ -44,12 +60,57 @@ module Funicular
|
|
|
44
60
|
@router
|
|
45
61
|
end
|
|
46
62
|
|
|
63
|
+
# Read the SSR state embedded by the server (funicular_state_tag) as a
|
|
64
|
+
# Ruby Hash with string keys. Returns {} when absent or on the server.
|
|
65
|
+
# Goes through JSON.stringify/parse for a reliable JS->Ruby conversion.
|
|
66
|
+
def self.window_state
|
|
67
|
+
return {} if server?
|
|
68
|
+
win = JS.global[:window]
|
|
69
|
+
# @type var win: JS::Object?
|
|
70
|
+
return {} unless win
|
|
71
|
+
raw = win[:__FUNICULAR_STATE__]
|
|
72
|
+
return {} if raw.nil?
|
|
73
|
+
json = JS.global[:JSON]
|
|
74
|
+
# @type var json: untyped
|
|
75
|
+
json_str = json.stringify(raw)
|
|
76
|
+
JSON.parse(json_str.to_s)
|
|
77
|
+
rescue => e
|
|
78
|
+
puts "[Funicular] Failed to read window state: #{e.message}"
|
|
79
|
+
{}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# True when the server embedded hydration state on the page.
|
|
83
|
+
def self.has_ssr_state?
|
|
84
|
+
return false if server?
|
|
85
|
+
win = JS.global[:window]
|
|
86
|
+
# @type var win: JS::Object?
|
|
87
|
+
return false unless win
|
|
88
|
+
!win[:__FUNICULAR_STATE__].nil?
|
|
89
|
+
rescue
|
|
90
|
+
false
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The first element child of a container, or nil. Used to find the
|
|
94
|
+
# server-rendered root for hydration.
|
|
95
|
+
def self.first_element_child(container_element)
|
|
96
|
+
child = container_element[:firstElementChild]
|
|
97
|
+
child.is_a?(JS::Element) ? child : nil
|
|
98
|
+
end
|
|
99
|
+
|
|
47
100
|
# Load schemas for models
|
|
48
101
|
# Usage:
|
|
49
102
|
# Funicular.load_schemas({ User => "user", Session => "session" }) do
|
|
50
103
|
# Funicular.start(container: 'app') { |router| ... }
|
|
51
104
|
# end
|
|
52
105
|
def self.load_schemas(models, &block)
|
|
106
|
+
# On the server there is no fetch and no need for client-side schemas:
|
|
107
|
+
# SSR injects plain data into component state directly. Just run the
|
|
108
|
+
# block so route registration (Funicular.start) still happens.
|
|
109
|
+
if server?
|
|
110
|
+
block.call if block
|
|
111
|
+
return
|
|
112
|
+
end
|
|
113
|
+
|
|
53
114
|
schemas_loaded = 0
|
|
54
115
|
total_schemas = models.size
|
|
55
116
|
|
|
@@ -78,7 +139,20 @@ module Funicular
|
|
|
78
139
|
# Usage:
|
|
79
140
|
# Funicular.start(MyComponent, container: 'app')
|
|
80
141
|
# Funicular.start(MyComponent, container: 'app', props: { name: 'John' })
|
|
81
|
-
def self.start(component_class = nil, container: 'app', props: {}, &block)
|
|
142
|
+
def self.start(component_class = nil, container: 'app', props: {}, hydrate: false, &block)
|
|
143
|
+
# On the server we only need route registration so SSR can resolve a
|
|
144
|
+
# path to a component. Skip all DOM/JS work (container lookup, popstate
|
|
145
|
+
# listener, debug export).
|
|
146
|
+
if server?
|
|
147
|
+
if block
|
|
148
|
+
router = Router.new(nil)
|
|
149
|
+
@router = router
|
|
150
|
+
block.call(router)
|
|
151
|
+
return router
|
|
152
|
+
end
|
|
153
|
+
return nil
|
|
154
|
+
end
|
|
155
|
+
|
|
82
156
|
# Export debug configuration to JavaScript
|
|
83
157
|
export_debug_config
|
|
84
158
|
|
|
@@ -91,23 +165,33 @@ module Funicular
|
|
|
91
165
|
container
|
|
92
166
|
end
|
|
93
167
|
|
|
94
|
-
unless container_element
|
|
168
|
+
unless container_element.is_a?(JS::Element)
|
|
95
169
|
raise "Container element not found: #{container}"
|
|
96
170
|
end
|
|
97
171
|
|
|
172
|
+
# Hydrate automatically when the server embedded state, unless the caller
|
|
173
|
+
# explicitly opted out.
|
|
174
|
+
hydrate = true if hydrate == false && has_ssr_state?
|
|
175
|
+
|
|
98
176
|
# If block is given, use router mode
|
|
99
177
|
if block
|
|
100
178
|
router = Router.new(container_element)
|
|
101
179
|
@router = router
|
|
102
180
|
block.call(router)
|
|
103
|
-
router.start
|
|
181
|
+
router.start(hydrate: hydrate)
|
|
104
182
|
return router
|
|
105
183
|
end
|
|
106
184
|
|
|
107
185
|
# Otherwise, mount single component (backward compatible)
|
|
108
186
|
if component_class
|
|
109
187
|
instance = component_class.new(props)
|
|
110
|
-
|
|
188
|
+
server_root = hydrate ? first_element_child(container_element) : nil
|
|
189
|
+
if server_root
|
|
190
|
+
instance.seed_state(window_state)
|
|
191
|
+
instance.hydrate(server_root)
|
|
192
|
+
else
|
|
193
|
+
instance.mount(container_element)
|
|
194
|
+
end
|
|
111
195
|
return instance
|
|
112
196
|
end
|
|
113
197
|
|
|
@@ -123,11 +207,15 @@ module Funicular
|
|
|
123
207
|
attr_accessor :form_builder_config
|
|
124
208
|
|
|
125
209
|
def configure_forms
|
|
210
|
+
# Defaults are semantic class names whose CSS the gem ships and injects
|
|
211
|
+
# via picoruby_include_tag (see assets/funicular.css).
|
|
126
212
|
@form_builder_config ||= {
|
|
127
|
-
error_class: "
|
|
128
|
-
field_error_class: "
|
|
213
|
+
error_class: "funicular-error",
|
|
214
|
+
field_error_class: "funicular-field-error"
|
|
129
215
|
}
|
|
130
|
-
|
|
216
|
+
config = @form_builder_config
|
|
217
|
+
# @type var config: Hash[Symbol, String]
|
|
218
|
+
yield config if block_given?
|
|
131
219
|
end
|
|
132
220
|
end
|
|
133
221
|
|
|
@@ -149,6 +237,7 @@ module Funicular
|
|
|
149
237
|
|
|
150
238
|
# Export debug_color to JavaScript global variable
|
|
151
239
|
def self.export_debug_config
|
|
240
|
+
return if server?
|
|
152
241
|
if JS.global[:window]
|
|
153
242
|
JS.global[:window][:FUNICULAR_DEBUG_COLOR] = @debug_color # steep:ignore
|
|
154
243
|
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
module Funicular
|
|
2
|
+
module VDOM
|
|
3
|
+
# HTMLSerializer is a string-emitting counterpart of VDOM::Renderer.
|
|
4
|
+
# While Renderer builds a live DOM tree via JS (createElement/appendChild),
|
|
5
|
+
# HTMLSerializer walks the same VNode tree and produces an HTML string.
|
|
6
|
+
#
|
|
7
|
+
# It is pure Ruby with no JS dependency, so it runs under both mruby
|
|
8
|
+
# (in the browser, if ever needed) and CRuby (on the Rails server for SSR).
|
|
9
|
+
#
|
|
10
|
+
# Event handler props (on*) are intentionally skipped: they are Procs that
|
|
11
|
+
# cannot be serialized. They are re-bound on the client during hydration.
|
|
12
|
+
class HTMLSerializer
|
|
13
|
+
# Elements that have no closing tag and no children.
|
|
14
|
+
VOID_ELEMENTS = %w[
|
|
15
|
+
area base br col embed hr img input link meta param source track wbr
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
# Props that must never be emitted as HTML attributes.
|
|
19
|
+
SKIP_PROPS = %i[ref key children_block]
|
|
20
|
+
|
|
21
|
+
def self.serialize(vnode)
|
|
22
|
+
new.render(vnode)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def render(vnode)
|
|
26
|
+
case vnode&.type
|
|
27
|
+
when :element
|
|
28
|
+
# @type var vnode: Funicular::VDOM::Element
|
|
29
|
+
render_element(vnode)
|
|
30
|
+
when :text
|
|
31
|
+
# @type var vnode: Funicular::VDOM::Text
|
|
32
|
+
render_text(vnode)
|
|
33
|
+
when :component
|
|
34
|
+
# @type var vnode: Funicular::VDOM::Component
|
|
35
|
+
render_component(vnode)
|
|
36
|
+
when nil
|
|
37
|
+
""
|
|
38
|
+
else
|
|
39
|
+
raise "Unknown vnode type: #{vnode&.type}"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def render_element(element)
|
|
46
|
+
tag = element.tag
|
|
47
|
+
attrs = serialize_props(element.props)
|
|
48
|
+
|
|
49
|
+
if VOID_ELEMENTS.include?(tag)
|
|
50
|
+
"<#{tag}#{attrs}>"
|
|
51
|
+
else
|
|
52
|
+
"<#{tag}#{attrs}>#{render_children(element.children)}</#{tag}>"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def render_children(children)
|
|
57
|
+
parts = [] #: Array[String]
|
|
58
|
+
children.each do |child|
|
|
59
|
+
if child.is_a?(VNode)
|
|
60
|
+
parts << render(child)
|
|
61
|
+
elsif child.is_a?(String)
|
|
62
|
+
parts << escape_html(child)
|
|
63
|
+
elsif child.is_a?(Array)
|
|
64
|
+
parts << render_children(child)
|
|
65
|
+
elsif !child.nil?
|
|
66
|
+
parts << escape_html(child.to_s)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
parts.join
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def render_text(text)
|
|
73
|
+
escape_html(text.content)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def render_component(component_vnode)
|
|
77
|
+
instance = component_vnode.component_class.new(component_vnode.props)
|
|
78
|
+
component_vnode.instance = instance
|
|
79
|
+
render(instance.build_vdom)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def serialize_props(props)
|
|
83
|
+
parts = [] #: Array[String]
|
|
84
|
+
props.each do |key, value|
|
|
85
|
+
key_str = key.to_s
|
|
86
|
+
next if SKIP_PROPS.include?(key)
|
|
87
|
+
# Event handlers are bound on the client, never serialized.
|
|
88
|
+
next if key_str.start_with?("on")
|
|
89
|
+
|
|
90
|
+
if URL_ATTRIBUTES.include?(key_str) &&
|
|
91
|
+
value.to_s.strip.downcase.start_with?("javascript:")
|
|
92
|
+
# Prevent XSS via javascript: URIs (mirrors Renderer#render_element).
|
|
93
|
+
next
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
if BOOLEAN_ATTRIBUTES.include?(key_str)
|
|
97
|
+
# Boolean attributes are absent when false/nil, otherwise present.
|
|
98
|
+
next if value.nil? || value.to_s == "false"
|
|
99
|
+
parts << " #{key_str}=\"#{key_str}\""
|
|
100
|
+
else
|
|
101
|
+
parts << " #{key_str}=\"#{escape_attr(value.to_s)}\""
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
parts.join
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Minimal HTML escaping that works the same under mruby and CRuby.
|
|
108
|
+
# Avoids any dependency on CGI/ERB::Util.
|
|
109
|
+
def escape_html(str)
|
|
110
|
+
str.to_s
|
|
111
|
+
.gsub("&", "&")
|
|
112
|
+
.gsub("<", "<")
|
|
113
|
+
.gsub(">", ">")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def escape_attr(str)
|
|
117
|
+
escape_html(str).gsub('"', """)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
data/mrblib/http.rb
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
module Funicular
|
|
2
2
|
module HTTP
|
|
3
|
+
CACHE_DB_NAME = 'funicular_http_cache'.freeze
|
|
4
|
+
CACHE_STORE = 'responses'.freeze
|
|
5
|
+
|
|
6
|
+
@cache = nil
|
|
7
|
+
|
|
3
8
|
class Response
|
|
4
9
|
attr_reader :data, :status, :ok
|
|
5
10
|
|
|
@@ -21,23 +26,73 @@ module Funicular
|
|
|
21
26
|
end
|
|
22
27
|
end
|
|
23
28
|
|
|
24
|
-
|
|
25
|
-
|
|
29
|
+
# Open (or reuse) the response cache store. Idempotent and safe to call
|
|
30
|
+
# multiple times. Falls back to the in-memory backing if browser
|
|
31
|
+
# IndexedDB is unavailable.
|
|
32
|
+
def self.cache_init!
|
|
33
|
+
cache = @cache
|
|
34
|
+
return cache if cache
|
|
35
|
+
@cache = IndexedDB::KVS.open(CACHE_DB_NAME, store: CACHE_STORE)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Drop a single cached entry by URL key. No-op if the cache is not
|
|
39
|
+
# initialized.
|
|
40
|
+
def self.cache_purge(url)
|
|
41
|
+
cache = @cache
|
|
42
|
+
return nil unless cache
|
|
43
|
+
cache.delete(url)
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Drop every cached entry. No-op if the cache is not initialized.
|
|
48
|
+
def self.cache_clear
|
|
49
|
+
cache = @cache
|
|
50
|
+
return nil unless cache
|
|
51
|
+
cache.clear
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Internal: read the cache for *url*. Returns the parsed entry hash or
|
|
56
|
+
# nil. Lazily initializes the cache on first use so callers can pass
|
|
57
|
+
# `cache:` without booting the SPA shell first.
|
|
58
|
+
def self.cache_lookup(url)
|
|
59
|
+
cache_init! unless @cache
|
|
60
|
+
cache = @cache
|
|
61
|
+
return nil unless cache
|
|
62
|
+
cache[url]
|
|
26
63
|
end
|
|
27
64
|
|
|
28
|
-
|
|
65
|
+
# Internal: write *entry* (a Hash with status/data/cached_at) to the
|
|
66
|
+
# cache. Awaits one extra Promise so the next request reliably hits.
|
|
67
|
+
def self.cache_write(url, entry)
|
|
68
|
+
cache_init! unless @cache
|
|
69
|
+
cache = @cache
|
|
70
|
+
return nil unless cache
|
|
71
|
+
cache[url] = entry
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.get(url, cache: nil, &block)
|
|
76
|
+
request("GET", url, nil, cache: cache, &block)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def self.post(url, body = nil, cache: nil, &block)
|
|
80
|
+
warn_unsupported_cache("post") if cache
|
|
29
81
|
request("POST", url, body, &block)
|
|
30
82
|
end
|
|
31
83
|
|
|
32
|
-
def self.patch(url, body = nil, &block)
|
|
84
|
+
def self.patch(url, body = nil, cache: nil, &block)
|
|
85
|
+
warn_unsupported_cache("patch") if cache
|
|
33
86
|
request("PATCH", url, body, &block)
|
|
34
87
|
end
|
|
35
88
|
|
|
36
|
-
def self.delete(url, &block)
|
|
89
|
+
def self.delete(url, cache: nil, &block)
|
|
90
|
+
warn_unsupported_cache("delete") if cache
|
|
37
91
|
request("DELETE", url, nil, &block)
|
|
38
92
|
end
|
|
39
93
|
|
|
40
|
-
def self.put(url, body = nil, &block)
|
|
94
|
+
def self.put(url, body = nil, cache: nil, &block)
|
|
95
|
+
warn_unsupported_cache("put") if cache
|
|
41
96
|
request("PUT", url, body, &block)
|
|
42
97
|
end
|
|
43
98
|
|
|
@@ -46,43 +101,82 @@ module Funicular
|
|
|
46
101
|
def self.csrf_token
|
|
47
102
|
meta = JS.document.querySelector('meta[name="csrf-token"]')
|
|
48
103
|
if meta
|
|
49
|
-
# Use getAttribute method (direct method call on JS::Object)
|
|
50
104
|
token_obj = meta.getAttribute('content')
|
|
51
|
-
# Convert JS::Object to Ruby string
|
|
52
105
|
token_obj ? token_obj.to_s : nil
|
|
53
106
|
else
|
|
54
107
|
nil
|
|
55
108
|
end
|
|
56
109
|
end
|
|
57
110
|
|
|
58
|
-
|
|
111
|
+
class << self
|
|
112
|
+
private
|
|
59
113
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
headers = {} #: Hash[String, String]
|
|
114
|
+
def warn_unsupported_cache(verb)
|
|
115
|
+
puts "[Funicular::HTTP] cache: option is GET-only; ignoring on #{verb.upcase}"
|
|
116
|
+
end
|
|
65
117
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
118
|
+
def now_seconds
|
|
119
|
+
# JavaScript Date.now() returns ms since epoch
|
|
120
|
+
ms = JS.global[:Date].now # steep:ignore
|
|
121
|
+
(ms.to_i / 1000)
|
|
69
122
|
end
|
|
70
123
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
124
|
+
def cache_hit?(entry, ttl)
|
|
125
|
+
return false unless entry.is_a?(Hash)
|
|
126
|
+
cached_at = entry["cached_at"]
|
|
127
|
+
return false unless cached_at.is_a?(Integer)
|
|
128
|
+
(now_seconds - cached_at) <= ttl
|
|
75
129
|
end
|
|
76
130
|
|
|
77
|
-
|
|
131
|
+
def serve_from_cache(entry, &block)
|
|
132
|
+
status = entry["status"].to_i
|
|
133
|
+
data = entry["data"]
|
|
134
|
+
block.call(Response.new(status, data)) if block
|
|
135
|
+
end
|
|
78
136
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
137
|
+
def request(method, url, body, cache: nil, &block)
|
|
138
|
+
if method == "GET" && cache.is_a?(Integer) && cache > 0
|
|
139
|
+
entry = cache_lookup(url)
|
|
140
|
+
if cache_hit?(entry, cache)
|
|
141
|
+
serve_from_cache(entry, &block)
|
|
142
|
+
return
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# @type var options: Hash[Symbol, String | Hash[String, String]]
|
|
147
|
+
options = { method: method, credentials: "include" }
|
|
148
|
+
|
|
149
|
+
headers = {} #: Hash[String, String]
|
|
150
|
+
|
|
151
|
+
if body
|
|
152
|
+
headers["Content-Type"] = "application/json"
|
|
153
|
+
options[:body] = JSON.generate(body)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
if method != "GET"
|
|
157
|
+
token = csrf_token
|
|
158
|
+
headers["X-CSRF-Token"] = token if token
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
options[:headers] = headers unless headers.empty?
|
|
162
|
+
|
|
163
|
+
JS.global.fetch(url, options) do |response|
|
|
164
|
+
status = response.status.to_i
|
|
165
|
+
json_text = response.to_binary
|
|
166
|
+
data = JSON.parse(json_text)
|
|
167
|
+
# @type var status: Integer
|
|
168
|
+
http_response = Response.new(status, data)
|
|
169
|
+
|
|
170
|
+
if method == "GET" && cache.is_a?(Integer) && cache > 0 && http_response.ok
|
|
171
|
+
cache_write(url, {
|
|
172
|
+
"status" => status,
|
|
173
|
+
"data" => data,
|
|
174
|
+
"cached_at" => now_seconds
|
|
175
|
+
})
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
block.call(http_response) if block
|
|
179
|
+
end
|
|
86
180
|
end
|
|
87
181
|
end
|
|
88
182
|
end
|
data/mrblib/model.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
module Funicular
|
|
2
2
|
class Model
|
|
3
|
+
include Validations
|
|
4
|
+
|
|
3
5
|
attr_reader :id
|
|
4
6
|
|
|
5
7
|
class << self
|
|
@@ -22,6 +24,41 @@ module Funicular
|
|
|
22
24
|
@changed_attributes[name] = value
|
|
23
25
|
end
|
|
24
26
|
end
|
|
27
|
+
|
|
28
|
+
# Validations are inlined per attribute by Funicular::Schema.build.
|
|
29
|
+
register_schema_validations(name => config["validations"]) if config["validations"]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Backward-compatible: a top-level { attr => rules } block also works.
|
|
33
|
+
register_schema_validations(schema_data["validations"])
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# validations: { "attr" => { "presence" => true, "length" => { "maximum" => 30 } } }
|
|
37
|
+
def self.register_schema_validations(validations)
|
|
38
|
+
return unless validations.is_a?(Hash)
|
|
39
|
+
validations.each do |attribute, rules|
|
|
40
|
+
next unless rules.is_a?(Hash)
|
|
41
|
+
rules.each do |kind, opts|
|
|
42
|
+
options = normalize_validation_options(kind, opts)
|
|
43
|
+
add_schema_validator(attribute, kind, options)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Turn JSON-shaped validator options into the Ruby options the client
|
|
49
|
+
# validators expect (notably rebuilding a Regexp for `format`). Integer
|
|
50
|
+
# Regexp flags are used so this works the same under CRuby and the client
|
|
51
|
+
# JS RegExp wrapper.
|
|
52
|
+
def self.normalize_validation_options(kind, opts)
|
|
53
|
+
return opts unless opts.is_a?(Hash)
|
|
54
|
+
if kind.to_s == "format" && opts["with"]
|
|
55
|
+
flags = opts["flags"].to_s
|
|
56
|
+
bits = 0
|
|
57
|
+
bits |= Regexp::IGNORECASE if flags.include?("i")
|
|
58
|
+
bits |= Regexp::MULTILINE if flags.include?("m")
|
|
59
|
+
{ with: Regexp.new(opts["with"], bits) }
|
|
60
|
+
else
|
|
61
|
+
opts
|
|
25
62
|
end
|
|
26
63
|
end
|
|
27
64
|
|
|
@@ -70,6 +107,13 @@ module Funicular
|
|
|
70
107
|
endpoint = @endpoints["create"]
|
|
71
108
|
return unless endpoint
|
|
72
109
|
|
|
110
|
+
# Validate on the client before the request (mirrors ActiveRecord#save).
|
|
111
|
+
candidate = new(attrs)
|
|
112
|
+
unless candidate.valid?
|
|
113
|
+
block.call(nil, candidate.errors) if block
|
|
114
|
+
return
|
|
115
|
+
end
|
|
116
|
+
|
|
73
117
|
HTTP.post(endpoint["path"], attrs) do |response|
|
|
74
118
|
if response.error?
|
|
75
119
|
block.call(nil, response.error_message) if block
|
|
@@ -101,6 +145,12 @@ module Funicular
|
|
|
101
145
|
attrs.each { |k, v| send("#{k}=", v) }
|
|
102
146
|
end
|
|
103
147
|
|
|
148
|
+
# Validate on the client before the request (mirrors ActiveRecord#save).
|
|
149
|
+
unless valid?
|
|
150
|
+
block.call(false, errors) if block
|
|
151
|
+
return
|
|
152
|
+
end
|
|
153
|
+
|
|
104
154
|
return if @changed_attributes.empty?
|
|
105
155
|
|
|
106
156
|
json_attrs = @changed_attributes.reject do |name, value|
|