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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 18a706d49e3ccea7baed117aa8e3f7912c9a1480882e924a3675dc7b7133c459
4
- data.tar.gz: 66ce008c9ca7f0f240da9c424336fd46fb151e9a8076db5f76b322bb79348583
3
+ metadata.gz: b4757c81ca4ae3c4e59c12a96f5ddf23801121fc8ac3a7cde16ca3a8f2d47208
4
+ data.tar.gz: 2c342468dd9879aec91f2dd6bc49c5dba639dab0a8841583d6032377ef937e78
5
5
  SHA512:
6
- metadata.gz: 965dfd5cf0fb7075b6cb6c3b21dc1ccbccf9b86e46f31c317fcf5248462eed6954e807dff3b9732a9b95e7ffcc1449fc892e7bfc21b7cba960c556f08a698d23
7
- data.tar.gz: a1c2735b2a86a31b1918eb1f7366415a594dbc0f4b2ea6474f240f3534f274371f00692c173d9cbe73a0ab2f191321ed739e4edc73a2512fa1745fb7cab3adcf
6
+ metadata.gz: 0b36be5d13960e0166eaa31fc698745a1ba9d3d69ed4b49ca91540af7d112e7ce0bf39ff0cc87cb2e1c973b21add87a1e9ef9e18eaa3fc7adf81b82c268f745f
7
+ data.tar.gz: fe831b9b04fe80dfbd3db57e41d1b999035ab676f5d602904fa8e759af95504b9ff6d4d6ea91d947eef4dbe85a54afa62a1f9c30c17aa092191cbec960dc3ff0
data/README.md CHANGED
@@ -1,66 +1,533 @@
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
148
+
149
+ # JWT
150
+ SECRET_KEY_BASE=...
151
+ SESSION_TIMEOUT_IN_MINUTES=31
47
152
 
48
- # Google OAuth
49
- GOOGLE_CLIENT_ID: "your-client-id.apps.googleusercontent.com"
50
- GOOGLE_CLIENT_SECRET: "your-client-secret"
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
+ ## License
532
+
533
+ MIT
data/Rakefile CHANGED
@@ -3,6 +3,9 @@
3
3
  require "bundler/gem_tasks"
4
4
  require "rake/testtask"
5
5
 
6
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
7
+ load "rails/tasks/engine.rake"
8
+
6
9
  Rake::TestTask.new(:test) do |t|
7
10
  t.libs << "test"
8
11
  t.libs << "lib"
@@ -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
- custom_action, token = if !params[:do].blank?
144
- # This also responds to custom actions which have the bearer token in the custom action name. A workaround to remove for some IoT devices
145
- # Which don't support token in header or in querystring
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
- # This method is only valid for ActiveRecords
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
- # Only ActiveRecords can have this model caputed
242
- return not_found! if (@model != TestApi && !@model.new.is_a?(ActiveRecord::Base) rescue false)
200
+ rescue Api::ModelResolver::NotFound
201
+ not_found!
243
202
  end
244
203
 
245
204
  def check_authorization(cmd)