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.
- checksums.yaml +4 -4
- data/README.md +832 -54
- data/Rakefile +3 -0
- data/app/controllers/api/v2/application_controller.rb +24 -49
- data/app/controllers/api/v2/info_controller.rb +2 -1308
- data/app/controllers/api/v3/application_controller.rb +132 -0
- data/app/controllers/api/v3/auth/oauth_controller.rb +4 -0
- data/app/controllers/api/v3/authentication_controller.rb +2 -0
- data/app/controllers/api/v3/info_controller.rb +37 -0
- data/app/controllers/api/v3/raw_controller.rb +14 -0
- data/app/controllers/api/v3/users_controller.rb +10 -0
- data/app/models/endpoints/push_subscriber.rb +110 -0
- data/config/routes.rb +44 -0
- data/lib/api/custom_action_dispatcher.rb +41 -0
- data/lib/api/model_resolver.rb +20 -0
- data/lib/api/open_api/base.rb +91 -0
- data/lib/api/open_api/v2.rb +1238 -0
- data/lib/api/open_api/v3.rb +349 -0
- data/lib/api/resource_attribute_set.rb +25 -0
- data/lib/api/v3/serializer_factory.rb +65 -0
- data/lib/concerns/api_exception_management.rb +4 -1
- data/lib/model_driven_api/engine.rb +7 -1
- data/lib/model_driven_api/version.rb +1 -1
- data/lib/model_driven_api.rb +8 -0
- data/lib/non_crud_endpoints.rb +18 -0
- metadata +78 -8
data/README.md
CHANGED
|
@@ -1,66 +1,844 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
Add
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
#
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
#
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|