archipelago-rails 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +8 -0
- data/app/controllers/archipelago/islands_controller.rb +26 -2
- data/lib/archipelago/action.rb +5 -1
- data/lib/archipelago/cancan_adapter.rb +25 -0
- data/lib/archipelago/channel.rb +14 -1
- data/lib/archipelago/configuration.rb +7 -1
- data/lib/archipelago/context.rb +3 -1
- data/lib/archipelago/params_dsl.rb +62 -3
- data/lib/archipelago/pundit_adapter.rb +30 -0
- data/lib/archipelago.rb +11 -0
- data/lib/generators/archipelago/install/install_generator.rb +2 -0
- data/lib/generators/archipelago/install/react/react_generator.rb +60 -0
- data/lib/generators/archipelago/install/react/templates/generate_registry.mjs.tt +28 -11
- data/test/archipelago/action_test.rb +182 -1
- data/test/archipelago/channel_test.rb +35 -0
- data/test/archipelago/notifications_test.rb +1 -1
- data/test/archipelago/params_dsl_test.rb +204 -1
- data/test/generators/react_install_generator_test.rb +10 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 958584d368aaf5860d872cd58c01639c7efda2a412b96b8d112bd941fc455d31
|
|
4
|
+
data.tar.gz: e1a648f35bdd36fee3c0c798989a613663a1bfd7c336757bd73005c60417f10a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: acd4fc0e23a2897fd483a0a18340a93c9a24bf705da3de9441b3f895103a2bcace726192be557ba4dd6ccc7fbb75b8df38b9575b59367b175a82ddae440617bc
|
|
7
|
+
data.tar.gz: 846fdc9686466a6ad5ca9a98b74ae5ca3ba9286878f5cedbc9b0410a739988558be826d10b2d49282835e6a675e010757263d4051fb35970924937db47689e5a
|
data/README.md
CHANGED
|
@@ -45,6 +45,14 @@ After `rails g archipelago:install`, you can scaffold frontend bootstrap wiring:
|
|
|
45
45
|
rails g archipelago:install:react
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
+
The generator writes `.npmrc` with:
|
|
49
|
+
|
|
50
|
+
```text
|
|
51
|
+
@archipelago-js:registry=https://registry.npmjs.org
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
This keeps installs reliable across npm, Yarn classic, pnpm, and bun.
|
|
55
|
+
|
|
48
56
|
By default this runs an interactive wizard with auto-detected defaults
|
|
49
57
|
(bundler, TypeScript, package manager, and local monorepo path).
|
|
50
58
|
|
|
@@ -35,10 +35,26 @@ module Archipelago
|
|
|
35
35
|
request: request,
|
|
36
36
|
params: params,
|
|
37
37
|
session: session,
|
|
38
|
-
user: current_archipelago_user
|
|
38
|
+
user: current_archipelago_user,
|
|
39
|
+
stream: extract_stream_name
|
|
39
40
|
)
|
|
40
41
|
end
|
|
41
42
|
|
|
43
|
+
def extract_stream_name
|
|
44
|
+
from_header = request.headers["X-Archipelago-Stream"].presence
|
|
45
|
+
return from_header if from_header
|
|
46
|
+
|
|
47
|
+
# Backwards compat: fall back to __stream in params with deprecation warning.
|
|
48
|
+
from_params = island_params[:__stream].presence
|
|
49
|
+
if from_params
|
|
50
|
+
ActiveSupport::Deprecation.warn(
|
|
51
|
+
"Passing __stream in the request payload is deprecated. " \
|
|
52
|
+
"Use the X-Archipelago-Stream header instead."
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
from_params
|
|
56
|
+
end
|
|
57
|
+
|
|
42
58
|
def current_archipelago_user
|
|
43
59
|
resolver_proc = Archipelago.configuration.current_user_resolver
|
|
44
60
|
return instance_exec(&resolver_proc) if resolver_proc
|
|
@@ -131,7 +147,7 @@ module Archipelago
|
|
|
131
147
|
return [] unless handler_class.respond_to?(:param_definitions)
|
|
132
148
|
|
|
133
149
|
handler_class.param_definitions.values.map do |definition|
|
|
134
|
-
{
|
|
150
|
+
entry = {
|
|
135
151
|
name: definition.name.to_s,
|
|
136
152
|
type: definition.type.to_s,
|
|
137
153
|
required: definition.required,
|
|
@@ -142,6 +158,14 @@ module Archipelago
|
|
|
142
158
|
upcase: definition.upcase
|
|
143
159
|
}
|
|
144
160
|
}
|
|
161
|
+
entry[:in] = definition.in.to_a if definition.in
|
|
162
|
+
entry[:format] = definition.format.inspect if definition.format
|
|
163
|
+
entry[:min] = definition.min if definition.min
|
|
164
|
+
entry[:max] = definition.max if definition.max
|
|
165
|
+
entry[:empty_as_nil] = true if definition.empty_as_nil
|
|
166
|
+
entry[:of] = definition.of.to_s if definition.of
|
|
167
|
+
entry[:validate] = true if definition.validate
|
|
168
|
+
entry
|
|
145
169
|
end
|
|
146
170
|
end
|
|
147
171
|
|
data/lib/archipelago/action.rb
CHANGED
|
@@ -110,8 +110,12 @@ module Archipelago
|
|
|
110
110
|
defined?(ActiveRecord::RecordInvalid) && error.is_a?(ActiveRecord::RecordInvalid)
|
|
111
111
|
end
|
|
112
112
|
|
|
113
|
+
def current_user
|
|
114
|
+
ctx.user
|
|
115
|
+
end
|
|
116
|
+
|
|
113
117
|
def maybe_broadcast(payload)
|
|
114
|
-
stream_name = raw_params[:__stream]
|
|
118
|
+
stream_name = ctx.stream || raw_params[:__stream]
|
|
115
119
|
return if stream_name.blank?
|
|
116
120
|
return unless payload[:status] == "ok"
|
|
117
121
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Archipelago
|
|
4
|
+
module CanCanAdapter
|
|
5
|
+
def authorize!(action, record)
|
|
6
|
+
ability = current_ability
|
|
7
|
+
unless ability.can?(action, record)
|
|
8
|
+
raise Archipelago::Forbidden, "not allowed to #{action} this #{record.class}"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
record
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def current_ability
|
|
15
|
+
ability_builder = Archipelago.configuration.current_ability
|
|
16
|
+
if ability_builder
|
|
17
|
+
ability_builder.call(current_user)
|
|
18
|
+
elsif defined?(::Ability)
|
|
19
|
+
::Ability.new(current_user)
|
|
20
|
+
else
|
|
21
|
+
raise Archipelago::Error, "No ability class configured. Set Archipelago.configuration.current_ability or define an Ability class."
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
data/lib/archipelago/channel.rb
CHANGED
|
@@ -9,13 +9,26 @@ else
|
|
|
9
9
|
def stream_from(*); end
|
|
10
10
|
def reject; end
|
|
11
11
|
def params = {}
|
|
12
|
+
def connection; end
|
|
12
13
|
end
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
channel_class = Class.new(base_channel) do
|
|
16
17
|
def subscribed
|
|
17
18
|
stream_name = verified_stream_name
|
|
18
|
-
|
|
19
|
+
unless stream_name
|
|
20
|
+
reject
|
|
21
|
+
return
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
unless Archipelago.authorize_stream?(
|
|
25
|
+
connection: connection,
|
|
26
|
+
stream_name: stream_name,
|
|
27
|
+
params: params.except(:channel, :stream_name)
|
|
28
|
+
)
|
|
29
|
+
reject
|
|
30
|
+
return
|
|
31
|
+
end
|
|
19
32
|
|
|
20
33
|
stream_from(stream_name)
|
|
21
34
|
end
|
|
@@ -8,7 +8,10 @@ module Archipelago
|
|
|
8
8
|
:authorize_by_default,
|
|
9
9
|
:strict_origin_check,
|
|
10
10
|
:allowed_redirect_hosts,
|
|
11
|
-
:version_source
|
|
11
|
+
:version_source,
|
|
12
|
+
:stream_authorizer,
|
|
13
|
+
:require_stream_authorization,
|
|
14
|
+
:current_ability
|
|
12
15
|
|
|
13
16
|
def initialize
|
|
14
17
|
@root_namespace = "Islands"
|
|
@@ -18,6 +21,9 @@ module Archipelago
|
|
|
18
21
|
@strict_origin_check = false
|
|
19
22
|
@allowed_redirect_hosts = []
|
|
20
23
|
@version_source = -> { (Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond)).to_i }
|
|
24
|
+
@stream_authorizer = nil
|
|
25
|
+
@require_stream_authorization = false
|
|
26
|
+
@current_ability = nil
|
|
21
27
|
end
|
|
22
28
|
end
|
|
23
29
|
end
|
data/lib/archipelago/context.rb
CHANGED
|
@@ -3,12 +3,14 @@
|
|
|
3
3
|
module Archipelago
|
|
4
4
|
class Context
|
|
5
5
|
attr_reader :request, :params, :session, :user
|
|
6
|
+
attr_accessor :stream
|
|
6
7
|
|
|
7
|
-
def initialize(request:, params:, session:, user: nil)
|
|
8
|
+
def initialize(request:, params:, session:, user: nil, stream: nil)
|
|
8
9
|
@request = request
|
|
9
10
|
@params = params
|
|
10
11
|
@session = session
|
|
11
12
|
@user = user
|
|
13
|
+
@stream = stream
|
|
12
14
|
end
|
|
13
15
|
end
|
|
14
16
|
end
|
|
@@ -15,6 +15,13 @@ module Archipelago
|
|
|
15
15
|
:strip,
|
|
16
16
|
:downcase,
|
|
17
17
|
:upcase,
|
|
18
|
+
:in,
|
|
19
|
+
:format,
|
|
20
|
+
:min,
|
|
21
|
+
:max,
|
|
22
|
+
:empty_as_nil,
|
|
23
|
+
:of,
|
|
24
|
+
:validate,
|
|
18
25
|
keyword_init: true
|
|
19
26
|
)
|
|
20
27
|
|
|
@@ -23,7 +30,8 @@ module Archipelago
|
|
|
23
30
|
@param_definitions ||= {}
|
|
24
31
|
end
|
|
25
32
|
|
|
26
|
-
def param(name, type, required: false, default: MISSING, strip: false, downcase: false, upcase: false
|
|
33
|
+
def param(name, type, required: false, default: MISSING, strip: false, downcase: false, upcase: false,
|
|
34
|
+
in: nil, format: nil, min: nil, max: nil, empty_as_nil: false, of: nil, validate: nil)
|
|
27
35
|
symbol_name = name.to_sym
|
|
28
36
|
|
|
29
37
|
param_definitions[symbol_name] = ParamDefinition.new(
|
|
@@ -33,7 +41,14 @@ module Archipelago
|
|
|
33
41
|
default: default,
|
|
34
42
|
strip: strip,
|
|
35
43
|
downcase: downcase,
|
|
36
|
-
upcase: upcase
|
|
44
|
+
upcase: upcase,
|
|
45
|
+
in: binding.local_variable_get(:in),
|
|
46
|
+
format: format,
|
|
47
|
+
min: min,
|
|
48
|
+
max: max,
|
|
49
|
+
empty_as_nil: empty_as_nil,
|
|
50
|
+
of: of,
|
|
51
|
+
validate: validate
|
|
37
52
|
)
|
|
38
53
|
|
|
39
54
|
define_method(symbol_name) do
|
|
@@ -53,6 +68,10 @@ module Archipelago
|
|
|
53
68
|
self.class.param_definitions.each_value do |definition|
|
|
54
69
|
raw_value = fetch_param(raw_params, definition.name)
|
|
55
70
|
|
|
71
|
+
if definition.empty_as_nil && empty_string?(raw_value)
|
|
72
|
+
raw_value = nil
|
|
73
|
+
end
|
|
74
|
+
|
|
56
75
|
if blank_value?(raw_value)
|
|
57
76
|
if definition.default != MISSING
|
|
58
77
|
coerced[definition.name] = definition.default.respond_to?(:call) ? definition.default.call : definition.default
|
|
@@ -65,7 +84,12 @@ module Archipelago
|
|
|
65
84
|
|
|
66
85
|
begin
|
|
67
86
|
coerced_value = coerce_value(raw_value, definition)
|
|
68
|
-
|
|
87
|
+
validation_error = run_validations(coerced_value, definition)
|
|
88
|
+
if validation_error
|
|
89
|
+
errors[definition.name.to_s] << validation_error
|
|
90
|
+
else
|
|
91
|
+
coerced[definition.name] = coerced_value
|
|
92
|
+
end
|
|
69
93
|
rescue ArgumentError, TypeError, JSON::ParserError
|
|
70
94
|
errors[definition.name.to_s] << "is invalid"
|
|
71
95
|
end
|
|
@@ -84,9 +108,17 @@ module Archipelago
|
|
|
84
108
|
value.nil? || (value.respond_to?(:empty?) && value.empty?)
|
|
85
109
|
end
|
|
86
110
|
|
|
111
|
+
def empty_string?(value)
|
|
112
|
+
value.is_a?(String) && value.strip.empty?
|
|
113
|
+
end
|
|
114
|
+
|
|
87
115
|
def coerce_value(raw_value, definition)
|
|
88
116
|
value = cast(raw_value, definition.type)
|
|
89
117
|
|
|
118
|
+
if definition.type == :array && definition.of
|
|
119
|
+
value = value.map { |element| cast(element, definition.of) }
|
|
120
|
+
end
|
|
121
|
+
|
|
90
122
|
return value unless value.is_a?(String)
|
|
91
123
|
|
|
92
124
|
value = value.strip if definition.strip
|
|
@@ -95,6 +127,33 @@ module Archipelago
|
|
|
95
127
|
value
|
|
96
128
|
end
|
|
97
129
|
|
|
130
|
+
def run_validations(value, definition)
|
|
131
|
+
if definition.in && !definition.in.include?(value)
|
|
132
|
+
return "is not included in the list"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
if definition.format && value.is_a?(String) && !value.match?(definition.format)
|
|
136
|
+
return "is invalid"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
if definition.min
|
|
140
|
+
comparable = value.respond_to?(:length) ? value.length : value
|
|
141
|
+
return "is too small" if comparable < definition.min
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
if definition.max
|
|
145
|
+
comparable = value.respond_to?(:length) ? value.length : value
|
|
146
|
+
return "is too large" if comparable > definition.max
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
if definition.validate
|
|
150
|
+
custom_error = definition.validate.call(value)
|
|
151
|
+
return custom_error if custom_error.is_a?(String)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
nil
|
|
155
|
+
end
|
|
156
|
+
|
|
98
157
|
def cast(value, type)
|
|
99
158
|
case type
|
|
100
159
|
when :string
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Archipelago
|
|
4
|
+
module PunditAdapter
|
|
5
|
+
def authorize(record, query = nil)
|
|
6
|
+
query ||= infer_pundit_query
|
|
7
|
+
policy = policy(record)
|
|
8
|
+
|
|
9
|
+
unless policy.public_send(query)
|
|
10
|
+
raise Archipelago::Forbidden, "not allowed to #{query} this #{record.class}"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
record
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def policy(record)
|
|
17
|
+
klass = "#{record.class}Policy".safe_constantize
|
|
18
|
+
raise Archipelago::Forbidden, "no policy found for #{record.class}" unless klass
|
|
19
|
+
|
|
20
|
+
klass.new(current_user, record)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def infer_pundit_query
|
|
26
|
+
action_name = self.class.name.to_s.demodulize.sub(/Action\z/, "").underscore
|
|
27
|
+
"#{action_name}?"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
data/lib/archipelago.rb
CHANGED
|
@@ -45,6 +45,15 @@ module Archipelago
|
|
|
45
45
|
Broadcasts.broadcast(stream_name, props: props, version: version)
|
|
46
46
|
end
|
|
47
47
|
|
|
48
|
+
def authorize_stream?(connection:, stream_name:, params: {})
|
|
49
|
+
authorizer = configuration.stream_authorizer
|
|
50
|
+
return true unless configuration.require_stream_authorization || authorizer
|
|
51
|
+
|
|
52
|
+
return false if configuration.require_stream_authorization && authorizer.nil?
|
|
53
|
+
|
|
54
|
+
authorizer.call(connection: connection, stream_name: stream_name, params: params)
|
|
55
|
+
end
|
|
56
|
+
|
|
48
57
|
def instrument(event, payload = {}, &block)
|
|
49
58
|
ActiveSupport::Notifications.instrument(event, payload, &block)
|
|
50
59
|
end
|
|
@@ -59,6 +68,8 @@ require "archipelago/context"
|
|
|
59
68
|
require "archipelago/security/origin_validator"
|
|
60
69
|
require "archipelago/security/redirect_validator"
|
|
61
70
|
require "archipelago/action"
|
|
71
|
+
require "archipelago/pundit_adapter"
|
|
72
|
+
require "archipelago/cancan_adapter"
|
|
62
73
|
require "archipelago/resolver"
|
|
63
74
|
require "archipelago/broadcasts"
|
|
64
75
|
require "archipelago/channel"
|
|
@@ -33,6 +33,8 @@ module Archipelago
|
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
def print_next_steps
|
|
36
|
+
say "If package install fails on Yarn mirror, add to .npmrc:"
|
|
37
|
+
say " @archipelago-js:registry=https://registry.npmjs.org"
|
|
36
38
|
say "Install JS packages: yarn add @archipelago-js/client @archipelago-js/react"
|
|
37
39
|
say "Optional React bootstrap wizard: rails g archipelago:install:react"
|
|
38
40
|
say "Non-interactive mode: rails g archipelago:install:react --interactive=false"
|
|
@@ -48,6 +48,11 @@ module Archipelago
|
|
|
48
48
|
default: true,
|
|
49
49
|
desc: "For esbuild, auto-generate component registry from app/javascript/islands"
|
|
50
50
|
|
|
51
|
+
class_option :lazy_registry,
|
|
52
|
+
type: :boolean,
|
|
53
|
+
default: false,
|
|
54
|
+
desc: "Generate lazy (dynamic import) registry instead of eager imports"
|
|
55
|
+
|
|
51
56
|
desc "Sets up React + Archipelago frontend bootstrapping for a Rails app."
|
|
52
57
|
|
|
53
58
|
def detect_stack
|
|
@@ -69,6 +74,7 @@ module Archipelago
|
|
|
69
74
|
@package_manager = options[:package_manager] == "auto" ? @detected_package_manager : options[:package_manager]
|
|
70
75
|
@local_monorepo_path = options[:local_monorepo_path] || @detected_local_monorepo_path
|
|
71
76
|
@auto_registry = options[:auto_registry]
|
|
77
|
+
@lazy_registry = options[:lazy_registry]
|
|
72
78
|
end
|
|
73
79
|
|
|
74
80
|
def interactive_preferences
|
|
@@ -122,6 +128,10 @@ module Archipelago
|
|
|
122
128
|
template "entry.js.tt", @entry_relative_path
|
|
123
129
|
end
|
|
124
130
|
|
|
131
|
+
def configure_scope_registry
|
|
132
|
+
ensure_scope_registry_in_npmrc
|
|
133
|
+
end
|
|
134
|
+
|
|
125
135
|
def setup_esbuild_auto_registry
|
|
126
136
|
return unless @bundler == :esbuild
|
|
127
137
|
return unless @auto_registry
|
|
@@ -131,6 +141,13 @@ module Archipelago
|
|
|
131
141
|
wire_esbuild_package_scripts
|
|
132
142
|
end
|
|
133
143
|
|
|
144
|
+
def setup_lazy_registry
|
|
145
|
+
return unless @lazy_registry
|
|
146
|
+
return if @bundler == :esbuild && @auto_registry
|
|
147
|
+
|
|
148
|
+
create_file registry_relative_path, lazy_registry_source
|
|
149
|
+
end
|
|
150
|
+
|
|
134
151
|
def wire_esbuild_entry
|
|
135
152
|
return unless @bundler == :esbuild
|
|
136
153
|
|
|
@@ -166,6 +183,7 @@ module Archipelago
|
|
|
166
183
|
say "Install packages:"
|
|
167
184
|
say " #{resolved_package_manager} add #{packages_for_install.join(' ')}"
|
|
168
185
|
end
|
|
186
|
+
say "Scoped registry configured in .npmrc for @archipelago-js -> npmjs.org."
|
|
169
187
|
say ""
|
|
170
188
|
if @bundler == :esbuild && @auto_registry
|
|
171
189
|
say "Islands in app/javascript/islands/**/* are auto-registered before esbuild runs."
|
|
@@ -268,6 +286,32 @@ module Archipelago
|
|
|
268
286
|
end
|
|
269
287
|
end
|
|
270
288
|
|
|
289
|
+
def lazy_registry_source
|
|
290
|
+
if @use_typescript
|
|
291
|
+
<<~TS
|
|
292
|
+
import { defineIslandLoader, type IslandRegistry } from "@archipelago-js/react"
|
|
293
|
+
|
|
294
|
+
// Lazy registry: islands are loaded on demand via dynamic import.
|
|
295
|
+
// Add entries like:
|
|
296
|
+
// MyIsland: defineIslandLoader(() => import("../islands/MyIsland"))
|
|
297
|
+
const registry: IslandRegistry = {}
|
|
298
|
+
|
|
299
|
+
export default registry
|
|
300
|
+
TS
|
|
301
|
+
else
|
|
302
|
+
<<~JS
|
|
303
|
+
import { defineIslandLoader } from "@archipelago-js/react"
|
|
304
|
+
|
|
305
|
+
// Lazy registry: islands are loaded on demand via dynamic import.
|
|
306
|
+
// Add entries like:
|
|
307
|
+
// MyIsland: defineIslandLoader(() => import("../islands/MyIsland"))
|
|
308
|
+
const registry = {}
|
|
309
|
+
|
|
310
|
+
export default registry
|
|
311
|
+
JS
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
271
315
|
def wire_esbuild_package_scripts
|
|
272
316
|
return unless path_exists?("package.json")
|
|
273
317
|
|
|
@@ -374,6 +418,22 @@ module Archipelago
|
|
|
374
418
|
root = Pathname.new(@local_monorepo_path).expand_path
|
|
375
419
|
"@archipelago-js/react@file:#{root.join('packages/react')}"
|
|
376
420
|
end
|
|
421
|
+
|
|
422
|
+
def ensure_scope_registry_in_npmrc
|
|
423
|
+
scope_key = "@archipelago-js:registry"
|
|
424
|
+
registry_line = "#{scope_key}=https://registry.npmjs.org"
|
|
425
|
+
npmrc_path = path_for(".npmrc")
|
|
426
|
+
|
|
427
|
+
unless File.exist?(npmrc_path)
|
|
428
|
+
create_file ".npmrc", "#{registry_line}\n"
|
|
429
|
+
return
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
content = File.read(npmrc_path)
|
|
433
|
+
return if content.include?(scope_key)
|
|
434
|
+
|
|
435
|
+
append_to_file ".npmrc", "\n#{registry_line}\n"
|
|
436
|
+
end
|
|
377
437
|
end
|
|
378
438
|
end
|
|
379
439
|
end
|
|
@@ -10,6 +10,7 @@ const outputFile = path.resolve(
|
|
|
10
10
|
archipelagoDir,
|
|
11
11
|
"registry.generated.<%= @use_typescript ? "ts" : "js" %>"
|
|
12
12
|
)
|
|
13
|
+
const lazy = <%= @lazy_registry ? "true" : "false" %>
|
|
13
14
|
|
|
14
15
|
function walk(dir) {
|
|
15
16
|
if (!fs.existsSync(dir)) {
|
|
@@ -62,16 +63,33 @@ const islandFiles = walk(islandsRoot).sort()
|
|
|
62
63
|
const imports = []
|
|
63
64
|
const registryRows = []
|
|
64
65
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
66
|
+
if (lazy) {
|
|
67
|
+
<% if @use_typescript %>
|
|
68
|
+
imports.push("import { defineIslandLoader, type IslandRegistry } from \"@archipelago-js/react\"")
|
|
69
|
+
<% else %>
|
|
70
|
+
imports.push("import { defineIslandLoader } from \"@archipelago-js/react\"")
|
|
71
|
+
<% end %>
|
|
71
72
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
73
|
+
islandFiles.forEach((absolutePath) => {
|
|
74
|
+
const relativeFromIslands = path.relative(islandsRoot, absolutePath)
|
|
75
|
+
const relativeWithoutExt = relativeFromIslands.replace(/\.[^.]+$/, "")
|
|
76
|
+
const importPath = `../islands/${relativeWithoutExt.split(path.sep).join("/")}`
|
|
77
|
+
const componentName = componentNameFromRelative(relativeWithoutExt)
|
|
78
|
+
|
|
79
|
+
registryRows.push(` "${componentName}": defineIslandLoader(() => import("${importPath}"))`)
|
|
80
|
+
})
|
|
81
|
+
} else {
|
|
82
|
+
islandFiles.forEach((absolutePath, index) => {
|
|
83
|
+
const relativeFromIslands = path.relative(islandsRoot, absolutePath)
|
|
84
|
+
const relativeWithoutExt = relativeFromIslands.replace(/\.[^.]+$/, "")
|
|
85
|
+
const importPath = `../islands/${relativeWithoutExt.split(path.sep).join("/")}`
|
|
86
|
+
const componentName = componentNameFromRelative(relativeWithoutExt)
|
|
87
|
+
const importName = `Island${index + 1}`
|
|
88
|
+
|
|
89
|
+
imports.push(`import ${importName} from "${importPath}"`)
|
|
90
|
+
registryRows.push(` "${componentName}": ${importName}`)
|
|
91
|
+
})
|
|
92
|
+
}
|
|
75
93
|
|
|
76
94
|
const source = [
|
|
77
95
|
"// This file is auto-generated by app/javascript/archipelago/generate_registry.mjs.",
|
|
@@ -80,8 +98,7 @@ const source = [
|
|
|
80
98
|
...imports,
|
|
81
99
|
imports.length > 0 ? "" : "",
|
|
82
100
|
<% if @use_typescript %>
|
|
83
|
-
"import type { IslandRegistry } from \"@archipelago-js/react\"",
|
|
84
|
-
"",
|
|
101
|
+
...(lazy ? [] : ["import type { IslandRegistry } from \"@archipelago-js/react\"", ""]),
|
|
85
102
|
"const registry: IslandRegistry = {",
|
|
86
103
|
<% else %>
|
|
87
104
|
"const registry = {",
|
|
@@ -18,7 +18,7 @@ rescue LoadError
|
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
class ActionTest < ArchipelagoTestCase
|
|
21
|
-
DummyContext = Struct.new(:user, :request, :params, :session)
|
|
21
|
+
DummyContext = Struct.new(:user, :request, :params, :session, :stream)
|
|
22
22
|
|
|
23
23
|
class ForbiddenAction < Archipelago::Action
|
|
24
24
|
authorize { false }
|
|
@@ -133,4 +133,185 @@ class ActionTest < ArchipelagoTestCase
|
|
|
133
133
|
assert_equal "error", payload[:status]
|
|
134
134
|
assert_equal ["Email can't be blank"], payload[:errors]["email"]
|
|
135
135
|
end
|
|
136
|
+
|
|
137
|
+
def test_current_user_delegates_to_ctx_user
|
|
138
|
+
user = Object.new
|
|
139
|
+
action = BroadcastAction.new(
|
|
140
|
+
ctx: DummyContext.new(user, nil, nil, nil),
|
|
141
|
+
raw_params: {}
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
assert_same user, action.send(:current_user)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def test_broadcasts_via_ctx_stream
|
|
148
|
+
captured = nil
|
|
149
|
+
original_broadcast = Archipelago.method(:broadcast)
|
|
150
|
+
previous_verbose, $VERBOSE = $VERBOSE, nil
|
|
151
|
+
|
|
152
|
+
Archipelago.singleton_class.define_method(:broadcast) do |stream_name, props:, version:|
|
|
153
|
+
captured = [stream_name, props, version]
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
begin
|
|
157
|
+
BroadcastAction.new(
|
|
158
|
+
ctx: DummyContext.new(nil, nil, nil, nil, "ctx-stream:42"),
|
|
159
|
+
raw_params: {}
|
|
160
|
+
).call
|
|
161
|
+
ensure
|
|
162
|
+
Archipelago.singleton_class.define_method(:broadcast, original_broadcast)
|
|
163
|
+
$VERBOSE = previous_verbose
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
assert_equal "ctx-stream:42", captured[0]
|
|
167
|
+
assert_equal({ members: [1, 2] }, captured[1])
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def test_ctx_stream_takes_precedence_over_raw_params_stream
|
|
171
|
+
captured = nil
|
|
172
|
+
original_broadcast = Archipelago.method(:broadcast)
|
|
173
|
+
previous_verbose, $VERBOSE = $VERBOSE, nil
|
|
174
|
+
|
|
175
|
+
Archipelago.singleton_class.define_method(:broadcast) do |stream_name, props:, version:|
|
|
176
|
+
captured = stream_name
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
begin
|
|
180
|
+
BroadcastAction.new(
|
|
181
|
+
ctx: DummyContext.new(nil, nil, nil, nil, "ctx-stream:1"),
|
|
182
|
+
raw_params: { __stream: "param-stream:1" }
|
|
183
|
+
).call
|
|
184
|
+
ensure
|
|
185
|
+
Archipelago.singleton_class.define_method(:broadcast, original_broadcast)
|
|
186
|
+
$VERBOSE = previous_verbose
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
assert_equal "ctx-stream:1", captured
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
class PunditAdapterTest < ArchipelagoTestCase
|
|
194
|
+
DummyContext = Struct.new(:user, :request, :params, :session, :stream)
|
|
195
|
+
|
|
196
|
+
DummyUser = Struct.new(:id, :admin)
|
|
197
|
+
|
|
198
|
+
class TeamPolicy
|
|
199
|
+
attr_reader :user, :record
|
|
200
|
+
|
|
201
|
+
def initialize(user, record)
|
|
202
|
+
@user = user
|
|
203
|
+
@record = record
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def update?
|
|
207
|
+
user.admin
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
Team = Struct.new(:name)
|
|
212
|
+
|
|
213
|
+
class UpdateAction < Archipelago::Action
|
|
214
|
+
include Archipelago::PunditAdapter
|
|
215
|
+
|
|
216
|
+
authorize { true }
|
|
217
|
+
|
|
218
|
+
def perform
|
|
219
|
+
authorize(Team.new("test"))
|
|
220
|
+
props ok: true
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def test_pundit_authorize_allows_when_policy_returns_true
|
|
225
|
+
user = DummyUser.new(1, true)
|
|
226
|
+
# TeamPolicy is looked up as "PunditAdapterTest::Team" + "Policy",
|
|
227
|
+
# so we need the class in scope. We define it above.
|
|
228
|
+
stub_const_for_test("PunditAdapterTest::TeamPolicy", TeamPolicy) do
|
|
229
|
+
payload = UpdateAction.new(
|
|
230
|
+
ctx: DummyContext.new(user, nil, nil, nil),
|
|
231
|
+
raw_params: {}
|
|
232
|
+
).call
|
|
233
|
+
|
|
234
|
+
assert_equal "ok", payload[:status]
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def test_pundit_authorize_raises_forbidden_when_policy_returns_false
|
|
239
|
+
user = DummyUser.new(1, false)
|
|
240
|
+
stub_const_for_test("PunditAdapterTest::TeamPolicy", TeamPolicy) do
|
|
241
|
+
payload = UpdateAction.new(
|
|
242
|
+
ctx: DummyContext.new(user, nil, nil, nil),
|
|
243
|
+
raw_params: {}
|
|
244
|
+
).call
|
|
245
|
+
|
|
246
|
+
assert_equal "forbidden", payload[:status]
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
private
|
|
251
|
+
|
|
252
|
+
def stub_const_for_test(_name, _klass, &block)
|
|
253
|
+
yield
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
class CanCanAdapterTest < ArchipelagoTestCase
|
|
258
|
+
DummyContext = Struct.new(:user, :request, :params, :session, :stream)
|
|
259
|
+
|
|
260
|
+
class DummyAbility
|
|
261
|
+
def initialize(user)
|
|
262
|
+
@user = user
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def can?(action, record)
|
|
266
|
+
action == :read
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
class ReadAction < Archipelago::Action
|
|
271
|
+
include Archipelago::CanCanAdapter
|
|
272
|
+
|
|
273
|
+
authorize { true }
|
|
274
|
+
|
|
275
|
+
def perform
|
|
276
|
+
authorize!(:read, Object.new)
|
|
277
|
+
props ok: true
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
class WriteAction < Archipelago::Action
|
|
282
|
+
include Archipelago::CanCanAdapter
|
|
283
|
+
|
|
284
|
+
authorize { true }
|
|
285
|
+
|
|
286
|
+
def perform
|
|
287
|
+
authorize!(:write, Object.new)
|
|
288
|
+
props ok: true
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def test_cancan_authorize_allows_permitted_action
|
|
293
|
+
Archipelago.configure do |config|
|
|
294
|
+
config.current_ability = ->(user) { DummyAbility.new(user) }
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
payload = ReadAction.new(
|
|
298
|
+
ctx: DummyContext.new(nil, nil, nil, nil),
|
|
299
|
+
raw_params: {}
|
|
300
|
+
).call
|
|
301
|
+
|
|
302
|
+
assert_equal "ok", payload[:status]
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def test_cancan_authorize_raises_forbidden_on_denied_action
|
|
306
|
+
Archipelago.configure do |config|
|
|
307
|
+
config.current_ability = ->(user) { DummyAbility.new(user) }
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
payload = WriteAction.new(
|
|
311
|
+
ctx: DummyContext.new(nil, nil, nil, nil),
|
|
312
|
+
raw_params: {}
|
|
313
|
+
).call
|
|
314
|
+
|
|
315
|
+
assert_equal "forbidden", payload[:status]
|
|
316
|
+
end
|
|
136
317
|
end
|
|
@@ -12,4 +12,39 @@ class ChannelTest < ArchipelagoTestCase
|
|
|
12
12
|
refute_match Archipelago::IslandChannel::STREAM_PATTERN, "../etc/passwd"
|
|
13
13
|
refute_match Archipelago::IslandChannel::STREAM_PATTERN, "team members"
|
|
14
14
|
end
|
|
15
|
+
|
|
16
|
+
def test_authorize_stream_returns_true_when_no_authorizer_and_not_required
|
|
17
|
+
Archipelago.configure do |config|
|
|
18
|
+
config.stream_authorizer = nil
|
|
19
|
+
config.require_stream_authorization = false
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
assert Archipelago.authorize_stream?(connection: nil, stream_name: "test:1")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def test_authorize_stream_rejects_all_when_required_but_no_authorizer
|
|
26
|
+
Archipelago.configure do |config|
|
|
27
|
+
config.stream_authorizer = nil
|
|
28
|
+
config.require_stream_authorization = true
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
refute Archipelago.authorize_stream?(connection: nil, stream_name: "test:1")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def test_authorize_stream_calls_authorizer_lambda
|
|
35
|
+
called_with = nil
|
|
36
|
+
Archipelago.configure do |config|
|
|
37
|
+
config.stream_authorizer = ->(connection:, stream_name:, params:) {
|
|
38
|
+
called_with = { connection: connection, stream_name: stream_name, params: params }
|
|
39
|
+
stream_name.start_with?("allowed:")
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
assert Archipelago.authorize_stream?(connection: :conn, stream_name: "allowed:1", params: { a: 1 })
|
|
44
|
+
assert_equal :conn, called_with[:connection]
|
|
45
|
+
assert_equal "allowed:1", called_with[:stream_name]
|
|
46
|
+
assert_equal({ a: 1 }, called_with[:params])
|
|
47
|
+
|
|
48
|
+
refute Archipelago.authorize_stream?(connection: :conn, stream_name: "denied:1")
|
|
49
|
+
end
|
|
15
50
|
end
|
|
@@ -24,7 +24,7 @@ module Islands
|
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
class NotificationsTest < ArchipelagoTestCase
|
|
27
|
-
DummyContext = Struct.new(:user, :request, :params, :session)
|
|
27
|
+
DummyContext = Struct.new(:user, :request, :params, :session, :stream)
|
|
28
28
|
|
|
29
29
|
def test_emits_resolve_notification
|
|
30
30
|
payloads = []
|
|
@@ -15,7 +15,70 @@ class ParamsDSLTest < ArchipelagoTestCase
|
|
|
15
15
|
end
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
class InValidatorAction < Archipelago::Action
|
|
19
|
+
param :role, :string, required: true, in: %w[admin member viewer]
|
|
20
|
+
|
|
21
|
+
authorize { true }
|
|
22
|
+
|
|
23
|
+
def perform
|
|
24
|
+
props role: role
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
class FormatValidatorAction < Archipelago::Action
|
|
29
|
+
param :slug, :string, required: true, format: /\A[a-z0-9-]+\z/
|
|
30
|
+
|
|
31
|
+
authorize { true }
|
|
32
|
+
|
|
33
|
+
def perform
|
|
34
|
+
props slug: slug
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class MinMaxValidatorAction < Archipelago::Action
|
|
39
|
+
param :age, :integer, required: true, min: 0, max: 150
|
|
40
|
+
param :name, :string, required: true, min: 2, max: 50
|
|
41
|
+
|
|
42
|
+
authorize { true }
|
|
43
|
+
|
|
44
|
+
def perform
|
|
45
|
+
props age: age, name: name
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
class EmptyAsNilAction < Archipelago::Action
|
|
50
|
+
param :nickname, :string, empty_as_nil: true
|
|
51
|
+
|
|
52
|
+
authorize { true }
|
|
53
|
+
|
|
54
|
+
def perform
|
|
55
|
+
props nickname: nickname
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
class TypedArrayAction < Archipelago::Action
|
|
60
|
+
param :tag_ids, :array, required: true, of: :integer
|
|
61
|
+
|
|
62
|
+
authorize { true }
|
|
63
|
+
|
|
64
|
+
def perform
|
|
65
|
+
props tag_ids: tag_ids
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
class CustomValidateAction < Archipelago::Action
|
|
70
|
+
param :code, :string, required: true, validate: ->(value) {
|
|
71
|
+
value.length == 6 ? nil : "must be exactly 6 characters"
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
authorize { true }
|
|
75
|
+
|
|
76
|
+
def perform
|
|
77
|
+
props code: code
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
DummyContext = Struct.new(:user, :request, :params, :session, :stream)
|
|
19
82
|
|
|
20
83
|
def test_coerces_and_transforms_params
|
|
21
84
|
payload = ParamAction.new(
|
|
@@ -48,4 +111,144 @@ class ParamsDSLTest < ArchipelagoTestCase
|
|
|
48
111
|
assert_equal "error", payload[:status]
|
|
49
112
|
assert_equal ["is invalid"], payload[:errors]["team_id"]
|
|
50
113
|
end
|
|
114
|
+
|
|
115
|
+
def test_in_validator_accepts_valid_value
|
|
116
|
+
payload = InValidatorAction.new(
|
|
117
|
+
ctx: DummyContext.new(nil, nil, nil, nil),
|
|
118
|
+
raw_params: { "role" => "admin" }
|
|
119
|
+
).call
|
|
120
|
+
|
|
121
|
+
assert_equal "ok", payload[:status]
|
|
122
|
+
assert_equal "admin", payload[:props][:role]
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def test_in_validator_rejects_invalid_value
|
|
126
|
+
payload = InValidatorAction.new(
|
|
127
|
+
ctx: DummyContext.new(nil, nil, nil, nil),
|
|
128
|
+
raw_params: { "role" => "superuser" }
|
|
129
|
+
).call
|
|
130
|
+
|
|
131
|
+
assert_equal "error", payload[:status]
|
|
132
|
+
assert_equal ["is not included in the list"], payload[:errors]["role"]
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def test_format_validator_accepts_matching_value
|
|
136
|
+
payload = FormatValidatorAction.new(
|
|
137
|
+
ctx: DummyContext.new(nil, nil, nil, nil),
|
|
138
|
+
raw_params: { "slug" => "my-slug-123" }
|
|
139
|
+
).call
|
|
140
|
+
|
|
141
|
+
assert_equal "ok", payload[:status]
|
|
142
|
+
assert_equal "my-slug-123", payload[:props][:slug]
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def test_format_validator_rejects_non_matching_value
|
|
146
|
+
payload = FormatValidatorAction.new(
|
|
147
|
+
ctx: DummyContext.new(nil, nil, nil, nil),
|
|
148
|
+
raw_params: { "slug" => "INVALID SLUG!" }
|
|
149
|
+
).call
|
|
150
|
+
|
|
151
|
+
assert_equal "error", payload[:status]
|
|
152
|
+
assert_equal ["is invalid"], payload[:errors]["slug"]
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def test_min_max_validator_accepts_in_range
|
|
156
|
+
payload = MinMaxValidatorAction.new(
|
|
157
|
+
ctx: DummyContext.new(nil, nil, nil, nil),
|
|
158
|
+
raw_params: { "age" => "25", "name" => "Alice" }
|
|
159
|
+
).call
|
|
160
|
+
|
|
161
|
+
assert_equal "ok", payload[:status]
|
|
162
|
+
assert_equal 25, payload[:props][:age]
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def test_min_validator_rejects_below_minimum
|
|
166
|
+
payload = MinMaxValidatorAction.new(
|
|
167
|
+
ctx: DummyContext.new(nil, nil, nil, nil),
|
|
168
|
+
raw_params: { "age" => "-1", "name" => "Alice" }
|
|
169
|
+
).call
|
|
170
|
+
|
|
171
|
+
assert_equal "error", payload[:status]
|
|
172
|
+
assert_equal ["is too small"], payload[:errors]["age"]
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def test_max_validator_rejects_above_maximum
|
|
176
|
+
payload = MinMaxValidatorAction.new(
|
|
177
|
+
ctx: DummyContext.new(nil, nil, nil, nil),
|
|
178
|
+
raw_params: { "age" => "200", "name" => "Alice" }
|
|
179
|
+
).call
|
|
180
|
+
|
|
181
|
+
assert_equal "error", payload[:status]
|
|
182
|
+
assert_equal ["is too large"], payload[:errors]["age"]
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def test_min_max_on_string_validates_length
|
|
186
|
+
payload = MinMaxValidatorAction.new(
|
|
187
|
+
ctx: DummyContext.new(nil, nil, nil, nil),
|
|
188
|
+
raw_params: { "age" => "25", "name" => "A" }
|
|
189
|
+
).call
|
|
190
|
+
|
|
191
|
+
assert_equal "error", payload[:status]
|
|
192
|
+
assert_equal ["is too small"], payload[:errors]["name"]
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def test_empty_as_nil_treats_blank_string_as_nil
|
|
196
|
+
payload = EmptyAsNilAction.new(
|
|
197
|
+
ctx: DummyContext.new(nil, nil, nil, nil),
|
|
198
|
+
raw_params: { "nickname" => " " }
|
|
199
|
+
).call
|
|
200
|
+
|
|
201
|
+
assert_equal "ok", payload[:status]
|
|
202
|
+
assert_nil payload[:props][:nickname]
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def test_empty_as_nil_preserves_non_empty_string
|
|
206
|
+
payload = EmptyAsNilAction.new(
|
|
207
|
+
ctx: DummyContext.new(nil, nil, nil, nil),
|
|
208
|
+
raw_params: { "nickname" => "Bob" }
|
|
209
|
+
).call
|
|
210
|
+
|
|
211
|
+
assert_equal "ok", payload[:status]
|
|
212
|
+
assert_equal "Bob", payload[:props][:nickname]
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def test_typed_array_coerces_elements
|
|
216
|
+
payload = TypedArrayAction.new(
|
|
217
|
+
ctx: DummyContext.new(nil, nil, nil, nil),
|
|
218
|
+
raw_params: { "tag_ids" => ["1", "2", "3"] }
|
|
219
|
+
).call
|
|
220
|
+
|
|
221
|
+
assert_equal "ok", payload[:status]
|
|
222
|
+
assert_equal [1, 2, 3], payload[:props][:tag_ids]
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def test_typed_array_rejects_invalid_elements
|
|
226
|
+
payload = TypedArrayAction.new(
|
|
227
|
+
ctx: DummyContext.new(nil, nil, nil, nil),
|
|
228
|
+
raw_params: { "tag_ids" => ["1", "abc", "3"] }
|
|
229
|
+
).call
|
|
230
|
+
|
|
231
|
+
assert_equal "error", payload[:status]
|
|
232
|
+
assert_equal ["is invalid"], payload[:errors]["tag_ids"]
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def test_custom_validate_accepts_valid_value
|
|
236
|
+
payload = CustomValidateAction.new(
|
|
237
|
+
ctx: DummyContext.new(nil, nil, nil, nil),
|
|
238
|
+
raw_params: { "code" => "ABC123" }
|
|
239
|
+
).call
|
|
240
|
+
|
|
241
|
+
assert_equal "ok", payload[:status]
|
|
242
|
+
assert_equal "ABC123", payload[:props][:code]
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def test_custom_validate_rejects_invalid_value
|
|
246
|
+
payload = CustomValidateAction.new(
|
|
247
|
+
ctx: DummyContext.new(nil, nil, nil, nil),
|
|
248
|
+
raw_params: { "code" => "SHORT" }
|
|
249
|
+
).call
|
|
250
|
+
|
|
251
|
+
assert_equal "error", payload[:status]
|
|
252
|
+
assert_equal ["must be exactly 6 characters"], payload[:errors]["code"]
|
|
253
|
+
end
|
|
51
254
|
end
|
|
@@ -41,6 +41,7 @@ class ReactInstallGeneratorTest < Rails::Generators::TestCase
|
|
|
41
41
|
package_json = JSON.parse(File.read(File.join(destination_root, "package.json")))
|
|
42
42
|
assert_equal "node app/javascript/archipelago/generate_registry.mjs", package_json.dig("scripts", "archipelago:registry")
|
|
43
43
|
assert_match(/^node app\/javascript\/archipelago\/generate_registry\.mjs && /, package_json.dig("scripts", "build"))
|
|
44
|
+
assert_file ".npmrc", /@archipelago-js:registry=https:\/\/registry\.npmjs\.org/
|
|
44
45
|
end
|
|
45
46
|
|
|
46
47
|
def test_generates_typescript_entry_when_requested
|
|
@@ -64,4 +65,13 @@ class ReactInstallGeneratorTest < Rails::Generators::TestCase
|
|
|
64
65
|
assert_file "app/javascript/archipelago/entry.jsx", /const registry = \{/
|
|
65
66
|
refute File.exist?(File.join(destination_root, "app/javascript/archipelago/generate_registry.mjs"))
|
|
66
67
|
end
|
|
68
|
+
|
|
69
|
+
def test_does_not_duplicate_scope_registry_when_already_present
|
|
70
|
+
File.write(File.join(destination_root, ".npmrc"), "@archipelago-js:registry=https://registry.npmjs.org\n")
|
|
71
|
+
|
|
72
|
+
run_generator %w[--interactive=false]
|
|
73
|
+
|
|
74
|
+
npmrc = File.read(File.join(destination_root, ".npmrc"))
|
|
75
|
+
assert_equal 1, npmrc.scan("@archipelago-js:registry=").length
|
|
76
|
+
end
|
|
67
77
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: archipelago-rails
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Archipelago
|
|
@@ -116,11 +116,13 @@ files:
|
|
|
116
116
|
- lib/archipelago.rb
|
|
117
117
|
- lib/archipelago/action.rb
|
|
118
118
|
- lib/archipelago/broadcasts.rb
|
|
119
|
+
- lib/archipelago/cancan_adapter.rb
|
|
119
120
|
- lib/archipelago/channel.rb
|
|
120
121
|
- lib/archipelago/configuration.rb
|
|
121
122
|
- lib/archipelago/context.rb
|
|
122
123
|
- lib/archipelago/engine.rb
|
|
123
124
|
- lib/archipelago/params_dsl.rb
|
|
125
|
+
- lib/archipelago/pundit_adapter.rb
|
|
124
126
|
- lib/archipelago/registry.rb
|
|
125
127
|
- lib/archipelago/resolver.rb
|
|
126
128
|
- lib/archipelago/response.rb
|