model_driven_api 3.6.3 β†’ 3.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/README.md CHANGED
@@ -1,66 +1,844 @@
1
- This is part of Thecore framework: https://github.com/gabrieletassoni/thecore/tree/release/3
2
-
3
- ## Authorization
4
-
5
- It's enabled to LDAP (Active Directory and othe LDAP Based local service) and to oauth2 (Google Workspace and Microsoft Entra ID) services.
6
-
7
- ### πŸ“˜ Register OAuth Apps
8
-
9
- To use OAuth2 for authentication, you need to register your application with the OAuth provider (Google or Microsoft). Follow these steps, which are specific to each provider and aimed at a demo frontend application running on `http://localhost:5173`.
10
-
11
- #### βœ… Google OAuth Client Setup
12
-
13
- Go to: https://console.cloud.google.com/apis/credentials
14
- Create OAuth 2.0 Client ID
15
- Choose Web Application
16
- Add to β€œAuthorized JavaScript Origins”:
17
- http://localhost:5173
18
- Add to β€œAuthorized redirect URIs” (used by Google, not backend):
19
- http://localhost:5173 (for frontend access only)
20
- Save your Client ID
21
-
22
-
23
- #### βœ… Microsoft Entra ID OAuth Setup
24
-
25
- Go to https://portal.azure.com
26
- Microsoft Entra ID β†’ App registrations β†’ New registration
27
- Set redirect URI to http://localhost:5173 (type: SPA)
28
- Note the:
29
- Application (client) ID
30
- Directory (tenant) ID
31
- Under Authentication tab:
32
- Add platform: β€œSingle-page application”
33
- Add http://localhost:5173 to Redirect URIs
34
-
35
- ### Save your Client ID and Tenant ID
36
- You will need these IDs to configure the backend.
37
-
38
- ### πŸ“˜ Configure OAuth in Thecore
39
- To configure OAuth in Thecore backend, you need to set the following environment variables in your `.env` file or `environmet:` configuration in docker-compose.yml:
1
+ # model_driven_api
2
+
3
+ Part of the [Thecore framework](https://github.com/gabrieletassoni/thecore/tree/release/3).
4
+
5
+ A Rails engine that auto-generates a versioned REST API by introspecting your ActiveRecord models at runtime. No per-model controllers or serializers needed β€” the schema drives everything.
6
+
7
+ | Version | Base path | Format | Query style |
8
+ |---|---|---|---|
9
+ | v2 | `/api/v2/` | Plain JSON | Ransack predicates (`q[field_eq]=value`) |
10
+ | v3 | `/api/v3/` | JSON:API | `filter[field]=value`, `sort=field`, `page[number]=N` |
11
+
12
+ ---
13
+
14
+ ## Features
15
+
16
+ - Full CRUD for every `ApplicationRecord` subclass with zero boilerplate
17
+ - **v2**: Ransack-powered filtering and sorting (GET and POST search endpoint)
18
+ - **v3**: JSON:API-compliant envelopes; filter/sort/page query params; Pagy pagination
19
+ - JWT authentication with sliding token expiration (shared by v2 and v3)
20
+ - OAuth2 support: Google Workspace and Microsoft Entra ID
21
+ - LDAP / Active Directory authentication (via host app headers)
22
+ - Custom actions on any model β€” two patterns supported (v2 and v3)
23
+ - SELECT-only raw SQL endpoint (v2 and v3)
24
+ - Self-generated OpenAPI 3.0 / Swagger documentation (v2 and v3)
25
+ - JSON:API sideloading with hybrid defaults (`json_attrs[:include]` + `?include=` override)
26
+ - JSON:API sparse fieldsets (`?fields[type]=f1,f2`)
27
+ - `Content-Range` header for react-admin and similar frontends (v2)
28
+
29
+ ---
30
+
31
+ ## Installation
32
+
33
+ Add to your host app's `Gemfile`:
34
+
35
+ ```ruby
36
+ gem 'model_driven_api', '~> 3.6'
37
+ ```
38
+
39
+ The gem declares `pagy ~> 9.0` as a runtime dependency and explicitly requires it at load time. No additional configuration is needed for pagination to work.
40
+
41
+ Include the engine concerns in your host models:
42
+
43
+ ```ruby
44
+ # app/models/application_record.rb
45
+ class ApplicationRecord < ActiveRecord::Base
46
+ include ModelDrivenApiApplicationRecord
47
+ end
48
+
49
+ # app/models/user.rb
50
+ class User < ApplicationRecord
51
+ include ModelDrivenApiUser
52
+ end
53
+
54
+ # app/models/role.rb
55
+ class Role < ApplicationRecord
56
+ include ModelDrivenApiRole
57
+ end
58
+ ```
59
+
60
+ Run migrations:
61
+
62
+ ```bash
63
+ bundle exec rails db:migrate
64
+ ```
65
+
66
+ ---
67
+
68
+ ## Authentication
69
+
70
+ ### JWT (email/password)
71
+
72
+ ```http
73
+ POST /api/v2/authenticate
74
+ Content-Type: application/json
75
+
76
+ { "auth": { "email": "admin@example.com", "password": "Change#1" } }
77
+ ```
78
+
79
+ Response body: User JSON. Response header: `Token: <jwt>`.
80
+
81
+ Every subsequent authenticated request returns a fresh `Token` header (sliding expiration). Clients must read this header and store the new token on every response.
82
+
83
+ Token expiry is controlled by the `SESSION_TIMEOUT_IN_MINUTES` env var (default: 31 minutes).
84
+
85
+ When `ALLOW_MULTISESSIONS=false`, each login invalidates all previous tokens for the user (stored in the `used_tokens` table).
86
+
87
+ ### Bearer token usage (v2 and v3)
88
+
89
+ ```http
90
+ GET /api/v2/items
91
+ Authorization: Bearer <jwt>
92
+
93
+ GET /api/v3/items
94
+ Authorization: Bearer <jwt>
95
+ Accept: application/vnd.api+json
96
+ ```
97
+
98
+ ---
99
+
100
+ ## OAuth2 Authorization
101
+
102
+ OAuth2 is enabled when the relevant environment variables are set (see below). Two flows are supported:
103
+
104
+ ### Server-side OmniAuth callback (traditional)
105
+
106
+ Set redirect URI to `http://yourdomain/auth/:provider/callback`. The OmniAuth middleware redirects to `/api/v2/auth/:provider/callback`, which returns the user JSON and a `Token` header.
107
+
108
+ ### Frontend token exchange (`POST /api/v2/auth/jwt`)
109
+
110
+ For SPA frontends that obtain the OAuth token themselves:
111
+
112
+ ```http
113
+ POST /api/v2/auth/jwt
114
+ Content-Type: application/json
115
+
116
+ { "provider": "google", "provider_token": "<google-access-token>" }
117
+ ```
118
+
119
+ The backend verifies the token against the provider's userinfo endpoint and returns a JWT.
120
+
121
+ ### Register OAuth apps
122
+
123
+ #### Google
124
+
125
+ 1. Go to [Google Cloud Console β†’ Credentials](https://console.cloud.google.com/apis/credentials)
126
+ 2. Create β†’ OAuth 2.0 Client ID β†’ Web Application
127
+ 3. Add Authorized JavaScript Origins: your frontend URL
128
+ 4. Note the Client ID
129
+
130
+ #### Microsoft Entra ID
131
+
132
+ 1. Go to [portal.azure.com](https://portal.azure.com) β†’ Microsoft Entra ID β†’ App registrations β†’ New registration
133
+ 2. Set redirect URI type: SPA, value: your frontend URL
134
+ 3. Note: Application (client) ID, Directory (tenant) ID
135
+ 4. Under Authentication β†’ Add platform: Single-page application
136
+
137
+ ### Environment variables
40
138
 
41
139
  ```plaintext
42
- # OAuth Configuration
43
- # Microsoft OAuth
44
- ENTRA_CLIENT_ID: "your-client-id"
45
- ENTRA_CLIENT_SECRET: "your-client-secret"
46
- ENTRA_TENANT_ID: "your-tenant-id"
140
+ # Microsoft
141
+ ENTRA_CLIENT_ID=your-client-id
142
+ ENTRA_CLIENT_SECRET=your-client-secret
143
+ ENTRA_TENANT_ID=your-tenant-id
144
+
145
+ # Google
146
+ GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
147
+ GOOGLE_CLIENT_SECRET=your-client-secret
47
148
 
48
- # Google OAuth
49
- GOOGLE_CLIENT_ID: "your-client-id.apps.googleusercontent.com"
50
- GOOGLE_CLIENT_SECRET: "your-client-secret"
149
+ # JWT
150
+ SECRET_KEY_BASE=...
151
+ SESSION_TIMEOUT_IN_MINUTES=31
152
+
153
+ # Session mode: "false" enables token blacklisting
154
+ ALLOW_MULTISESSIONS=true
51
155
  ```
52
156
 
53
- In the Frontend applciation, you will need to set the following environment variables in your `.env` file:
157
+ Frontend env vars (example for Vite):
54
158
 
55
159
  ```plaintext
56
- # OAuth Configuration
57
- # Google OAuth
58
160
  VITE_GOOGLE_CLIENT_ID=your-client-id
59
-
60
- # Microsoft OAuth
61
161
  VITE_AZURE_CLIENT_ID=your-client-id
62
162
  VITE_AZURE_TENANT_ID=your-tenant-id
63
-
64
- # OAuth Callback URLs
65
163
  VITE_API_URL=http://yourdomain/api/v2/auth/google_oauth2/callback
66
164
  ```
165
+
166
+ ---
167
+
168
+ ## API v2 β€” Plain JSON (Ransack)
169
+
170
+ ### CRUD endpoints
171
+
172
+ All `ApplicationRecord` subclasses get these endpoints automatically:
173
+
174
+ | Method | Path | Action |
175
+ |---|---|---|
176
+ | GET | `/api/v2/:model` | Index (all records or paginated) |
177
+ | GET | `/api/v2/:model/:id` | Show |
178
+ | POST | `/api/v2/:model` | Create |
179
+ | PUT / PATCH | `/api/v2/:model/:id` | Update |
180
+ | DELETE | `/api/v2/:model/:id` | Destroy |
181
+ | PUT / PATCH | `/api/v2/:model/:id/multi` | Bulk update (comma-separated ids) |
182
+ | DELETE | `/api/v2/:model/:id/multi` | Bulk destroy |
183
+ | POST | `/api/v2/:model/search` | Search (Ransack, same params as GET index) |
184
+
185
+ ### Search, filtering & pagination
186
+
187
+ Parameters work identically in query string (GET) and JSON body (POST search).
188
+
189
+ #### Pagination
190
+
191
+ | Param | Type | Effect |
192
+ |---|---|---|
193
+ | `page` | Integer | Page number |
194
+ | `per` | Integer | Records per page |
195
+ | `count` | any | Return `{ "count": N }` instead of records |
196
+
197
+ #### Field selection (`a` or `json_attrs`)
198
+
199
+ ```json
200
+ {
201
+ "a": {
202
+ "only": ["id", "name"],
203
+ "methods": ["computed_field"],
204
+ "include": { "items": { "only": ["id", "code"] } }
205
+ }
206
+ }
207
+ ```
208
+
209
+ #### Ransack filtering (`q`)
210
+
211
+ ```
212
+ q[field_predicate]=value
213
+ ```
214
+
215
+ Common predicates: `_eq`, `_cont`, `_start`, `_end`, `_gt`, `_lt`, `_gteq`, `_lteq`, `_in`, `_present`, `_blank`.
216
+ Sorting: `q[s]=field_name asc`.
217
+ Cross-association: `q[user_email_end]=@example.com`.
218
+
219
+ #### Examples
220
+
221
+ ```
222
+ # Paginated index
223
+ GET /api/v2/users?page=2&per=10
224
+
225
+ # Filter + sort + field selection
226
+ GET /api/v2/orders?q[total_price_gt]=50&q[s]=created_at desc&a[only][]=id&a[only][]=total_price
227
+
228
+ # Count only
229
+ GET /api/v2/users?q[active_eq]=true&count=true
230
+
231
+ # Array filter
232
+ GET /api/v2/products?q[status_in][]=new&q[status_in][]=refurbished
233
+ ```
234
+
235
+ POST equivalent (preferred for complex queries):
236
+
237
+ ```http
238
+ POST /api/v2/orders/search
239
+ Authorization: Bearer <jwt>
240
+ Content-Type: application/json
241
+
242
+ {
243
+ "q": { "total_price_gt": 50, "s": "created_at desc" },
244
+ "a": { "only": ["id", "total_price"] }
245
+ }
246
+ ```
247
+
248
+ The response includes a `Content-Range` header: `model_name start-end/total`.
249
+
250
+ ---
251
+
252
+ ## API v3 β€” JSON:API
253
+
254
+ All responses follow the [JSON:API 1.0](https://jsonapi.org) specification. Send `Accept: application/vnd.api+json` and `Content-Type: application/vnd.api+json` on write requests.
255
+
256
+ ### CRUD endpoints
257
+
258
+ | Method | Path | Action | Response |
259
+ |---|---|---|---|
260
+ | GET | `/api/v3/:model` | Index | `{ data: […], meta: { total: N } }` |
261
+ | GET | `/api/v3/:model/:id` | Show | `{ data: { id, type, attributes } }` |
262
+ | POST | `/api/v3/:model` | Create | `{ data: { … } }` β€” 201 Created |
263
+ | PATCH | `/api/v3/:model/:id` | Update | `{ data: { … } }` β€” 200 OK |
264
+ | DELETE | `/api/v3/:model/:id` | Destroy | 204 No Content |
265
+
266
+ ### Filtering
267
+
268
+ ```
269
+ GET /api/v3/articles?filter[title]=Hello
270
+ GET /api/v3/articles?filter[status]=published&filter[author_id]=42
271
+ ```
272
+
273
+ Field names are validated against the model's `ransackable_attributes` whitelist. Unknown fields are silently ignored.
274
+
275
+ ### Sorting
276
+
277
+ ```
278
+ GET /api/v3/articles?sort=title # ascending
279
+ GET /api/v3/articles?sort=-created_at # descending
280
+ GET /api/v3/articles?sort=status,-title # multi-field
281
+ ```
282
+
283
+ ### Pagination
284
+
285
+ ```
286
+ GET /api/v3/articles?page[number]=2&page[size]=10
287
+ ```
288
+
289
+ Response includes `meta.total` with the full count:
290
+
291
+ ```json
292
+ {
293
+ "data": [ … ],
294
+ "meta": { "total": 47 }
295
+ }
296
+ ```
297
+
298
+ ### Sparse fieldsets
299
+
300
+ Return only a subset of attributes per type:
301
+
302
+ ```
303
+ GET /api/v3/articles?fields[articles]=title,published_at
304
+ ```
305
+
306
+ Multiple types can be narrowed in a single request when sideloading:
307
+
308
+ ```
309
+ GET /api/v3/roles/1?include=users&fields[roles]=name&fields[users]=email
310
+ ```
311
+
312
+ ### Sideloading (relationships)
313
+
314
+ Default sideloads are defined in the model's `json_attrs[:include]`. The client can override:
315
+
316
+ ```
317
+ # Default sideloads from json_attrs[:include]
318
+ GET /api/v3/roles/1
319
+
320
+ # Explicit override β€” only sideload users
321
+ GET /api/v3/roles/1?include=users
322
+
323
+ # Suppress all sideloading
324
+ GET /api/v3/roles/1?include=
325
+ ```
326
+
327
+ Sideloaded resources appear in a top-level `included` array per the JSON:API spec.
328
+
329
+ ### Create / update request body
330
+
331
+ ```http
332
+ POST /api/v3/articles
333
+ Content-Type: application/vnd.api+json
334
+ Authorization: Bearer <jwt>
335
+
336
+ {
337
+ "data": {
338
+ "type": "articles",
339
+ "attributes": {
340
+ "title": "My Article",
341
+ "body": "Content here"
342
+ }
343
+ }
344
+ }
345
+ ```
346
+
347
+ ### JSON:API response example
348
+
349
+ ```json
350
+ {
351
+ "data": {
352
+ "id": "1",
353
+ "type": "articles",
354
+ "attributes": {
355
+ "title": "My Article",
356
+ "body": "Content here",
357
+ "published_at": "2026-06-08T10:00:00.000Z"
358
+ }
359
+ }
360
+ }
361
+ ```
362
+
363
+ Attributes are driven by the model's `json_attrs` (minus `:id`, which is always the resource identifier). `methods:` entries become virtual attributes; `include:` entries produce relationship linkage and default sideloads.
364
+
365
+ ---
366
+
367
+ ## Custom Actions (v2 and v3)
368
+
369
+ Custom actions are dispatched by `Api::CustomActionDispatcher` in both v2 and v3. Responses are plain JSON (not JSON:API envelopes) in both versions. The bearer token must be sent via the `Authorization` header β€” embedding tokens in the action name is no longer supported.
370
+
371
+ ### Pattern 1 β€” class method on the model
372
+
373
+ ```ruby
374
+ class MyModel < ApplicationRecord
375
+ # Called via: GET /api/v2/my_models?do=report
376
+ # GET /api/v3/my_models?do=report
377
+ def self.custom_action_report(params)
378
+ [{ total: count, params: params }, 200]
379
+ end
380
+ end
381
+ ```
382
+
383
+ ### Pattern 2 β€” `NonCrudEndpoints` subclass (with OpenAPI docs)
384
+
385
+ Place in `app/models/endpoints/my_model.rb`:
386
+
387
+ ```ruby
388
+ class Endpoints::MyModel < NonCrudEndpoints
389
+ self.desc 'MyModel', :report, {
390
+ get: {
391
+ summary: "Monthly Report",
392
+ tags: ["MyModel"],
393
+ parameters: [
394
+ { name: "month", in: "query", required: true, schema: { type: "integer" } }
395
+ ],
396
+ responses: {
397
+ 200 => { description: "Report data", content: { "application/json": { schema: { type: "object" } } } }
398
+ }
399
+ }
400
+ }
401
+
402
+ def report(params)
403
+ [{ month: params[:month], data: [] }, 200]
404
+ end
405
+ end
406
+ ```
407
+
408
+ Routes for pattern 2 (same shape in v2 and v3):
409
+
410
+ ```
411
+ GET /api/v2/my_models/custom_action/report
412
+ GET /api/v3/my_models/custom_action/report
413
+ GET /api/v2/my_models/custom_action/report/:id
414
+ GET /api/v3/my_models/custom_action/report/:id
415
+ POST / PUT / PATCH / DELETE also available in both versions
416
+ ```
417
+
418
+ ---
419
+
420
+ ## JSON Serialisation DSL (`json_attrs`)
421
+
422
+ Each model can declare `self.json_attrs` to control the API response shape. In v2 this mirrors the Rails `as_json` API; in v3 the `[:only]` list drives the generated JSON:API serializer.
423
+
424
+ ```ruby
425
+ class MyModel < ApplicationRecord
426
+ cattr_accessor :json_attrs
427
+ self.json_attrs = {
428
+ only: [:id, :name, :status], # attribute whitelist
429
+ except: [:internal_notes], # attribute blacklist (used when only: is absent)
430
+ methods: [:computed_value], # virtual attributes (callable on instance)
431
+ include: {
432
+ category: { only: [:id, :name] },
433
+ tags: { only: [:id, :label] }
434
+ }
435
+ }
436
+ end
437
+ ```
438
+
439
+ **v2 behaviour**: `only`/`except`/`methods`/`include` are passed through Rails `as_json` on every response. Clients may override per-request via the `a` or `json_attrs` query/body parameter.
440
+
441
+ **v3 behaviour**:
442
+ - `only:` / `except:` β€” drive the generated JSON:API serializer's attribute list.
443
+ - `methods:` β€” each entry becomes a virtual attribute using `object.send(method)` (private methods are supported, consistent with Rails `as_json`).
444
+ - `include:` β€” each entry declares a relationship on the serializer and becomes a **default sideload**. The client can suppress or replace defaults with `?include=` (empty to suppress, comma-separated list to override).
445
+
446
+ When composing `json_attrs` across multiple concerns, use `ModelDrivenApi.smart_merge` to deep-merge without losing fields set by other concerns:
447
+
448
+ ```ruby
449
+ self.json_attrs = ModelDrivenApi.smart_merge((json_attrs || {}), { only: [:id, :name] })
450
+ ```
451
+
452
+ ---
453
+
454
+ ## Raw SQL endpoint
455
+
456
+ Executes read-only SELECT queries. Authentication required. Only `SELECT` (and `WITH … SELECT`) statements are allowed; DDL and DML are rejected with HTTP 400.
457
+
458
+ ### v2 β€” requires `result` key
459
+
460
+ ```http
461
+ POST /api/v2/raw/sql
462
+ Authorization: Bearer <jwt>
463
+ Content-Type: application/json
464
+
465
+ {
466
+ "query": "SELECT json_agg(u) AS result FROM users u WHERE u.admin = true"
467
+ }
468
+ ```
469
+
470
+ The query **must** return a `result` column (use `json_agg` or `jsonb_agg`).
471
+
472
+ ### v3 β€” plain JSON array
473
+
474
+ ```http
475
+ GET /api/v3/raw/sql?query=SELECT+id,title+FROM+articles+LIMIT+10
476
+ Authorization: Bearer <jwt>
477
+
478
+ POST /api/v3/raw/sql
479
+ Authorization: Bearer <jwt>
480
+ Content-Type: application/json
481
+
482
+ { "query": "SELECT id, title FROM articles ORDER BY created_at DESC LIMIT 10" }
483
+ ```
484
+
485
+ Returns rows directly as a JSON array β€” no `result` key, no JSON:API envelope. This is a deliberate exception to JSON:API compliance for the SQL escape hatch.
486
+
487
+ ---
488
+
489
+ ## Info endpoints (v2 and v3)
490
+
491
+ All info endpoints are available under both `/api/v2/info/` and `/api/v3/info/`. v3 clients can use a single base URL for auth, CRUD, and info.
492
+
493
+ | Endpoint | Auth | Description |
494
+ |---|---|---|
495
+ | `GET /info/version` | No | App version string |
496
+ | `GET /info/heartbeat` | Yes | Renews token, returns current user |
497
+ | `GET /info/ntp` | Yes | Server UTC time (client clock sync) |
498
+ | `GET /info/roles` | Yes | All roles |
499
+ | `GET /info/schema` | Yes | DB schema for models the user can read |
500
+ | `GET /info/dsl` | Yes | `json_attrs` DSL for each model |
501
+ | `GET /info/translations` | Yes | Full i18n tree (`?locale=en`) |
502
+ | `GET /info/settings` | Yes | All `ThecoreSettings::Setting` values |
503
+ | `GET /info/swagger` | No | OpenAPI 3.0 spec (alias: `/info/openapi`) |
504
+
505
+ The v2 and v3 swagger specs are different β€” v2 documents plain JSON CRUD + Ransack + search + bulk ops; v3 documents JSON:API envelopes + filter/sort/page params + 204 on delete.
506
+
507
+ ---
508
+
509
+ ## ActiveStorage file uploads (v2)
510
+
511
+ For models with `has_many_attached :assets`, use `multipart/form-data` β€” do not use JSON:
512
+
513
+ ```javascript
514
+ const formData = new FormData();
515
+ formData.append('product[title]', title);
516
+ files.forEach(file => formData.append('product[assets][]', file));
517
+
518
+ // Do NOT set Content-Type manually β€” the browser sets the boundary
519
+ fetch('/api/v2/products', { method: 'POST', body: formData });
520
+ ```
521
+
522
+ To delete attachments, pass the `ActiveStorage::Attachment` IDs via a virtual attribute:
523
+
524
+ ```javascript
525
+ idsToRemove.forEach(id => formData.append('product[remove_assets][]', id));
526
+ fetch(`/api/v2/products/${id}`, { method: 'PATCH', body: formData });
527
+ ```
528
+
529
+ ---
530
+
531
+ ---
532
+
533
+ ## Web Push (VAPID) from a React client
534
+
535
+ This section is the complete integration guide for React apps that want to receive browser push notifications from a Thecore backend. The server-side setup (models, service, ActionCable channel) is documented in the [`thecore_backend_commons` README](../thecore_backend_commons/README.md#web-push-notifications-vapid).
536
+
537
+ ### Prerequisites
538
+
539
+ 1. Run `rails db:seed` on the backend β€” this generates the VAPID key pair automatically.
540
+ 2. Set `vapid.contact_email` in RailsAdmin β†’ Settings (e.g. `admin@yourapp.com`).
541
+ 3. Your site must be served over **HTTPS** (required by the Push API in all browsers). `localhost` is exempt for development.
542
+
543
+ ### Endpoint reference
544
+
545
+ All endpoints live under `/api/v2/push_subscribers/custom_action/`.
546
+
547
+ | Method | Path | Auth | Description |
548
+ |--------|------|------|-------------|
549
+ | `GET` | `vapid_public_key` | **No** | Returns the VAPID public key for `PushManager.subscribe` |
550
+ | `POST` | `subscribe` | Yes (JWT) | Registers or updates a push subscription for the current user |
551
+ | `POST` | `send_push` | Yes (JWT) | Creates a `PushMessage` and dispatches it to an active subscriber |
552
+ | `POST` | `acknowledge` | Yes (JWT) | Marks a message as `received` and/or `read` |
553
+
554
+ ### Step 1 β€” Register a service worker
555
+
556
+ Create `public/sw.js` in your React app:
557
+
558
+ ```javascript
559
+ // public/sw.js
560
+
561
+ self.addEventListener('push', event => {
562
+ const data = event.data?.json() ?? {};
563
+ event.waitUntil(
564
+ self.registration.showNotification(data.title ?? 'Notification', {
565
+ body: data.body,
566
+ icon: data.icon ?? '/favicon.ico',
567
+ data: { url: data.url },
568
+ })
569
+ );
570
+ });
571
+
572
+ self.addEventListener('notificationclick', event => {
573
+ event.notification.close();
574
+ const url = event.notification.data?.url;
575
+ if (url) {
576
+ event.waitUntil(clients.openWindow(url));
577
+ }
578
+ });
579
+ ```
580
+
581
+ ### Step 2 β€” Subscribe to push notifications
582
+
583
+ ```javascript
584
+ // src/usePushSubscription.js
585
+ const API_BASE = process.env.REACT_APP_API_URL ?? '/api/v2';
586
+
587
+ // Convert a base64url string to a Uint8Array (required by PushManager)
588
+ function urlBase64ToUint8Array(base64String) {
589
+ const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
590
+ const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
591
+ const raw = atob(base64);
592
+ return Uint8Array.from([...raw].map(c => c.charCodeAt(0)));
593
+ }
594
+
595
+ // Convert a PushSubscription key to a base64url string (required by the backend)
596
+ function keyToBase64(subscription, key) {
597
+ return btoa(String.fromCharCode(...new Uint8Array(subscription.getKey(key))));
598
+ }
599
+
600
+ export async function subscribeToPush(jwtToken) {
601
+ // 1. Register service worker
602
+ const registration = await navigator.serviceWorker.register('/sw.js');
603
+
604
+ // 2. Fetch the VAPID public key (no auth needed)
605
+ const res = await fetch(`${API_BASE}/push_subscribers/custom_action/vapid_public_key`);
606
+ const { vapid_public_key } = await res.json();
607
+
608
+ // 3. Subscribe via the Push API
609
+ const subscription = await registration.pushManager.subscribe({
610
+ userVisibleOnly: true,
611
+ applicationServerKey: urlBase64ToUint8Array(vapid_public_key),
612
+ });
613
+
614
+ // 4. Register the subscription with the backend
615
+ const registerRes = await fetch(
616
+ `${API_BASE}/push_subscribers/custom_action/subscribe`,
617
+ {
618
+ method: 'POST',
619
+ headers: {
620
+ 'Content-Type': 'application/json',
621
+ Authorization: `Bearer ${jwtToken}`,
622
+ },
623
+ body: JSON.stringify({
624
+ endpoint: subscription.endpoint,
625
+ p256dh: keyToBase64(subscription, 'p256dh'),
626
+ auth: keyToBase64(subscription, 'auth'),
627
+ user_agent: navigator.userAgent,
628
+ }),
629
+ }
630
+ );
631
+
632
+ const subscriber = await registerRes.json();
633
+ // subscriber.id is the push_subscriber_id to use with ActionCable and send_push
634
+ return subscriber;
635
+ }
636
+ ```
637
+
638
+ Call this after the user logs in (a JWT is required for `subscribe`):
639
+
640
+ ```javascript
641
+ import { subscribeToPush } from './usePushSubscription';
642
+
643
+ const subscriber = await subscribeToPush(jwtToken);
644
+ localStorage.setItem('push_subscriber_id', subscriber.id);
645
+ ```
646
+
647
+ ### Step 3 β€” Listen via ActionCable
648
+
649
+ Install the ActionCable client if you haven't already:
650
+
651
+ ```bash
652
+ npm install @rails/actioncable
653
+ # or
654
+ yarn add @rails/actioncable
655
+ ```
656
+
657
+ ```javascript
658
+ // src/usePushChannel.js
659
+ import { createConsumer } from '@rails/actioncable';
660
+
661
+ const WS_URL = process.env.REACT_APP_WS_URL ?? 'ws://localhost:3000/cable';
662
+
663
+ export function connectPushChannel(jwtToken, subscriberId, onMessage) {
664
+ // The JWT token is passed as a query parameter so the ActionCable
665
+ // connection.rb can authenticate the WebSocket handshake.
666
+ const consumer = createConsumer(`${WS_URL}?token=${jwtToken}`);
667
+
668
+ const subscription = consumer.subscriptions.create(
669
+ { channel: 'PushNotificationChannel', subscriber_id: subscriberId },
670
+ {
671
+ received(data) {
672
+ // data is a PushMessage serialised as JSON:
673
+ // { id, title, body, url, icon, sent_at, received_at, read_at }
674
+ onMessage(data);
675
+ },
676
+ connected() {
677
+ console.log('[PushNotificationChannel] connected');
678
+ },
679
+ disconnected() {
680
+ console.log('[PushNotificationChannel] disconnected');
681
+ },
682
+ }
683
+ );
684
+
685
+ // Return a cleanup function
686
+ return () => {
687
+ subscription.unsubscribe();
688
+ consumer.disconnect();
689
+ };
690
+ }
691
+ ```
692
+
693
+ Use it in a React component or hook:
694
+
695
+ ```javascript
696
+ import { useEffect } from 'react';
697
+ import { connectPushChannel } from './usePushChannel';
698
+
699
+ function App() {
700
+ const jwtToken = localStorage.getItem('token');
701
+ const subscriberId = localStorage.getItem('push_subscriber_id');
702
+
703
+ useEffect(() => {
704
+ if (!jwtToken || !subscriberId) return;
705
+
706
+ const disconnect = connectPushChannel(jwtToken, subscriberId, message => {
707
+ console.log('New push message via ActionCable:', message);
708
+ // Optionally acknowledge receipt immediately
709
+ acknowledgeMessage(jwtToken, message.id, { received: true });
710
+ });
711
+
712
+ return disconnect; // cleanup on unmount
713
+ }, [jwtToken, subscriberId]);
714
+ }
715
+ ```
716
+
717
+ > **Tip:** use `user_id` instead of `subscriber_id` if you want to receive messages across all active subscriptions for the current user (e.g. multiple tabs):
718
+ > ```javascript
719
+ > { channel: 'PushNotificationChannel', user_id: currentUserId }
720
+ > ```
721
+
722
+ ### Step 4 β€” Acknowledge receipt and read
723
+
724
+ ```javascript
725
+ // src/pushApi.js
726
+ const API_BASE = process.env.REACT_APP_API_URL ?? '/api/v2';
727
+
728
+ export async function acknowledgeMessage(jwtToken, messageId, { received = false, read = false } = {}) {
729
+ const res = await fetch(
730
+ `${API_BASE}/push_subscribers/custom_action/acknowledge`,
731
+ {
732
+ method: 'POST',
733
+ headers: {
734
+ 'Content-Type': 'application/json',
735
+ Authorization: `Bearer ${jwtToken}`,
736
+ },
737
+ body: JSON.stringify({
738
+ push_message_id: messageId,
739
+ received,
740
+ read,
741
+ }),
742
+ }
743
+ );
744
+ return res.json();
745
+ // Response: { id, title, body, url, icon, sent_at, received_at, read_at, ... }
746
+ }
747
+ ```
748
+
749
+ Call `received: true` as soon as the message arrives via ActionCable. Call `read: true` when the user opens or dismisses it. Fields are set only once β€” a second call with the same flag is a no-op (idempotent).
750
+
751
+ ### Step 5 β€” Send a push from the backend (optional)
752
+
753
+ Normally the backend triggers pushes from jobs or model callbacks. If you need to trigger a push from a privileged frontend (e.g. an admin panel), use `send_push`:
754
+
755
+ ```javascript
756
+ export async function sendPush(jwtToken, { subscriberId, title, body, url, icon }) {
757
+ const res = await fetch(
758
+ `${API_BASE}/push_subscribers/custom_action/send_push`,
759
+ {
760
+ method: 'POST',
761
+ headers: {
762
+ 'Content-Type': 'application/json',
763
+ Authorization: `Bearer ${jwtToken}`,
764
+ },
765
+ body: JSON.stringify({
766
+ push_subscriber_id: subscriberId,
767
+ title,
768
+ body,
769
+ url, // optional
770
+ icon, // optional
771
+ }),
772
+ }
773
+ );
774
+ if (!res.ok) {
775
+ const err = await res.json();
776
+ throw new Error(err.error ?? `HTTP ${res.status}`);
777
+ }
778
+ return res.json(); // PushMessage record
779
+ }
780
+ ```
781
+
782
+ ### Full flow summary
783
+
784
+ ```
785
+ React app Thecore backend
786
+ β”‚ β”‚
787
+ │── GET vapid_public_key ──────────►│ (no auth)
788
+ │◄── { vapid_public_key: "..." } ───│
789
+ β”‚ β”‚
790
+ β”‚ navigator.serviceWorker.register('/sw.js')
791
+ β”‚ registration.pushManager.subscribe({ applicationServerKey })
792
+ β”‚ β”‚
793
+ │── POST subscribe ────────────────►│ creates/updates PushSubscriber
794
+ │◄── { id: 42, endpoint, ... } ─────│
795
+ β”‚ β”‚
796
+ β”‚ createConsumer(wsUrl?token=jwt) β”‚
797
+ │── WS upgrade ────────────────────►│ PushNotificationChannel#subscribed
798
+ │◄── stream: push_notifications_subscriber_42
799
+ β”‚ β”‚
800
+ β”‚ [backend dispatches push] β”‚
801
+ │◄── Web Push payload (sw.js) ──────│ Webpush.payload_send via VAPID
802
+ β”‚ showNotification(title, body) β”‚
803
+ β”‚ β”‚
804
+ │◄── ActionCable data ──────────────│ PushNotificationChannel.broadcast_to
805
+ β”‚ onMessage(data) β”‚
806
+ β”‚ β”‚
807
+ │── POST acknowledge (received) ───►│ message.received_at = now
808
+ │── POST acknowledge (read) ────────►│ message.read_at = now
809
+ ```
810
+
811
+ ### Handling subscription expiry
812
+
813
+ Push service endpoints expire (the push provider returns HTTP 410). The backend automatically calls `subscriber.expire!` when this happens, but the client needs to re-subscribe on the next boot:
814
+
815
+ ```javascript
816
+ export async function ensureSubscription(jwtToken) {
817
+ // Re-subscribe unconditionally β€” subscribe_for upserts on endpoint,
818
+ // so re-registering the same browser is always safe and clears expired_at.
819
+ const sub = await subscribeToPush(jwtToken);
820
+ localStorage.setItem('push_subscriber_id', sub.id);
821
+ return sub;
822
+ }
823
+ ```
824
+
825
+ ### Permissions check
826
+
827
+ Before calling `subscribeToPush`, check that the browser supports push and the user has granted permission:
828
+
829
+ ```javascript
830
+ export async function requestPushPermission() {
831
+ if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
832
+ console.warn('Web Push not supported in this browser');
833
+ return false;
834
+ }
835
+ const permission = await Notification.requestPermission();
836
+ return permission === 'granted';
837
+ }
838
+ ```
839
+
840
+ ---
841
+
842
+ ## License
843
+
844
+ MIT