model_driven_api 3.6.3 → 3.6.4
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 +521 -54
- data/Rakefile +3 -0
- data/app/controllers/api/v2/application_controller.rb +6 -47
- 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/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/model_driven_api/engine.rb +7 -1
- data/lib/model_driven_api/version.rb +1 -1
- data/lib/model_driven_api.rb +8 -0
- metadata +75 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b4757c81ca4ae3c4e59c12a96f5ddf23801121fc8ac3a7cde16ca3a8f2d47208
|
|
4
|
+
data.tar.gz: 2c342468dd9879aec91f2dd6bc49c5dba639dab0a8841583d6032377ef937e78
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0b36be5d13960e0166eaa31fc698745a1ba9d3d69ed4b49ca91540af7d112e7ce0bf39ff0cc87cb2e1c973b21add87a1e9ef9e18eaa3fc7adf81b82c268f745f
|
|
7
|
+
data.tar.gz: fe831b9b04fe80dfbd3db57e41d1b999035ab676f5d602904fa8e759af95504b9ff6d4d6ea91d947eef4dbe85a54afa62a1f9c30c17aa092191cbec960dc3ff0
|
data/README.md
CHANGED
|
@@ -1,66 +1,533 @@
|
|
|
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
|
|
148
|
+
|
|
149
|
+
# JWT
|
|
150
|
+
SECRET_KEY_BASE=...
|
|
151
|
+
SESSION_TIMEOUT_IN_MINUTES=31
|
|
47
152
|
|
|
48
|
-
#
|
|
49
|
-
|
|
50
|
-
GOOGLE_CLIENT_SECRET: "your-client-secret"
|
|
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
|
+
## License
|
|
532
|
+
|
|
533
|
+
MIT
|
data/Rakefile
CHANGED
|
@@ -140,43 +140,9 @@ class Api::V2::ApplicationController < ActionController::API
|
|
|
140
140
|
# or
|
|
141
141
|
# [GET|PUT|POST|DELETE] :controller/custom_action/:custom_action/:id
|
|
142
142
|
def check_for_custom_action
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
# This is for backward compatibility and in future it can ben removed
|
|
147
|
-
params[:do].split("-")
|
|
148
|
-
elsif request.url.include? "/custom_action/"
|
|
149
|
-
[params[:action_name], nil]
|
|
150
|
-
else
|
|
151
|
-
# Not a custom action call
|
|
152
|
-
false
|
|
153
|
-
end
|
|
154
|
-
return false unless custom_action
|
|
155
|
-
# Poor man's solution to avoid the possibility to
|
|
156
|
-
# call an unwanted method in the AR Model.
|
|
157
|
-
|
|
158
|
-
# Adding some useful information to the params hash
|
|
159
|
-
params[:request_url] = request.url
|
|
160
|
-
params[:remote_ip] = request.remote_ip
|
|
161
|
-
params[:request_verb] = request.request_method
|
|
162
|
-
params[:token] = token.presence || bearer_token
|
|
163
|
-
# The endpoint can be expressed in two ways:
|
|
164
|
-
# 1. As a method in the model, with suffix custom_action_<custom_action>
|
|
165
|
-
# 2. As a module instance method in the model, like Track::Endpoints.inventory
|
|
166
|
-
# Example:
|
|
167
|
-
# Endpoints::TestApi.new(:test, {request_verb: "POST", is_connected: "Uhhhh"}).result
|
|
168
|
-
Rails.logger.debug("Checking for custom action #{custom_action} in #{@model}")
|
|
169
|
-
if @model.respond_to?("custom_action_#{custom_action}")
|
|
170
|
-
body, status = @model.send("custom_action_#{custom_action}", params)
|
|
171
|
-
elsif ("Endpoints::#{@model}".constantize rescue false) && "Endpoints::#{@model}".constantize.instance_methods.include?(custom_action.to_sym)
|
|
172
|
-
# Custom endpoint exists and can be called in the sub-modules form
|
|
173
|
-
body, status = "Endpoints::#{@model}".constantize.new(custom_action, params).result
|
|
174
|
-
else
|
|
175
|
-
# Custom endpoint does not exist or cannot be called
|
|
176
|
-
raise NoMethodError
|
|
177
|
-
end
|
|
178
|
-
|
|
179
|
-
return true, body.to_json(json_attrs), status
|
|
143
|
+
dispatched, body, status = Api::CustomActionDispatcher.call(@model, params, request)
|
|
144
|
+
return false unless dispatched
|
|
145
|
+
[true, body.to_json(json_attrs), status]
|
|
180
146
|
end
|
|
181
147
|
|
|
182
148
|
def bearer_token
|
|
@@ -229,17 +195,10 @@ class Api::V2::ApplicationController < ActionController::API
|
|
|
229
195
|
end
|
|
230
196
|
|
|
231
197
|
def extract_model
|
|
232
|
-
|
|
233
|
-
# For any other model-less controller, the actions must be
|
|
234
|
-
# defined in the route, and must exist in the controller definition.
|
|
235
|
-
# So, if it's not an activerecord, the find model makes no sense at all
|
|
236
|
-
# thus must return 404.
|
|
237
|
-
@model = (params[:ctrl].classify.constantize rescue params[:path].split("/").first.classify.constantize rescue controller_path.classify.constantize rescue controller_name.classify.constantize rescue nil)
|
|
238
|
-
# Getting the body of the request if it exists, it's ok the singular or
|
|
239
|
-
# plural form, this helps with automatic tests with Insomnia.
|
|
198
|
+
@model = Api::ModelResolver.resolve(params, controller_path, controller_name)
|
|
240
199
|
@body = (params[@model.model_name.singular].presence || params[@model.model_name.route_key]) rescue params
|
|
241
|
-
|
|
242
|
-
|
|
200
|
+
rescue Api::ModelResolver::NotFound
|
|
201
|
+
not_found!
|
|
243
202
|
end
|
|
244
203
|
|
|
245
204
|
def check_authorization(cmd)
|