zero-rails-adapter 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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +344 -0
- data/app/controllers/zero_rails_adapter/mutations_controller.rb +74 -0
- data/config/routes.rb +7 -0
- data/examples/nextjs/README.md +29 -0
- data/examples/nextjs/src/zero/mutators.ts +37 -0
- data/examples/nextjs/src/zero/schema.ts +20 -0
- data/lib/generators/zero_rails_adapter/install/install_generator.rb +15 -0
- data/lib/generators/zero_rails_adapter/install/templates/initializer.rb +52 -0
- data/lib/generators/zero_rails_adapter/mutator/mutator_generator.rb +31 -0
- data/lib/generators/zero_rails_adapter/mutator/templates/mutator.rb +14 -0
- data/lib/generators/zero_rails_adapter/typescript/typescript_generator.rb +18 -0
- data/lib/zero_rails_adapter/configuration.rb +57 -0
- data/lib/zero_rails_adapter/context.rb +19 -0
- data/lib/zero_rails_adapter/crud/dispatcher.rb +121 -0
- data/lib/zero_rails_adapter/engine.rb +7 -0
- data/lib/zero_rails_adapter/errors.rb +50 -0
- data/lib/zero_rails_adapter/identity.rb +9 -0
- data/lib/zero_rails_adapter/mutation.rb +50 -0
- data/lib/zero_rails_adapter/mutator.rb +70 -0
- data/lib/zero_rails_adapter/processor.rb +178 -0
- data/lib/zero_rails_adapter/registry.rb +51 -0
- data/lib/zero_rails_adapter/request.rb +93 -0
- data/lib/zero_rails_adapter/request_verifiers/api_key.rb +21 -0
- data/lib/zero_rails_adapter/storage/zero_schema.rb +95 -0
- data/lib/zero_rails_adapter/type_script/generator.rb +421 -0
- data/lib/zero_rails_adapter/version.rb +5 -0
- data/lib/zero_rails_adapter.rb +50 -0
- metadata +89 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 15ffa084c77f3af139ecfc0e284d2b2d41265357ec3b3edafda78d5c7378c8b8
|
|
4
|
+
data.tar.gz: a6aaafe3bb4cca20cfd6544ad6fd63f49bca923ebd225edb0811069a31c3c778
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: cdb046e65aa33933cdb5e33ede0f484275e7734ad637cbe87d298846a5636d79c4a1b5188d390551224c9d660c7dc8ab3bd4a48dfd54571f61c54520a895a1f0
|
|
7
|
+
data.tar.gz: b3b4149d75a0519b307d7464fe48da60c77eca56a71283791f858151d518f164245e3027310347c342d57d18f03729afb9d2fc66a5a0e20d947ea2807d1d5195
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zero Rails Adapter contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
# Zero Rails Adapter
|
|
2
|
+
|
|
3
|
+
`zero-rails-adapter` is a mountable Rails Engine that maps Rocicorp Zero custom
|
|
4
|
+
mutations directly to existing Active Record models.
|
|
5
|
+
|
|
6
|
+
The default CRUD path does not require a Ruby Mutator class for every model:
|
|
7
|
+
|
|
8
|
+
| Zero mutation | Rails call |
|
|
9
|
+
| --- | --- |
|
|
10
|
+
| `articles.create` / `articles.insert` | `Article.create!(attributes)` |
|
|
11
|
+
| `articles.update` | `Article.find(...).update!(attributes)` |
|
|
12
|
+
| `articles.destroy` / `articles.delete` | `Article.find(...).destroy!` |
|
|
13
|
+
|
|
14
|
+
Existing Active Record validations, callbacks, associations, `after_commit`
|
|
15
|
+
jobs, database constraints, and transaction behavior therefore work without
|
|
16
|
+
adapter-specific wrappers. Explicit Mutators are reserved for non-CRUD domain
|
|
17
|
+
commands, aggregate operations, and other custom behavior.
|
|
18
|
+
|
|
19
|
+
The gem does not depend on Devise, a JWT implementation, Pundit, or another
|
|
20
|
+
authorization framework. Authentication, authorization, model exposure, and
|
|
21
|
+
mass-assignment policies are all configurable callable interfaces.
|
|
22
|
+
|
|
23
|
+
## Requirements
|
|
24
|
+
|
|
25
|
+
- Ruby 4.0 or newer
|
|
26
|
+
- Rails 8.0 or newer
|
|
27
|
+
- PostgreSQL, the upstream database supported by Zero
|
|
28
|
+
- Current `@rocicorp/zero` and `zero-cache` releases
|
|
29
|
+
|
|
30
|
+
The Zero-managed `schema.clients` and `schema.mutations` tables and the
|
|
31
|
+
application tables must use the same database connection. In a Rails
|
|
32
|
+
multi-database application, configure `transaction_class` with the abstract
|
|
33
|
+
Active Record base class connected to that PostgreSQL database.
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
Add the gem:
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
# Gemfile
|
|
41
|
+
gem "zero-rails-adapter"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Install the bundle and initializer:
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
bundle install
|
|
48
|
+
bin/rails generate zero_rails_adapter:install
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Mount the Engine:
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
# config/routes.rb
|
|
55
|
+
Rails.application.routes.draw do
|
|
56
|
+
mount ZeroRailsAdapter::Engine => "/zero"
|
|
57
|
+
end
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Configure zero-cache:
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
ZERO_MUTATE_URL="https://api.example.com/zero/mutate"
|
|
64
|
+
ZERO_MUTATE_API_KEY="replace-with-a-long-random-secret"
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The Engine accepts `POST /mutate`, `POST /push`, and `POST /` beneath its mount
|
|
68
|
+
point. `/mutate` is the recommended endpoint.
|
|
69
|
+
|
|
70
|
+
The gem intentionally installs no tracking-table migration. zero-cache creates
|
|
71
|
+
the `clients` and `mutations` tables in its shard schema, such as `zero_0`.
|
|
72
|
+
The adapter uses Zero's validated `schema` parameter to build schema-qualified
|
|
73
|
+
Active Record classes for those tables, then writes application data and the
|
|
74
|
+
LMID in the same transaction.
|
|
75
|
+
|
|
76
|
+
## Generic Active Record CRUD
|
|
77
|
+
|
|
78
|
+
This client-side mutation:
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
await mutators.articles.create({
|
|
82
|
+
id: crypto.randomUUID(),
|
|
83
|
+
title: 'Rails and Zero',
|
|
84
|
+
})
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
reaches the Engine as `articles.create`. The adapter resolves `Article` from
|
|
88
|
+
the table name, applies the writable-attribute policy, and calls:
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
Article.create!(id: "...", title: "Rails and Zero")
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
For `update` and `destroy`, the adapter first loads the record using the
|
|
95
|
+
model's primary key, including composite primary keys, and then calls
|
|
96
|
+
`update!` or `destroy!`. The bang methods are intentional: a validation,
|
|
97
|
+
callback, or database-constraint failure rolls back the complete business
|
|
98
|
+
transaction and produces a structured Zero application error.
|
|
99
|
+
|
|
100
|
+
Configure the models exposed to generic CRUD explicitly:
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
ZeroRailsAdapter.configure do |config|
|
|
104
|
+
config.model_provider = -> { [Article, Comment, Project] }
|
|
105
|
+
end
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`model_provider` is both the CRUD allowlist and the source used by the
|
|
109
|
+
TypeScript generator. By default, the adapter eager-loads the Rails application
|
|
110
|
+
and returns every named, non-abstract Active Record model outside the adapter
|
|
111
|
+
itself. An explicit allowlist is recommended in production.
|
|
112
|
+
|
|
113
|
+
If a table cannot be resolved through Rails naming conventions, replace the
|
|
114
|
+
resolver:
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
config.model_resolver = ->(table_name) { LegacyModels.fetch(table_name) }
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
The resolver must return a non-abstract Active Record class.
|
|
121
|
+
|
|
122
|
+
## Generate Zero TypeScript from Rails Models
|
|
123
|
+
|
|
124
|
+
The frontend schema and generic CRUD mutators do not need to be maintained by
|
|
125
|
+
hand:
|
|
126
|
+
|
|
127
|
+
```sh
|
|
128
|
+
bin/rails generate zero_rails_adapter:typescript app/javascript/zero
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
The generator reflects on the models returned by `model_provider` in the Rails
|
|
132
|
+
runtime and writes:
|
|
133
|
+
|
|
134
|
+
- `schema.ts`, containing tables, columns, nullability, primary keys, and
|
|
135
|
+
safely inferred `belongs_to`, `has_one`, and `has_many` relationships.
|
|
136
|
+
- `mutators.ts`, containing `create`, `update`, and `destroy` for every table,
|
|
137
|
+
using Zero's current `defineMutator` / `defineMutators` API and Zod argument
|
|
138
|
+
schemas.
|
|
139
|
+
|
|
140
|
+
Run the generator again after changing Rails migrations or model associations.
|
|
141
|
+
The output can live in Rails' JavaScript directory or be written directly into
|
|
142
|
+
an adjacent Next.js application:
|
|
143
|
+
|
|
144
|
+
```sh
|
|
145
|
+
bin/rails generate zero_rails_adapter:typescript ../web/src/zero
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
The default mappings follow Zero's PostgreSQL type conventions:
|
|
149
|
+
|
|
150
|
+
- string/text/uuid → `string()`
|
|
151
|
+
- integer/bigint/float/decimal → `number()`
|
|
152
|
+
- boolean → `boolean()`
|
|
153
|
+
- date/time/datetime/timestamp → `number()`
|
|
154
|
+
- json/jsonb → `json()`
|
|
155
|
+
- Active Record enum → `enumeration<...>()`
|
|
156
|
+
|
|
157
|
+
Nullable columns use `.optional()`. Rails timestamps use `Date.now()` for the
|
|
158
|
+
optimistic client write and remain managed normally by Rails on the server.
|
|
159
|
+
The generator raises a descriptive error for a column that cannot be mapped
|
|
160
|
+
reliably instead of emitting an incorrect type.
|
|
161
|
+
|
|
162
|
+
`generated_attributes` controls which fields appear in generated create and
|
|
163
|
+
update argument schemas:
|
|
164
|
+
|
|
165
|
+
```ruby
|
|
166
|
+
config.generated_attributes = lambda do |model, action|
|
|
167
|
+
case model.name
|
|
168
|
+
when "User" then %w[id name avatar_url]
|
|
169
|
+
else model.column_names - %w[admin internal_state]
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
This is a static code-generation policy and must not depend on the current
|
|
175
|
+
user. Runtime permissions must still be enforced through the authorization
|
|
176
|
+
and writable-attribute interfaces below.
|
|
177
|
+
|
|
178
|
+
## Authentication, Authorization, and Attribute Policies
|
|
179
|
+
|
|
180
|
+
The generated initializer supports `ZERO_MUTATE_API_KEY` and verifies
|
|
181
|
+
zero-cache's `X-Api-Key` header using a constant-time comparison.
|
|
182
|
+
|
|
183
|
+
The authentication interface receives the Rails request. It can integrate with
|
|
184
|
+
Devise/Warden, any JWT library, a session, or an application-specific
|
|
185
|
+
authentication system:
|
|
186
|
+
|
|
187
|
+
```ruby
|
|
188
|
+
ZeroRailsAdapter.configure do |config|
|
|
189
|
+
config.authenticator = lambda do |request|
|
|
190
|
+
user = request.env["warden"]&.user
|
|
191
|
+
raise ZeroRailsAdapter::UnauthorizedError, "Sign in required" unless user
|
|
192
|
+
|
|
193
|
+
ZeroRailsAdapter::Identity.new(
|
|
194
|
+
user_id: user.id.to_s,
|
|
195
|
+
current_user: user,
|
|
196
|
+
claims: {"role" => user.role}
|
|
197
|
+
)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
The global `authorizer` runs first inside every mutation transaction. The CRUD
|
|
203
|
+
authorizer then receives the model class as `target` for create, or the loaded
|
|
204
|
+
record for update and destroy:
|
|
205
|
+
|
|
206
|
+
```ruby
|
|
207
|
+
config.crud_authorizer = lambda do |context, action, target, attributes|
|
|
208
|
+
MutationPolicy.new(
|
|
209
|
+
context.current_user,
|
|
210
|
+
action,
|
|
211
|
+
target,
|
|
212
|
+
attributes
|
|
213
|
+
).allowed?
|
|
214
|
+
end
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
The server-side mass-assignment allowlist can vary by model, action, and
|
|
218
|
+
authenticated identity:
|
|
219
|
+
|
|
220
|
+
```ruby
|
|
221
|
+
config.writable_attributes = lambda do |model, action, context|
|
|
222
|
+
MutationPolicy.new(context.current_user, action, model).permitted_attributes
|
|
223
|
+
end
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
The default excludes readonly attributes, the STI inheritance column, and
|
|
227
|
+
Rails timestamps. Attributes outside the allowlist are not assigned. Client
|
|
228
|
+
mutation arguments must never be treated as identity or trusted authorization
|
|
229
|
+
data.
|
|
230
|
+
|
|
231
|
+
Return `false` or raise `ZeroRailsAdapter::ForbiddenError` to reject a
|
|
232
|
+
mutation. Raise `UnauthorizedError` for an authentication failure. A request
|
|
233
|
+
verifier that returns `false` also produces HTTP 401.
|
|
234
|
+
|
|
235
|
+
## Custom Domain Mutators
|
|
236
|
+
|
|
237
|
+
Use an explicit Mutator only when ordinary model CRUD cannot express the
|
|
238
|
+
operation, such as publishing an article with an audit event, issuing a command
|
|
239
|
+
across multiple aggregates, or invoking an external service:
|
|
240
|
+
|
|
241
|
+
```sh
|
|
242
|
+
bin/rails generate zero_rails_adapter:mutator articles.publish
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
```ruby
|
|
246
|
+
class Articles::PublishMutator < ZeroRailsAdapter::Mutator
|
|
247
|
+
mutation_name "articles.publish"
|
|
248
|
+
|
|
249
|
+
attribute :id, :string
|
|
250
|
+
validates :id, presence: true
|
|
251
|
+
|
|
252
|
+
authorize_with do |context|
|
|
253
|
+
context.current_user.present?
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def perform
|
|
257
|
+
ArticlePublishing.call(Article.find(id), actor: context.current_user)
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
An explicitly registered Mutator takes precedence over generic CRUD, so an
|
|
263
|
+
application may intentionally override `articles.create`. The Ruby DSL is also
|
|
264
|
+
available:
|
|
265
|
+
|
|
266
|
+
```ruby
|
|
267
|
+
ZeroRailsAdapter.define_mutator "projects.archive" do
|
|
268
|
+
attribute :id, :string
|
|
269
|
+
|
|
270
|
+
perform do
|
|
271
|
+
Project.find(id).archive!(actor: context.current_user)
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
Mutators use Active Model attributes and validations and share a transaction
|
|
277
|
+
with application writes and LMID tracking.
|
|
278
|
+
|
|
279
|
+
## Mutation Ordering and Transaction Semantics
|
|
280
|
+
|
|
281
|
+
For each `(schema, clientGroupID, clientID)`, the adapter:
|
|
282
|
+
|
|
283
|
+
1. Locks or creates the Zero client tracking row.
|
|
284
|
+
2. Rejects a mutation ID above the next expected ID.
|
|
285
|
+
3. Skips a mutation ID that has already been processed.
|
|
286
|
+
4. Executes the authorized Active Record operation inside a transaction.
|
|
287
|
+
5. Updates the LMID atomically.
|
|
288
|
+
|
|
289
|
+
When a business validation or callback fails, the first transaction rolls
|
|
290
|
+
back. A second transaction advances the LMID and stores a structured `app`
|
|
291
|
+
result so that a bad mutation is not retried forever and later mutations can
|
|
292
|
+
continue. If the second transaction also fails, the adapter returns a
|
|
293
|
+
retry-safe `PushFailed` response.
|
|
294
|
+
|
|
295
|
+
Each mutation owns an independent transaction. A later failure in the same
|
|
296
|
+
HTTP batch does not roll back earlier committed mutations.
|
|
297
|
+
`_zero_cleanupResults` removes acknowledged error results without advancing
|
|
298
|
+
the LMID.
|
|
299
|
+
|
|
300
|
+
## Observability
|
|
301
|
+
|
|
302
|
+
Every non-internal mutation emits an Active Support notification:
|
|
303
|
+
|
|
304
|
+
```ruby
|
|
305
|
+
ActiveSupport::Notifications.subscribe("mutation.zero_rails_adapter") do |event|
|
|
306
|
+
Rails.logger.info(
|
|
307
|
+
name: event.payload[:name],
|
|
308
|
+
client_id: event.payload[:client_id],
|
|
309
|
+
duration_ms: event.duration
|
|
310
|
+
)
|
|
311
|
+
end
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
The payload also contains `mutation_id`, `client_group_id`, `app_id`, and
|
|
315
|
+
`schema`.
|
|
316
|
+
|
|
317
|
+
## Development and Testing
|
|
318
|
+
|
|
319
|
+
```sh
|
|
320
|
+
bundle install
|
|
321
|
+
bundle exec rake test
|
|
322
|
+
bundle exec gem build zero-rails-adapter.gemspec
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
The suite covers the mountable Engine endpoint, Zero protocol ordering and
|
|
326
|
+
error semantics, dynamic CRUD dispatch, Active Record validations, callbacks,
|
|
327
|
+
dependent destroy behavior, authorization hooks, TypeScript generation, and a
|
|
328
|
+
PostgreSQL contract test using Zero's exact tracking-table structure.
|
|
329
|
+
|
|
330
|
+
Run the complete contract suite against a dedicated PostgreSQL test database:
|
|
331
|
+
|
|
332
|
+
```sh
|
|
333
|
+
DATABASE_URL=postgres://localhost/zero_rails_adapter_test bundle exec rake test
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
See [`examples/nextjs`](examples/nextjs) for a Next.js integration fixture.
|
|
337
|
+
|
|
338
|
+
## References
|
|
339
|
+
|
|
340
|
+
- [Zero custom mutators](https://zero.rocicorp.dev/docs/mutators)
|
|
341
|
+
- [Zero schema](https://zero.rocicorp.dev/docs/schema)
|
|
342
|
+
- [Zero PostgreSQL support](https://zero.rocicorp.dev/docs/postgres-support)
|
|
343
|
+
- [Zero `process-mutations.ts`](https://github.com/rocicorp/mono/blob/main/packages/zero-server/src/process-mutations.ts)
|
|
344
|
+
- [Server implementation plan](https://jeremykreutzbender.com/blog/server-implementation-plan-rocicorp-zero-custom-mutators)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZeroRailsAdapter
|
|
4
|
+
class MutationsController < ActionController::API
|
|
5
|
+
def create
|
|
6
|
+
verify_request!
|
|
7
|
+
identity = authenticate!
|
|
8
|
+
zero_request = Request.parse(parsed_body, query: request.query_parameters)
|
|
9
|
+
context = Context.new(identity:, request: zero_request, rack_request: request)
|
|
10
|
+
|
|
11
|
+
render json: Processor.new(request: zero_request, context:).call, status: :ok
|
|
12
|
+
rescue JSON::ParserError => error
|
|
13
|
+
render json: push_failed("parse", "Failed to parse push body: #{error.message}", []),
|
|
14
|
+
status: :ok
|
|
15
|
+
rescue ParseError => error
|
|
16
|
+
subject = error.source == :query ? "push query parameters" : "push body"
|
|
17
|
+
render json: push_failed("parse", "Failed to parse #{subject}: #{error.message}", error.mutation_ids),
|
|
18
|
+
status: :ok
|
|
19
|
+
rescue UnsupportedPushVersionError => error
|
|
20
|
+
render json: push_failed("unsupportedPushVersion", error.message, error.mutation_ids),
|
|
21
|
+
status: :ok
|
|
22
|
+
rescue UnauthorizedError => error
|
|
23
|
+
render json: authentication_error(error), status: :unauthorized
|
|
24
|
+
rescue ForbiddenError => error
|
|
25
|
+
render json: authentication_error(error), status: :forbidden
|
|
26
|
+
rescue StandardError => error
|
|
27
|
+
ZeroRailsAdapter.configuration.logger&.error(
|
|
28
|
+
"ZeroRailsAdapter request failed: #{error.class}: #{error.message}"
|
|
29
|
+
)
|
|
30
|
+
render json: push_failed("internal", error.message, []), status: :internal_server_error
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def parsed_body
|
|
36
|
+
JSON.parse(request.raw_post)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def verify_request!
|
|
40
|
+
verified = ZeroRailsAdapter.configuration.request_verifier.call(request)
|
|
41
|
+
raise UnauthorizedError, "Zero request verification failed" unless verified
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def authenticate!
|
|
45
|
+
value = ZeroRailsAdapter.configuration.authenticator.call(request)
|
|
46
|
+
return value if value.is_a?(Identity)
|
|
47
|
+
|
|
48
|
+
attributes = value.respond_to?(:to_h) ? value.to_h.symbolize_keys : {}
|
|
49
|
+
Identity.new(
|
|
50
|
+
user_id: attributes[:user_id],
|
|
51
|
+
current_user: attributes[:current_user],
|
|
52
|
+
claims: attributes[:claims] || {}
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def push_failed(reason, message, mutation_ids)
|
|
57
|
+
{
|
|
58
|
+
"kind" => "PushFailed",
|
|
59
|
+
"origin" => "server",
|
|
60
|
+
"reason" => reason,
|
|
61
|
+
"message" => message,
|
|
62
|
+
"mutationIDs" => mutation_ids
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def authentication_error(error)
|
|
67
|
+
{
|
|
68
|
+
"kind" => "Unauthorized",
|
|
69
|
+
"origin" => "server",
|
|
70
|
+
"message" => error.message
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Next.js integration fixture
|
|
2
|
+
|
|
3
|
+
This fixture shows the generated client half of Rails-backed generic CRUD.
|
|
4
|
+
Install the current `@rocicorp/zero` and `zod` releases in a Next.js app, or
|
|
5
|
+
generate equivalent files directly from the Rails models:
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
bin/rails generate zero_rails_adapter:typescript ../web/src/zero
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Register the exported `schema` and `mutators` with the app's Zero client.
|
|
12
|
+
|
|
13
|
+
Configure:
|
|
14
|
+
|
|
15
|
+
```env
|
|
16
|
+
NEXT_PUBLIC_ZERO_CACHE_URL=http://localhost:4848
|
|
17
|
+
NEXT_PUBLIC_ZERO_MUTATE_URL=http://localhost:3000/zero/mutate
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Configure zero-cache with the same Rails URL:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
ZERO_MUTATE_URL=http://localhost:3000/zero/mutate
|
|
24
|
+
ZERO_MUTATE_API_KEY=development-secret
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The Rails application must mount the Engine at `/zero`, use the generated API
|
|
28
|
+
key verifier, and expose `Book` through `config.model_provider`. No Ruby
|
|
29
|
+
mutator class is needed for `books.create/update/destroy`.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import {defineMutator, defineMutators} from '@rocicorp/zero'
|
|
2
|
+
import {z} from 'zod'
|
|
3
|
+
|
|
4
|
+
export const mutators = defineMutators({
|
|
5
|
+
books: {
|
|
6
|
+
create: defineMutator(
|
|
7
|
+
z.object({
|
|
8
|
+
id: z.string(),
|
|
9
|
+
title: z.string().min(1).max(200),
|
|
10
|
+
owner_id: z.string(),
|
|
11
|
+
}),
|
|
12
|
+
async ({tx, args}) => {
|
|
13
|
+
const now = Date.now()
|
|
14
|
+
await tx.mutate.books.insert({
|
|
15
|
+
...args,
|
|
16
|
+
created_at: now,
|
|
17
|
+
updated_at: now,
|
|
18
|
+
})
|
|
19
|
+
},
|
|
20
|
+
),
|
|
21
|
+
update: defineMutator(
|
|
22
|
+
z.object({
|
|
23
|
+
id: z.string(),
|
|
24
|
+
title: z.string().min(1).max(200).optional(),
|
|
25
|
+
}),
|
|
26
|
+
async ({tx, args}) => {
|
|
27
|
+
await tx.mutate.books.update({...args, updated_at: Date.now()})
|
|
28
|
+
},
|
|
29
|
+
),
|
|
30
|
+
destroy: defineMutator(
|
|
31
|
+
z.object({id: z.string()}),
|
|
32
|
+
async ({tx, args}) => {
|
|
33
|
+
await tx.mutate.books.delete(args)
|
|
34
|
+
},
|
|
35
|
+
),
|
|
36
|
+
},
|
|
37
|
+
})
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import {createSchema, number, string, table} from '@rocicorp/zero'
|
|
2
|
+
|
|
3
|
+
const books = table('books')
|
|
4
|
+
.columns({
|
|
5
|
+
id: string(),
|
|
6
|
+
title: string(),
|
|
7
|
+
owner_id: string(),
|
|
8
|
+
created_at: number(),
|
|
9
|
+
updated_at: number(),
|
|
10
|
+
})
|
|
11
|
+
.primaryKey('id')
|
|
12
|
+
|
|
13
|
+
export const schema = createSchema({tables: [books]})
|
|
14
|
+
export type Schema = typeof schema
|
|
15
|
+
|
|
16
|
+
declare module '@rocicorp/zero' {
|
|
17
|
+
interface DefaultTypes {
|
|
18
|
+
schema: Schema
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module ZeroRailsAdapter
|
|
6
|
+
module Generators
|
|
7
|
+
class InstallGenerator < Rails::Generators::Base
|
|
8
|
+
source_root File.expand_path("templates", __dir__)
|
|
9
|
+
|
|
10
|
+
def copy_initializer
|
|
11
|
+
template "initializer.rb", "config/initializers/zero_rails_adapter.rb"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
ZeroRailsAdapter.configure do |config|
|
|
4
|
+
# zero-cache owns the schema.clients and schema.mutations tracking tables,
|
|
5
|
+
# so this gem does not install a migration for them.
|
|
6
|
+
|
|
7
|
+
# Verify that calls came from your zero-cache instance.
|
|
8
|
+
# Configure zero-cache with ZERO_MUTATE_API_KEY using the same value.
|
|
9
|
+
if ENV["ZERO_MUTATE_API_KEY"].present?
|
|
10
|
+
config.request_verifier = ZeroRailsAdapter::RequestVerifiers::ApiKey.new(
|
|
11
|
+
key: ENV.fetch("ZERO_MUTATE_API_KEY")
|
|
12
|
+
)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Return an Identity from any authentication system (Devise, JWT, cookies, etc.).
|
|
16
|
+
# config.authenticator = lambda do |request|
|
|
17
|
+
# user = YourAuthentication.call(request)
|
|
18
|
+
# ZeroRailsAdapter::Identity.new(
|
|
19
|
+
# user_id: user&.id&.to_s,
|
|
20
|
+
# current_user: user,
|
|
21
|
+
# claims: {}
|
|
22
|
+
# )
|
|
23
|
+
# end
|
|
24
|
+
|
|
25
|
+
# This runs once per mutation, inside its database transaction.
|
|
26
|
+
# Raise ZeroRailsAdapter::ForbiddenError to reject a mutation.
|
|
27
|
+
# config.authorizer = ->(context, mutation) { YourPolicy.authorize!(context, mutation) }
|
|
28
|
+
|
|
29
|
+
# Generic CRUD resolves "articles.create", "articles.update", and
|
|
30
|
+
# "articles.destroy" against this allowlist. The default is every named,
|
|
31
|
+
# non-abstract Active Record model; an explicit list is recommended.
|
|
32
|
+
# config.model_provider = -> { [Article, Comment] }
|
|
33
|
+
|
|
34
|
+
# Authorize generic CRUD independently of Devise/JWT/Pundit/etc. target is
|
|
35
|
+
# the model class for create, and the loaded record for update/destroy.
|
|
36
|
+
# config.crud_authorizer = lambda do |context, action, target, attributes|
|
|
37
|
+
# ApplicationPolicy.new(context.current_user, target).public_send("#{action}?")
|
|
38
|
+
# end
|
|
39
|
+
|
|
40
|
+
# Server-side mass-assignment policy. It is always enforced after auth.
|
|
41
|
+
# config.writable_attributes = lambda do |model, action, context|
|
|
42
|
+
# ModelMutationPolicy.new(context.current_user, model, action).attributes
|
|
43
|
+
# end
|
|
44
|
+
|
|
45
|
+
# Static field allowlist used by the TypeScript generator. Keep
|
|
46
|
+
# user-dependent decisions in writable_attributes/crud_authorizer.
|
|
47
|
+
# config.generated_attributes = ->(model, action) { model.column_names - %w[admin] }
|
|
48
|
+
|
|
49
|
+
# For Rails multi-database apps, use an abstract base class connected to the
|
|
50
|
+
# same PostgreSQL database as zero-cache's upstream schema.
|
|
51
|
+
# config.transaction_class = PrimaryApplicationRecord
|
|
52
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module ZeroRailsAdapter
|
|
6
|
+
module Generators
|
|
7
|
+
class MutatorGenerator < Rails::Generators::Base
|
|
8
|
+
argument :name, type: :string, banner: "namespace.action"
|
|
9
|
+
|
|
10
|
+
source_root File.expand_path("templates", __dir__)
|
|
11
|
+
|
|
12
|
+
def create_mutator
|
|
13
|
+
template "mutator.rb", File.join("app/mutators", "#{file_path}_mutator.rb")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def name_parts
|
|
19
|
+
@name_parts ||= name.split(/[|.]/)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def class_name
|
|
23
|
+
"#{name_parts.map(&:camelize).join('::')}Mutator"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def file_path
|
|
27
|
+
name_parts.map(&:underscore).join("/")
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class <%= class_name %> < ZeroRailsAdapter::Mutator
|
|
4
|
+
mutation_name "<%= name %>"
|
|
5
|
+
|
|
6
|
+
# Arguments are Active Model attributes and support normal Rails validations.
|
|
7
|
+
# attribute :title, :string
|
|
8
|
+
# validates :title, presence: true
|
|
9
|
+
|
|
10
|
+
def perform
|
|
11
|
+
# Use context.current_user, context.user_id, and context.claims for authorization.
|
|
12
|
+
raise NotImplementedError
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "zero_rails_adapter"
|
|
5
|
+
|
|
6
|
+
module ZeroRailsAdapter
|
|
7
|
+
module Generators
|
|
8
|
+
class TypeScriptGenerator < Rails::Generators::Base
|
|
9
|
+
argument :destination, type: :string, default: "app/javascript/zero"
|
|
10
|
+
|
|
11
|
+
def generate_typescript
|
|
12
|
+
generator = ZeroRailsAdapter::TypeScript::Generator.new
|
|
13
|
+
create_file File.join(destination, "schema.ts"), generator.schema
|
|
14
|
+
create_file File.join(destination, "mutators.ts"), generator.mutators
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|