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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3f916ab9e567301591e63ef33c6c2862e7fd545f27821d37d079c348fdf31489
4
- data.tar.gz: e1a648f35bdd36fee3c0c798989a613663a1bfd7c336757bd73005c60417f10a
3
+ metadata.gz: cc3ff097bdcb50e59b6ace832f59db53b77d1bf2d4d50e10da0595b38804afbd
4
+ data.tar.gz: cd849b78be869b5626e4582ddd8966be6626bce1540dcb7cb2b1613ebf238d03
5
5
  SHA512:
6
- metadata.gz: ca210e1dd474885e9b322881e23ecc264ee038914de111080883e2198dd1a0dfe9f85eb6416ba72b7c6db9700fa61419003f94d6736043192c391ea2c1924317
7
- data.tar.gz: 846fdc9686466a6ad5ca9a98b74ae5ca3ba9286878f5cedbc9b0410a739988558be826d10b2d49282835e6a675e010757263d4051fb35970924937db47689e5a
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
- ## Supported Rails versions
5
+ ## Install
6
+
7
+ Add to your Gemfile:
6
8
 
7
- - Rails `>= 7.1`
9
+ ```ruby
10
+ gem "archipelago-rails"
11
+ ```
8
12
 
9
- ## Development setup
13
+ Then run the install generator:
10
14
 
11
15
  ```bash
12
- bundle install
16
+ rails g archipelago:install
13
17
  ```
14
18
 
15
- ## Run tests
19
+ ### React setup (esbuild)
16
20
 
17
21
  ```bash
18
- # full suite (core + rails integration tests)
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
- ## Rails version matrix (Appraisal)
25
+ This scaffolds frontend bootstrap wiring. Options:
27
26
 
28
27
  ```bash
29
- bundle exec appraisal install
30
- bin/test-appraisal rails-7-1
31
- bin/test-appraisal rails-7-2
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
- ## Notes
33
+ ## Core Concepts
36
34
 
37
- - Controller/generator tests run against an in-test Rails application harness.
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
- ## Host app setup (React + esbuild)
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
- After `rails g archipelago:install`, you can scaffold frontend bootstrap wiring:
51
+ ## Building Actions
43
52
 
44
- ```bash
45
- rails g archipelago:install:react
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
- The generator writes `.npmrc` with:
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
- ```text
51
- @archipelago-js:registry=https://registry.npmjs.org
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
- This keeps installs reliable across npm, Yarn classic, pnpm, and bun.
95
+ ### `current_user`
55
96
 
56
- By default this runs an interactive wizard with auto-detected defaults
57
- (bundler, TypeScript, package manager, and local monorepo path).
97
+ Available in all actions, delegating to the configured user method:
58
98
 
59
- For esbuild apps, the wizard also enables auto-registry by default:
60
- - scans `app/javascript/islands/**/*.{js,jsx,ts,tsx}`
61
- - writes `app/javascript/archipelago/registry.generated.(js|ts)`
62
- - wires `package.json` esbuild scripts to run generator first
99
+ ```ruby
100
+ def perform
101
+ project = current_user.projects.find(project_id)
102
+ # ...
103
+ end
104
+ ```
63
105
 
64
- Useful options:
106
+ ### ActiveRecord::RecordInvalid
65
107
 
66
- ```bash
67
- # disable prompts and use flags only
68
- rails g archipelago:install:react --interactive=false --bundler=esbuild --typescript=true
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
- # force TSX output
71
- rails g archipelago:install:react --typescript=true
170
+ ### Per-action authorization
72
171
 
73
- # disable auto-registry and keep manual registry map in entry file
74
- rails g archipelago:install:react --auto_registry=false
172
+ Every action should define an `authorize` block:
75
173
 
76
- # install npm packages immediately
77
- rails g archipelago:install:react --install
174
+ ```ruby
175
+ class TeamMembers::AddMember < Archipelago::Action
176
+ authorize { current_user.admin? }
78
177
 
79
- # install Archipelago packages from a local monorepo path
80
- rails g archipelago:install:react --install --local-monorepo-path=/absolute/path/to/cdx
178
+ def perform
179
+ # ...
180
+ end
181
+ end
81
182
  ```
82
183
 
83
- Manual refresh command (if needed while a long-running watch is already running):
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
- yarn archipelago:registry
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
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.9.0
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Archipelago