archipelago-rails 0.9.0 → 0.11.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 +300 -48
- data/test/archipelago/resolver_test.rb +9 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cc3ff097bdcb50e59b6ace832f59db53b77d1bf2d4d50e10da0595b38804afbd
|
|
4
|
+
data.tar.gz: cd849b78be869b5626e4582ddd8966be6626bce1540dcb7cb2b1613ebf238d03
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 92f4432fa77ce8ee1442c210a29dbee01c22e6dac9958c7f1652290a1d9e5cde97eff943276e9d755beeff6c601958370d4a6c4c4a601fad1399a133c91df437
|
|
7
|
+
data.tar.gz: 12efedc0b57f6d59fc3a3edca4e08f51c93f3fb07a727f606939c2cca39cdc3106558ceb0a2cc11c5728f38f4f2325d431eb7635e0f338e6f566eba304f5cfe3
|
data/README.md
CHANGED
|
@@ -1,87 +1,339 @@
|
|
|
1
1
|
# archipelago-rails
|
|
2
2
|
|
|
3
|
-
Rails engine for Archipelago server-driven React islands.
|
|
3
|
+
Rails engine for Archipelago — server-driven React islands with Inertia-style props, form handling, and real-time updates via ActionCable.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
Add to your Gemfile:
|
|
6
8
|
|
|
7
|
-
|
|
9
|
+
```ruby
|
|
10
|
+
gem "archipelago-rails"
|
|
11
|
+
```
|
|
8
12
|
|
|
9
|
-
|
|
13
|
+
Then run the install generator:
|
|
10
14
|
|
|
11
15
|
```bash
|
|
12
|
-
|
|
16
|
+
rails g archipelago:install
|
|
13
17
|
```
|
|
14
18
|
|
|
15
|
-
|
|
19
|
+
### React setup (esbuild)
|
|
16
20
|
|
|
17
21
|
```bash
|
|
18
|
-
|
|
19
|
-
bin/test
|
|
20
|
-
|
|
21
|
-
# split tasks
|
|
22
|
-
bundle exec rake test:core
|
|
23
|
-
bundle exec rake test:rails
|
|
22
|
+
rails g archipelago:install:react
|
|
24
23
|
```
|
|
25
24
|
|
|
26
|
-
|
|
25
|
+
This scaffolds frontend bootstrap wiring. Options:
|
|
27
26
|
|
|
28
27
|
```bash
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
bin/test-appraisal rails-8-1
|
|
28
|
+
rails g archipelago:install:react --interactive=false --bundler=esbuild --typescript=true
|
|
29
|
+
rails g archipelago:install:react --lazy_registry # dynamic imports instead of eager
|
|
30
|
+
rails g archipelago:install:react --install # install npm packages immediately
|
|
33
31
|
```
|
|
34
32
|
|
|
35
|
-
##
|
|
33
|
+
## Core Concepts
|
|
36
34
|
|
|
37
|
-
|
|
38
|
-
- JS packages are tested from repository root via `yarn test`.
|
|
35
|
+
Archipelago lets you embed interactive React components ("islands") inside server-rendered Rails views. Each island receives **props from the server** and can call **server-side actions** that return updated props, errors, or redirects.
|
|
39
36
|
|
|
40
|
-
|
|
37
|
+
```
|
|
38
|
+
┌─────────────────────────────────────┐
|
|
39
|
+
│ Rails View │
|
|
40
|
+
│ ┌───────────────────┐ │
|
|
41
|
+
│ │ React Island │ ← props │
|
|
42
|
+
│ │ (TeamMembers) │ │
|
|
43
|
+
│ │ ┌─────────────┐ │ │
|
|
44
|
+
│ │ │ Add Member │──┼─→ Action │
|
|
45
|
+
│ │ │ Form │ │ (server) │
|
|
46
|
+
│ │ └─────────────┘ │ │
|
|
47
|
+
│ └───────────────────┘ │
|
|
48
|
+
└─────────────────────────────────────┘
|
|
49
|
+
```
|
|
41
50
|
|
|
42
|
-
|
|
51
|
+
## Building Actions
|
|
43
52
|
|
|
44
|
-
|
|
45
|
-
|
|
53
|
+
Actions live in `app/islands/<component>/` and handle requests from island components.
|
|
54
|
+
|
|
55
|
+
### Basic action
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
# app/islands/team_members/add_member.rb
|
|
59
|
+
class TeamMembers::AddMember < Archipelago::Action
|
|
60
|
+
param :team_id, :integer, required: true
|
|
61
|
+
param :email, :string, required: true, strip: true, downcase: true
|
|
62
|
+
|
|
63
|
+
authorize { current_user.teams.exists?(id: team_id) }
|
|
64
|
+
|
|
65
|
+
def perform
|
|
66
|
+
team = current_user.teams.find(team_id)
|
|
67
|
+
team.members.create!(email: email)
|
|
68
|
+
|
|
69
|
+
props(
|
|
70
|
+
members: team.members.map { |m| { id: m.id, email: m.email } }
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
46
74
|
```
|
|
47
75
|
|
|
48
|
-
|
|
76
|
+
### Action lifecycle
|
|
77
|
+
|
|
78
|
+
1. **Param coercion** — declared params are validated and coerced into typed accessors
|
|
79
|
+
2. **Authorization** — the `authorize` block runs (raises `Forbidden` on failure)
|
|
80
|
+
3. **`perform`** — your business logic executes
|
|
81
|
+
4. **Response** — returns `ok` (with props), `error` (with field errors), `redirect`, or `forbidden`
|
|
82
|
+
|
|
83
|
+
### Response helpers
|
|
49
84
|
|
|
50
|
-
```
|
|
51
|
-
|
|
85
|
+
```ruby
|
|
86
|
+
def perform
|
|
87
|
+
props(members: [...]) # return updated props
|
|
88
|
+
|
|
89
|
+
redirect_to "/teams/#{team_id}" # or redirect
|
|
90
|
+
|
|
91
|
+
add_error(:email, "is already taken") # or add field errors
|
|
92
|
+
end
|
|
52
93
|
```
|
|
53
94
|
|
|
54
|
-
|
|
95
|
+
### `current_user`
|
|
55
96
|
|
|
56
|
-
|
|
57
|
-
(bundler, TypeScript, package manager, and local monorepo path).
|
|
97
|
+
Available in all actions, delegating to the configured user method:
|
|
58
98
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
99
|
+
```ruby
|
|
100
|
+
def perform
|
|
101
|
+
project = current_user.projects.find(project_id)
|
|
102
|
+
# ...
|
|
103
|
+
end
|
|
104
|
+
```
|
|
63
105
|
|
|
64
|
-
|
|
106
|
+
### ActiveRecord::RecordInvalid
|
|
65
107
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
108
|
+
Archipelago automatically catches `ActiveRecord::RecordInvalid` exceptions and maps them to field-level error responses.
|
|
109
|
+
|
|
110
|
+
## Params DSL
|
|
111
|
+
|
|
112
|
+
Declare expected parameters with type coercion, validation, and transformation:
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
class TeamMembers::UpdateSettings < Archipelago::Action
|
|
116
|
+
param :name, :string, required: true, strip: true, min: 2, max: 100
|
|
117
|
+
param :email, :string, required: true, format: /\A[^@\s]+@[^@\s]+\z/
|
|
118
|
+
param :role, :string, required: true, in: %w[admin member viewer]
|
|
119
|
+
param :bio, :string, empty_as_nil: true
|
|
120
|
+
param :age, :integer, min: 13, max: 150
|
|
121
|
+
param :score, :float
|
|
122
|
+
param :active, :boolean, default: true
|
|
123
|
+
param :tags, :array, of: :string
|
|
124
|
+
param :metadata, :json
|
|
125
|
+
param :starts_on, :date
|
|
126
|
+
param :due_at, :datetime
|
|
127
|
+
param :nickname, :string, validate: ->(v) { "is offensive" if offensive?(v) }
|
|
128
|
+
|
|
129
|
+
# Params become methods: name, email, role, bio, etc.
|
|
130
|
+
def perform
|
|
131
|
+
user = current_user
|
|
132
|
+
user.update!(name: name, email: email, role: role, bio: bio)
|
|
133
|
+
props(user: serialize(user))
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### Supported types
|
|
139
|
+
|
|
140
|
+
| Type | Coercion |
|
|
141
|
+
|------|----------|
|
|
142
|
+
| `:string` | `String(value)` |
|
|
143
|
+
| `:integer` | `Integer(value)` |
|
|
144
|
+
| `:float` | `Float(value)` |
|
|
145
|
+
| `:boolean` | `true/1/"1"/"true"/"on"/"yes"` → `true`, etc. |
|
|
146
|
+
| `:date` | `Date.parse(value)` |
|
|
147
|
+
| `:datetime` | `Time.parse(value)` |
|
|
148
|
+
| `:array` | Pass-through or `JSON.parse`, with optional `of:` typed elements |
|
|
149
|
+
| `:json` | Pass-through or `JSON.parse` |
|
|
150
|
+
|
|
151
|
+
### Validation options
|
|
152
|
+
|
|
153
|
+
| Option | Description |
|
|
154
|
+
|--------|-------------|
|
|
155
|
+
| `required: true` | Rejects blank/nil values |
|
|
156
|
+
| `default: value` | Fallback when missing (supports lambdas) |
|
|
157
|
+
| `strip: true` | Strip whitespace (strings only) |
|
|
158
|
+
| `downcase: true` | Downcase (strings only) |
|
|
159
|
+
| `upcase: true` | Upcase (strings only) |
|
|
160
|
+
| `in: [...]` | Value must be in the list |
|
|
161
|
+
| `format: /regex/` | String must match pattern |
|
|
162
|
+
| `min: n` | Minimum value or length |
|
|
163
|
+
| `max: n` | Maximum value or length |
|
|
164
|
+
| `empty_as_nil: true` | Treat `""` / whitespace-only as `nil` |
|
|
165
|
+
| `of: :type` | Element type for arrays |
|
|
166
|
+
| `validate: ->(v) { ... }` | Custom validator; return error string or nil |
|
|
167
|
+
|
|
168
|
+
## Authorization
|
|
69
169
|
|
|
70
|
-
|
|
71
|
-
rails g archipelago:install:react --typescript=true
|
|
170
|
+
### Per-action authorization
|
|
72
171
|
|
|
73
|
-
|
|
74
|
-
rails g archipelago:install:react --auto_registry=false
|
|
172
|
+
Every action should define an `authorize` block:
|
|
75
173
|
|
|
76
|
-
|
|
77
|
-
|
|
174
|
+
```ruby
|
|
175
|
+
class TeamMembers::AddMember < Archipelago::Action
|
|
176
|
+
authorize { current_user.admin? }
|
|
78
177
|
|
|
79
|
-
|
|
80
|
-
|
|
178
|
+
def perform
|
|
179
|
+
# ...
|
|
180
|
+
end
|
|
181
|
+
end
|
|
81
182
|
```
|
|
82
183
|
|
|
83
|
-
|
|
184
|
+
When `authorize_by_default` is `true` (the default), actions without an `authorize` block raise `MissingAuthorization`.
|
|
185
|
+
|
|
186
|
+
### Pundit adapter
|
|
187
|
+
|
|
188
|
+
Include `Archipelago::PunditAdapter` for Pundit-style authorization:
|
|
189
|
+
|
|
190
|
+
```ruby
|
|
191
|
+
class TeamMembers::AddMember < Archipelago::Action
|
|
192
|
+
include Archipelago::PunditAdapter
|
|
193
|
+
|
|
194
|
+
param :team_id, :integer, required: true
|
|
195
|
+
|
|
196
|
+
def perform
|
|
197
|
+
team = current_user.teams.find(team_id)
|
|
198
|
+
authorize(team) # infers query from action class name
|
|
199
|
+
team.members.create!(email: email)
|
|
200
|
+
props(members: team.members.as_json)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
The adapter provides:
|
|
206
|
+
- `authorize(record, query = nil)` — raises `Forbidden` if policy denies
|
|
207
|
+
- `policy(record)` — returns the policy instance
|
|
208
|
+
|
|
209
|
+
### CanCan adapter
|
|
210
|
+
|
|
211
|
+
Include `Archipelago::CanCanAdapter` for CanCan-style authorization:
|
|
212
|
+
|
|
213
|
+
```ruby
|
|
214
|
+
class TeamMembers::AddMember < Archipelago::Action
|
|
215
|
+
include Archipelago::CanCanAdapter
|
|
216
|
+
|
|
217
|
+
param :team_id, :integer, required: true
|
|
218
|
+
|
|
219
|
+
def perform
|
|
220
|
+
team = current_user.teams.find(team_id)
|
|
221
|
+
authorize!(:manage, team)
|
|
222
|
+
team.members.create!(email: email)
|
|
223
|
+
props(members: team.members.as_json)
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
The adapter provides:
|
|
229
|
+
- `authorize!(action, record)` — raises `Forbidden` if ability denies
|
|
230
|
+
- `current_ability` — returns the ability instance
|
|
231
|
+
|
|
232
|
+
Configure the ability builder if you don't use a top-level `Ability` class:
|
|
233
|
+
|
|
234
|
+
```ruby
|
|
235
|
+
Archipelago.configure do |config|
|
|
236
|
+
config.current_ability = ->(user) { CustomAbility.new(user) }
|
|
237
|
+
end
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
## Stream Authorization
|
|
241
|
+
|
|
242
|
+
ActionCable streams can be authorized before subscription:
|
|
243
|
+
|
|
244
|
+
```ruby
|
|
245
|
+
Archipelago.configure do |config|
|
|
246
|
+
config.stream_authorizer = ->(connection:, stream_name:, params:) {
|
|
247
|
+
user = connection.current_user
|
|
248
|
+
# stream_name is e.g. "TeamMembers:42"
|
|
249
|
+
team_id = stream_name.split(":").last.to_i
|
|
250
|
+
user.teams.exists?(id: team_id)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
# Reject all streams that don't pass through the authorizer
|
|
254
|
+
config.require_stream_authorization = true
|
|
255
|
+
end
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
When `require_stream_authorization` is `true`, any stream without a configured authorizer is rejected. When `false` (default), streams are allowed unless an authorizer explicitly denies them.
|
|
259
|
+
|
|
260
|
+
**Important:** If your streams carry tenant-specific or user-specific data, always configure a `stream_authorizer` or enable `require_stream_authorization`.
|
|
261
|
+
|
|
262
|
+
## Configuration
|
|
263
|
+
|
|
264
|
+
```ruby
|
|
265
|
+
# config/initializers/archipelago.rb
|
|
266
|
+
Archipelago.configure do |config|
|
|
267
|
+
config.root_namespace = "Islands" # where actions live under app/islands/
|
|
268
|
+
config.current_user_method = :current_user # controller method for current user
|
|
269
|
+
config.authorize_by_default = true # require authorize blocks
|
|
270
|
+
config.strict_origin_check = false # validate redirect origins
|
|
271
|
+
config.allowed_redirect_hosts = [] # allowed redirect hosts
|
|
272
|
+
config.stream_authorizer = nil # ActionCable stream auth lambda
|
|
273
|
+
config.require_stream_authorization = false # reject unauthed streams
|
|
274
|
+
config.current_ability = nil # CanCan ability builder
|
|
275
|
+
end
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
## Response Contract
|
|
279
|
+
|
|
280
|
+
All action responses follow a standard JSON shape:
|
|
281
|
+
|
|
282
|
+
```json
|
|
283
|
+
// ok — updated props
|
|
284
|
+
{ "status": "ok", "props": { ... }, "version": 1716000000000 }
|
|
285
|
+
|
|
286
|
+
// error — field-level validation errors
|
|
287
|
+
{ "status": "error", "errors": { "email": ["can't be blank"] } }
|
|
288
|
+
|
|
289
|
+
// redirect
|
|
290
|
+
{ "status": "redirect", "location": "/teams/1" }
|
|
291
|
+
|
|
292
|
+
// forbidden
|
|
293
|
+
{ "status": "forbidden" }
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
The `version` field is a monotonic timestamp used by the client to prevent stale broadcasts from overwriting newer data.
|
|
297
|
+
|
|
298
|
+
## Streams & Broadcasting
|
|
299
|
+
|
|
300
|
+
When a client sends the `X-Archipelago-Stream` header (or the legacy `__stream` param), successful action responses are automatically broadcast to all subscribers of that stream.
|
|
301
|
+
|
|
302
|
+
On the client side, `useIslandProps({ stream: "TeamMembers:42" })` auto-subscribes to the stream and merges broadcast props into the component.
|
|
303
|
+
|
|
304
|
+
## Supported Rails versions
|
|
305
|
+
|
|
306
|
+
- Rails `>= 7.1`, `< 9.0`
|
|
307
|
+
|
|
308
|
+
## Development
|
|
309
|
+
|
|
310
|
+
```bash
|
|
311
|
+
bundle install
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
### Run tests
|
|
315
|
+
|
|
316
|
+
```bash
|
|
317
|
+
bin/test # full suite
|
|
318
|
+
bundle exec rake test:core # core unit tests
|
|
319
|
+
bundle exec rake test:rails # rails integration tests
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
### Rails version matrix (Appraisal)
|
|
84
323
|
|
|
85
324
|
```bash
|
|
86
|
-
|
|
325
|
+
bundle exec appraisal install
|
|
326
|
+
bin/test-appraisal rails-7-1
|
|
327
|
+
bin/test-appraisal rails-7-2
|
|
328
|
+
bin/test-appraisal rails-8-1
|
|
87
329
|
```
|
|
330
|
+
|
|
331
|
+
## Stability
|
|
332
|
+
|
|
333
|
+
This library follows [Semantic Versioning](https://semver.org/). The public API surface — `Archipelago::Action`, the Params DSL, `authorize`, response helpers (`props`, `redirect_to`, `add_error`), configuration options, stream authorization, and the Pundit/CanCan adapter interfaces — is considered stable. Breaking changes will only occur in major version bumps.
|
|
334
|
+
|
|
335
|
+
Internal modules, resolver internals, and the `raw_params` hash shape are not part of the public contract and may change in minor releases.
|
|
336
|
+
|
|
337
|
+
## License
|
|
338
|
+
|
|
339
|
+
MIT
|
|
@@ -77,4 +77,13 @@ class ResolverTest < ArchipelagoTestCase
|
|
|
77
77
|
Archipelago::Resolver.new.resolve(component: "UnknownIsland", operation: "add_member")
|
|
78
78
|
end
|
|
79
79
|
end
|
|
80
|
+
|
|
81
|
+
def test_rejects_handler_that_does_not_inherit_from_action
|
|
82
|
+
not_an_action = Class.new
|
|
83
|
+
Archipelago.map "TeamMembers#not_action" => not_an_action
|
|
84
|
+
|
|
85
|
+
assert_raises(Archipelago::ResolutionError) do
|
|
86
|
+
Archipelago::Resolver.new.resolve(component: "TeamMembers", operation: "not_action")
|
|
87
|
+
end
|
|
88
|
+
end
|
|
80
89
|
end
|