model_driven_api 3.6.2 → 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.
@@ -65,1307 +65,6 @@ class Api::V2::InfoController < Api::V2::ApplicationController
65
65
  render json: pivot.to_json, status: 200
66
66
  end
67
67
 
68
- def compute_type(model, key)
69
- Rails.logger.debug "compute_type #{model} #{key}"
70
- # if it's a file, a date or a text, then return string
71
- instance = model.new
72
- # If it's a method, it is a peculiar case, in which we have to return "object" and additionalProperties: true
73
- return "method" if model.methods.include?(:json_attrs) && model.json_attrs && model.json_attrs.include?(:methods) && model.json_attrs[:methods].include?(key.to_sym)
74
- # If it's not the case of a method, then it's a field
75
- method_class = instance.send(key).class.to_s
76
- Rails.logger.debug "compute_type #{model} #{key} #{method_class}"
77
- method_key = model.columns_hash[key]
78
-
79
- # Not columns
80
- return nil if method_key.nil?
81
- return "object" if method_class == "ActiveStorage::Attached::One"
82
- return "array" if method_class == "ActiveStorage::Attached::Many" || method_class == "Array" || method_class.ends_with?("Array") || method_class.ends_with?("Collection") || method_class.ends_with?("Relation") || method_class.ends_with?("Set") || method_class.ends_with?("List") || method_class.ends_with?("Queue") || method_class.ends_with?("Stack") || method_class.ends_with?("ActiveRecord_Associations_CollectionProxy")
83
-
84
- # Columns
85
- case method_key.type
86
- when :json, :jsonb
87
- return "object"
88
- when :enum
89
- return "array"
90
- when :text, :hstore
91
- return "string"
92
- when :decimal, :float, :bigint
93
- return "number"
94
- end
95
- method_key.type.to_s
96
- end
97
-
98
- def integer?(str)
99
- true if Integer(str) rescue false
100
- end
101
-
102
- def number?(str)
103
- true if Float(str) rescue false
104
- end
105
-
106
- def datetime?(str)
107
- true if DateTime.parse(str) rescue false
108
- end
109
-
110
- def create_properties_from_model(model, dsl, remove_reserved = false)
111
- parsed_json = JSON.parse(model.new.to_json(dsl))
112
- parsed_json.keys.map do |k|
113
- type = compute_type(model, k)
114
-
115
- # Remove fields that cannot be created or updated
116
- if remove_reserved && %w( id created_at updated_at lock_version ).include?(k.to_s)
117
- nil
118
- elsif type == "method" && (parsed_json[k].is_a?(FalseClass) || parsed_json[k].is_a?(TrueClass))
119
- [k, { "type": "boolean" }]
120
- elsif type == "method" && parsed_json[k].is_a?(String) && number?(parsed_json[k])
121
- [k, { "type": "number" }]
122
- elsif type == "method" && parsed_json[k].is_a?(String) && integer?(parsed_json[k])
123
- [k, { "type": "integer" }]
124
- elsif type == "method" && parsed_json[k].is_a?(String) && datetime?(parsed_json[k])
125
- [k, { "type": "string", "format": "date-time" }]
126
- elsif type == "method"
127
- # Unknown or complex format returned
128
- [k, { "type": "object", "additionalProperties": true }]
129
- elsif type == "date"
130
- [k, { "type": "string", "format": "date" }]
131
- elsif type == "datetime"
132
- [k, { "type": "string", "format": "date-time" }]
133
- elsif type == "object" && (k.classify.constantize rescue false)
134
- sub_model = k.classify.constantize
135
- properties = dsl[:include].present? && dsl[:include].include?(k) ? create_properties_from_model(sub_model, dsl[:include][k.to_sym]) : create_properties_from_model(sub_model, {})
136
- [k, { "type": "object", "properties": properties }] rescue nil
137
- elsif type == "array" && (k.classify.constantize rescue false)
138
- sub_model = k.classify.constantize
139
- properties = dsl[:include].present? && dsl[:include].include?(k) ? create_properties_from_model(sub_model, dsl[:include][k.to_sym]) : create_properties_from_model(sub_model, {})
140
- [k, { "type": "array", "items": { "type": "object", "properties": properties } }] rescue nil
141
- else
142
- [k, { "type": type }]
143
- end unless type.blank?
144
- end.compact.to_h
145
- end
146
-
147
- def generate_paths
148
- pivot = {
149
- "/authenticate": {
150
- "post": {
151
- "summary": "Authenticate",
152
- "tags": ["Authentication"],
153
- "description": "Authenticate the user and return a JWT token in the header and the current user as body.",
154
- "security": [
155
- "basicAuth": [],
156
- ],
157
- "requestBody": {
158
- "content": {
159
- "application/json": {
160
- "schema": {
161
- "type": "object",
162
- "properties": {
163
- "auth": {
164
- "type": "object",
165
- "properties": {
166
- "email": {
167
- "type": "string",
168
- "format": "email",
169
- },
170
- "password": {
171
- "type": "string",
172
- "format": "password",
173
- },
174
- },
175
- },
176
- },
177
- "required": ["email", "password"],
178
- },
179
- },
180
- },
181
- },
182
- "responses": {
183
- "200": {
184
- "description": "User authenticated",
185
- "headers": {
186
- "token": {
187
- "description": "JWT",
188
- "schema": {
189
- "type": "string",
190
- },
191
- },
192
- },
193
- "content": {
194
- "application/json": {
195
- "schema": {
196
- "type": "object",
197
- # ["id", "email", "created_at", "admin", "locked", "supplier_id", "location_id", "roles"]
198
- "properties": create_properties_from_model(User, User.json_attrs),
199
- },
200
- },
201
- },
202
- },
203
- "401": {
204
- "description": "Unauthorized",
205
- },
206
- },
207
- },
208
- },
209
- "/raw/sql": {
210
- "post": {
211
- "summary": "Raw SQL query execution of SELECT queries",
212
- "description": "Executes a SQL query on the underlying PostgreSQL database, the query must return the JSON in a **result** key (please note in the examples the _SELECT json_agg(u) AS result_ or in the more complex one the _SELECT jsonb_agg(pick_data) AS result_, they always use the **result** return object).\n \nDesigned for SELECT queries that use the json_agg function to aggregate results into JSON arrays, which must return the JSON in a **result** key.\n \nOther query types are not recommended and may be restricted for security and performance reasons.\n \nOnly SELECT statements are allowed. DDL and DML statements (INSERT, UPDATE, DELETE) are forbidden.\n \nQueries can be as simple as:\n \n```sql\nSELECT json_agg(u) AS result\nFROM users u\nWHERE u.active = true;\n```\n \nor more complex, using joins, subqueries, CTEs, and other SQL features. like:\n \n```sql\nWITH pick_data AS (\n SELECT p.id,\n p.project_id,\n p.quantity,\n p.created_at,\n p.updated_at,\n p.notes,\n p.document_id,\n p.external_code,\n p.reference_project_id,\n p.reference_row,\n p.closed,\n p.parent_reference_row,\n p.packages,\n p.weight,\n p.dispatched_quantity,\n p.override_item_reference,\n p.override_item_description,\n p.override_item_measure_unit,\n p.lock_version,\n p.user_id,\n COALESCE(SUM(pr.quantity), 0) AS quantity_detected,\n COALESCE(p.quantity, 0) - COALESCE(SUM(pr.quantity), 0) AS quantity_remaining,\n json_agg(\n jsonb_build_object(\n 'id',\n pr.id,\n 'item_id',\n pr.item_id,\n 'location_id',\n pr.location_id,\n 'quantity',\n pr.quantity\n )\n ) AS project_rows,\n jsonb_build_object(\n 'id',\n l.id,\n 'name',\n l.name,\n 'description',\n l.description\n ) AS location,\n jsonb_build_object(\n 'id',\n i.id,\n 'code',\n i.code,\n 'created_at',\n i.created_at,\n 'updated_at',\n i.updated_at,\n 'description',\n i.description,\n 'has_serials',\n i.has_serials,\n 'external_code',\n i.external_code,\n 'barcode',\n i.barcode,\n 'weight',\n i.weight,\n 'quantity',\n i.quantity,\n 'package_quantity',\n i.package_quantity,\n 'locked_quantity',\n i.locked_quantity,\n 'disabled',\n i.disabled,\n 'measure_unit',\n jsonb_build_object('id', mu.id, 'name', mu.name),\n 'location',\n jsonb_build_object('id', il.id, 'name', il.name),\n 'locations',\n (\n SELECT jsonb_agg(\n jsonb_build_object('id', loc.id, 'name', loc.name)\n )\n FROM locations loc\n JOIN item_locations il ON il.location_id = loc.id\n WHERE il.item_id = i.id\n ),\n 'additional_barcodes',\n (\n SELECT jsonb_agg(\n jsonb_build_object('id', ab.id, 'code', ab.code)\n )\n FROM additional_barcodes ab\n WHERE ab.item_id = i.id\n )\n ) AS item\n FROM picks p\n LEFT JOIN project_rows pr ON pr.pick_id = p.id\n LEFT JOIN locations l ON l.id = p.location_id\n LEFT JOIN items i ON i.id = p.item_id\n LEFT JOIN measure_units mu ON mu.id = i.measure_unit_id\n LEFT JOIN locations il ON il.id = i.location_id\n WHERE p.project_id = 16130\n GROUP BY p.id,\n l.id,\n i.id,\n mu.id,\n il.id\n)\nSELECT jsonb_agg(pick_data) AS result\nFROM pick_data;\n```\n \nLet's break down the provided SQL query and understand why it uses a Common Table Expression (CTE) and how it can improve performance.\n \n### Explanation of the complex Query\n \nThe provided query uses a CTE named `pick_data` to gather and aggregate data from multiple tables (`picks`, `project_rows`, `locations`, `items`, `measure_units`, `item_locations`, and `additional_barcodes`). The final result is a JSON array of aggregated data.\n \n#### Key Components of the Query:\n \n1. **CTE Definition**:\n \n ```sql\n WITH pick_data AS (\n -- Subquery content\n )\n ```\n \n The CTE `pick_data` is defined to encapsulate the logic of the subquery. This makes the query more readable and modular.\n \n2. **Data Selection and Aggregation**:\n Inside the CTE, data is selected and aggregated from various tables. Key operations include:\n \n - **Column Selection**: Selecting specific columns from the `picks` table.\n - **Aggregation**: Using `COALESCE` and `SUM` to calculate `quantity_detected` and `quantity_remaining`.\n - **JSON Aggregation**: Using `json_agg` and `jsonb_build_object` to create JSON objects and arrays for nested data structures.\n \n3. **Final Selection**:\n ```sql\n SELECT jsonb_agg(pick_data) AS result FROM pick_data;\n ```\n The final selection aggregates all rows from the CTE `pick_data` into a single JSON array.\n \n### Why Use a CTE?\n \n1. **Readability and Maintainability**:\n \n - **Modular Code**: By using a CTE, the complex logic is encapsulated in a named subquery, making the main query easier to read and understand.\n - **Reusability**: The CTE can be reused within the same query if needed, avoiding duplication of code.\n \n2. **Performance**:\n - **Optimization**: Modern SQL engines can optimize CTEs effectively. They can be materialized (computed once and stored) or inlined (expanded in the main query) based on the query plan.\n - **Intermediate Results**: CTEs allow breaking down complex queries into simpler steps, which can sometimes help the SQL engine optimize each step more effectively.\n \n### Documentation for Editing Generic Queries\n \nWhen editing or creating new queries, consider the following steps and best practices:\n \n1. **Identify the Purpose**:\n \n - Clearly define what the query needs to achieve. Understand the data relationships and the final output format.\n \n2. **Use CTEs for Complex Logic**:\n \n - Break down complex queries into smaller, manageable parts using CTEs. This improves readability and maintainability.\n \n3. **Optimize Aggregations and Joins**:\n \n - Ensure that aggregations and joins are optimized. Use indexes where appropriate and avoid unnecessary computations.\n \n4. **Leverage JSON Functions**:\n \n - Use JSON functions (`json_agg`, `jsonb_build_object`, etc.) to handle nested data structures effectively.\n \n5. **Test and Validate**:\n - Test the query with different datasets to ensure it performs well and returns the correct results. Validate the output format.\n \n### Example of a Generic Query Using CTE\n \nHere's a generic example to illustrate how to use a CTE in a query:\n \n```sql\n \n\nWITH data_aggregation AS (\n SELECT\n t1.id,\n t1.name,\n SUM(t2.value) AS total_value,\n json_agg(\n jsonb_build_object(\n 'id', t2.id,\n 'value', t2.value\n )\n ) AS details\n FROM table1 t1\n LEFT JOIN table2 t2 ON t2.table1_id = t1.id\n GROUP BY t1.id\n)\nSELECT jsonb_agg(data_aggregation) AS result FROM data_aggregation;\n```\n \n### Conclusion\n \nUsing CTEs in SQL queries helps in organizing complex logic, improving readability, and potentially enhancing performance. When editing or creating new queries, follow best practices such as breaking down complex logic, optimizing joins and aggregations, and leveraging JSON functions for nested data structures.\n",
213
- "tags": ["Raw"],
214
- "security": [
215
- "bearerAuth": [],
216
- ],
217
- "responses": {
218
- "200": {
219
- "description": "SQL Query Result",
220
- "content": {
221
- "application/json": {
222
- "schema": {
223
- "type": "array",
224
- "items": {
225
- "type": "object",
226
- "properties": {
227
- "json_agg": {
228
- "type": "string",
229
- },
230
- },
231
- },
232
- },
233
- },
234
- },
235
- },
236
- "400": {
237
- "description": "SQL query must return a key called result otherwise cannot be parsed",
238
- "content": {
239
- "application/json": {
240
- "schema": {
241
- "type": "object",
242
- "properties": {
243
- "error": {
244
- "type": "string",
245
- },
246
- },
247
- },
248
- },
249
- },
250
- },
251
-
252
- },
253
- "requestBody": {
254
- "content": {
255
- "application/json": {
256
- "schema": {
257
- "type": "object",
258
- "properties": {
259
- "query": {
260
- "type": "string",
261
- "example": "SELECT json_agg(u) FROM users u WHERE u.active = true;",
262
- },
263
- },
264
- },
265
- },
266
- },
267
- },
268
- },
269
- },
270
- "/info/version": {
271
- "get": {
272
- "summary": "Version",
273
- "description": "Just prints the APPVERSION",
274
- "tags": ["Info"],
275
- "responses": {
276
- "200": {
277
- "description": "APPVERSION",
278
- "content": {
279
- "application/json": {
280
- "schema": {
281
- "type": "string",
282
- },
283
- },
284
- },
285
- },
286
- },
287
- },
288
- },
289
- "/info/heartbeat": {
290
- "get": {
291
- "summary": "Heartbeat",
292
- "description": "Just keeps the session alive by returning a new token",
293
- "tags": ["Info"],
294
- "security": [
295
- "bearerAuth": [],
296
- ],
297
- "responses": {
298
- "200": {
299
- "description": "Session alive",
300
- "headers": {
301
- "token": {
302
- "description": "JWT",
303
- "schema": {
304
- "type": "string",
305
- },
306
- },
307
- },
308
- },
309
- },
310
- },
311
- },
312
- "/info/roles": {
313
- "get": {
314
- "summary": "Roles",
315
- "description": "Returns the roles list",
316
- "tags": ["Info"],
317
- "security": [
318
- "bearerAuth": [],
319
- ],
320
- "responses": {
321
- "200": {
322
- "description": "Roles list",
323
- "content": {
324
- "application/json": {
325
- "schema": {
326
- "type": "array",
327
- "items": {
328
- "type": "object",
329
- "properties": {
330
- "id": {
331
- "type": "integer",
332
- },
333
- "name": {
334
- "type": "string",
335
- },
336
- "description": {
337
- "type": "string",
338
- },
339
- },
340
- },
341
- },
342
- },
343
- },
344
- },
345
- },
346
- },
347
- },
348
- "/info/schema": {
349
- "get": {
350
- "summary": "Schema",
351
- "description": "Returns the schema of the models",
352
- "tags": ["Info"],
353
- "security": [
354
- "bearerAuth": [],
355
- ],
356
- "responses": {
357
- "200": {
358
- "description": "Schema of the models",
359
- "content": {
360
- "application/json": {
361
- "schema": {
362
- "type": "array",
363
- "items": {
364
- "type": "object",
365
- "properties": {
366
- "id": {
367
- "type": "integer",
368
- },
369
- "created_at": {
370
- "type": "string",
371
- "format": "date-time",
372
- },
373
- "updated_at": {
374
- "type": "string",
375
- "format": "date-time",
376
- },
377
- },
378
- },
379
- },
380
- },
381
- },
382
- },
383
- },
384
- },
385
- },
386
- "/info/dsl": {
387
- "get": {
388
- "summary": "DSL",
389
- "description": "Returns the DSL of the models",
390
- "tags": ["Info"],
391
- "security": [
392
- "bearerAuth": [],
393
- ],
394
- "responses": {
395
- "200": {
396
- "description": "DSL of the models",
397
- "content": {
398
- "application/json": {
399
- "schema": {
400
- "type": "object",
401
- "properties": {
402
- "id": {
403
- "type": "integer",
404
- },
405
- "created_at": {
406
- "type": "string",
407
- "format": "date-time",
408
- },
409
- "updated_at": {
410
- "type": "string",
411
- "format": "date-time",
412
- },
413
- },
414
- },
415
- },
416
- },
417
- },
418
- },
419
- },
420
- },
421
- "/info/translations": {
422
- "get": {
423
- "summary": "Translations",
424
- "description": "Returns the translations of the entire App",
425
- "tags": ["Info"],
426
- "security": [
427
- "bearerAuth": [],
428
- ],
429
- "responses": {
430
- "200": {
431
- "description": "Translations",
432
- "content": {
433
- "application/json": {
434
- "schema": {
435
- "type": "object",
436
- "properties": {
437
- "key": {
438
- "type": "string",
439
- },
440
- "value": {
441
- "type": "string",
442
- },
443
- },
444
- },
445
- },
446
- },
447
- },
448
- },
449
- },
450
- },
451
- "/info/settings": {
452
- "get": {
453
- "summary": "Settings",
454
- "description": "Returns the settings of the App",
455
- "tags": ["Info"],
456
- "security": [
457
- "bearerAuth": [],
458
- ],
459
- "responses": {
460
- "200": {
461
- "description": "Settings",
462
- "content": {
463
- "application/json": {
464
- "schema": {
465
- "type": "object",
466
- "properties": {
467
- "ns": {
468
- "type": "object",
469
- "properties": {
470
- "key": {
471
- "type": "string",
472
- },
473
- "value": {
474
- "type": "string",
475
- },
476
- },
477
- },
478
- },
479
- },
480
- },
481
- },
482
- },
483
- },
484
- },
485
- },
486
- "/info/swagger": {
487
- "get": {
488
- "summary": "Swagger",
489
- "description": "Returns the self generated Swagger for all the models in the App.",
490
- "tags": ["Info"],
491
- "responses": {
492
- "200": {
493
- "description": "Swagger",
494
- "content": {
495
- "application/json": {
496
- "schema": {
497
- "type": "object",
498
- "properties": {
499
- "id": {
500
- "type": "integer",
501
- },
502
- "created_at": {
503
- "type": "string",
504
- "format": "date-time",
505
- },
506
- "updated_at": {
507
- "type": "string",
508
- "format": "date-time",
509
- },
510
- },
511
- },
512
- },
513
- },
514
- },
515
- },
516
- },
517
- },
518
- }
519
- ApplicationRecord.subclasses.sort_by { |d| d.to_s }.each do |d|
520
- # Only if current user can read the model
521
- if true # can? :read, d
522
- model = d.to_s.underscore.tableize
523
- # CRUD and Search endpoints
524
- pivot["/#{model}"] = {
525
- "get": {
526
- "summary": "Index",
527
- "description": "Returns the list of #{model}",
528
- "tags": [model.classify],
529
- "security": [
530
- "bearerAuth": [],
531
- ],
532
- "responses": {
533
- "200": {
534
- "description": "List of #{model}",
535
- "content": {
536
- "application/json": {
537
- "schema": {
538
- "type": "array",
539
- "items": {
540
- "type": "object",
541
- "properties": create_properties_from_model(d, (d.json_attrs rescue {})),
542
- },
543
- },
544
- },
545
- },
546
- },
547
- "404": {
548
- "description": "No #{model} found",
549
- },
550
- },
551
- },
552
- "post": {
553
- "summary": "Create",
554
- "description": "Creates a new #{model}",
555
- "tags": [model.classify],
556
- "security": [
557
- "bearerAuth": [],
558
- ],
559
- "requestBody": {
560
- "content": {
561
- "application/json": {
562
- "schema": {
563
- "type": "object",
564
- "properties": {
565
- "#{model.singularize}": {
566
- "type": "object",
567
- "properties": create_properties_from_model(d, {}, true),
568
- },
569
- },
570
- },
571
- },
572
- },
573
- },
574
- "responses": {
575
- "200": {
576
- "description": "#{model} Created",
577
- "content": {
578
- "application/json": {
579
- "schema": {
580
- "type": "object",
581
- "properties": create_properties_from_model(d, (d.json_attrs rescue {})),
582
- },
583
- },
584
- },
585
- },
586
- },
587
- },
588
- }
589
- # Non CRUD or Search, but custom, usually bulk operations endpoints
590
- new_custom_actions = ("Endpoints::#{d.model_name.name}".constantize.instance_methods(false) rescue [])
591
- Rails.logger.debug "New Custom Actions (#{d.model_name.name}): #{new_custom_actions}"
592
- new_custom_actions.each do |action|
593
- openapi_definition = "Endpoints::#{d.model_name.name}".constantize.definitions[d.model_name.name][action.to_sym] rescue []
594
-
595
- # Add the tag to the openapi definition
596
- openapi_definition.each do |k, v|
597
- v[:tags] = [d.model_name.name]
598
- end
599
-
600
- pivot["/#{model}/custom_action/#{action}"] = openapi_definition if openapi_definition
601
- end
602
- pivot["/#{model}/search"] = {
603
- # Complex queries are made using ranskac search via a post endpoint
604
- "post": {
605
- "summary": "Search",
606
- "description": "Searches the #{model} using complex queries. Please refer to the [documentation](https://activerecord-hackery.github.io/ransack/) for the query syntax and to the general description of this swagger document to discover the usage of other, non ransack predicates for example to count records, select only some fields and more.",
607
- "tags": [model.classify],
608
- "security": [
609
- "bearerAuth": [],
610
- ],
611
- "requestBody": {
612
- "content": {
613
- "application/json": {
614
- "schema": {
615
- "type": "object",
616
- "properties": {
617
- "q": {
618
- "type": "object",
619
- "properties": {
620
- "name_or_description_cont": {
621
- "type": "string",
622
- },
623
- "first_name_eq": {
624
- "type": "string",
625
- },
626
- },
627
- },
628
- },
629
- },
630
- },
631
- },
632
- },
633
- "responses": {
634
- "200": {
635
- "description": "List of #{model}",
636
- "content": {
637
- "application/json": {
638
- "schema": {
639
- "type": "array",
640
- "items": {
641
- "type": "object",
642
- "properties": create_properties_from_model(d, (d.json_attrs rescue {})),
643
- },
644
- },
645
- },
646
- },
647
- },
648
- "404": {
649
- "description": "No #{model} found",
650
- },
651
- },
652
- },
653
- }
654
- pivot["/#{model}/{id}"] = {
655
- "put": {
656
- "summary": "Update",
657
- "description": "Updates the complete #{model}",
658
- "parameters": [
659
- {
660
- "name": "id",
661
- "in": "path",
662
- "required": true,
663
- "schema": {
664
- "type": "integer",
665
- },
666
- },
667
- ],
668
- "tags": [model.classify],
669
- "security": [
670
- "bearerAuth": [],
671
- ],
672
- "requestBody": {
673
- "content": {
674
- "application/json": {
675
- "schema": {
676
- "type": "object",
677
- "properties": {
678
- "#{model.singularize}": {
679
- "type": "object",
680
- "properties": create_properties_from_model(d, {}, true),
681
- },
682
- },
683
- },
684
- },
685
- },
686
- },
687
- "responses": {
688
- "200": {
689
- "description": "#{model} Updated",
690
- "content": {
691
- "application/json": {
692
- "schema": {
693
- "type": "object",
694
- "properties": create_properties_from_model(d, (d.json_attrs rescue {})),
695
- },
696
- },
697
- },
698
- },
699
- "404": {
700
- "description": "No #{model} found",
701
- },
702
- },
703
- },
704
- "patch": {
705
- "summary": "Patch",
706
- "description": "Updates the partial #{model}",
707
- "parameters": [
708
- {
709
- "name": "id",
710
- "in": "path",
711
- "required": true,
712
- "schema": {
713
- "type": "integer",
714
- },
715
- },
716
- ],
717
- "tags": [model.classify],
718
- "security": [
719
- "bearerAuth": [],
720
- ],
721
- "requestBody": {
722
- "content": {
723
- "application/json": {
724
- "schema": {
725
- "type": "object",
726
- "properties": {
727
- "#{model.singularize}": {
728
- "type": "object",
729
- "properties": create_properties_from_model(d, {}, true),
730
- },
731
- },
732
- },
733
- },
734
- },
735
- },
736
- "responses": {
737
- "200": {
738
- "description": "#{model} Patched",
739
- "content": {
740
- "application/json": {
741
- "schema": {
742
- "type": "object",
743
- "properties": create_properties_from_model(d, (d.json_attrs rescue {})),
744
- },
745
- },
746
- },
747
- },
748
- "404": {
749
- "description": "No #{model} found",
750
- },
751
- },
752
- },
753
- "delete": {
754
- "summary": "Delete",
755
- "description": "Deletes the #{model}",
756
- "parameters": [
757
- {
758
- "name": "id",
759
- "in": "path",
760
- "required": true,
761
- "schema": {
762
- "type": "integer",
763
- },
764
- },
765
- ],
766
- "tags": [model.classify],
767
- "security": [
768
- "bearerAuth": [],
769
- ],
770
- "responses": {
771
- "200": {
772
- "description": "#{model} Deleted",
773
- },
774
- "404": {
775
- "description": "No #{model} found",
776
- },
777
- },
778
- },
779
- "get": {
780
- "summary": "Show",
781
- "description": "Shows the #{model}",
782
- "parameters": [
783
- {
784
- "name": "id",
785
- "in": "path",
786
- "required": true,
787
- "schema": {
788
- "type": "integer",
789
- },
790
- },
791
- ],
792
- "tags": [model.classify],
793
- "security": [
794
- "bearerAuth": [],
795
- ],
796
- "responses": {
797
- "200": {
798
- "description": "Show #{model}",
799
- "content": {
800
- "application/json": {
801
- "schema": {
802
- "type": "object",
803
- "properties": create_properties_from_model(d, (d.json_attrs rescue {})),
804
- },
805
- },
806
- },
807
- },
808
- "404": {
809
- "description": "No #{model} found",
810
- },
811
- },
812
- },
813
- }
814
- # d.columns_hash.each_pair do |key, val|
815
- # pivot[model][key] = val.type unless key.ends_with? "_id"
816
- # end
817
- # # Only application record descendants in order to have a clean schema
818
- # pivot[model][:associations] ||= {
819
- # has_many: d.reflect_on_all_associations(:has_many).map { |a|
820
- # a.name if (((a.options[:class_name].presence || a.name).to_s.classify.constantize.new.is_a? ApplicationRecord) rescue false)
821
- # }.compact,
822
- # belongs_to: d.reflect_on_all_associations(:belongs_to).map { |a|
823
- # a.name if (((a.options[:class_name].presence || a.name).to_s.classify.constantize.new.is_a? ApplicationRecord) rescue false)
824
- # }.compact
825
- # }
826
- # pivot[model][:methods] ||= (d.instance_methods(false).include?(:json_attrs) && !d.json_attrs.blank?) ? d.json_attrs[:methods] : nil
827
- end
828
- end
829
- pivot
830
- end
831
-
832
- def info_description
833
- info = <<-MARKDOWN
834
-
835
- ## About this API Documentation
836
-
837
- Model Driven Backend [API](https://github.com/gabrieletassoni/thecore/blob/master/docs/04_REST_API.md) created to reflect the actual Active Record Models present in the project in a dynamic way.
838
-
839
- This swagger describes all the CRUD endpoints provided by the application, as well as all the custom endpoints and gives a deep dive into the parameters accepted in GET (Index) requests and POST (Search) requests. Since the controller unifies params (via request.parameters), the filtering logic is identical whether parameters are passed in the Query String (GET) or the JSON Body (POST).
840
-
841
- The documentation starts from the authentication mechanism, integrated with the details from the provided code.
842
- Here is the updated and integrated documentation for the Authentication mechanism. It now covers the full lifecycle: obtaining the initial token via the login endpoint and maintaining the session via the sliding expiration mechanism found in the controller.
843
-
844
- ---
845
-
846
- ## Authentication & Token Management
847
-
848
- The API implements a **stateless JWT (JSON Web Token)** authentication mechanism. It consists of two distinct phases:
849
-
850
- 1. **Initial Authentication:** Exchanging credentials for the first Token.
851
- 2. **Session Maintenance:** Using a **Sliding Expiration** strategy where every subsequent successful request issues a fresh token.
852
-
853
- ### 1. Initial Authentication (Login)
854
-
855
- To begin a session, the client must POST user credentials to the authentication endpoint. This is the only request that does not require an `Authorization` header.
856
-
857
- #### Request
858
-
859
- **Endpoint:** `POST /api/v2/authenticate`
860
-
861
- **Body:**
862
-
863
- ```json
864
- {
865
- "auth": {
866
- "email": "admin@example.com",
867
- "password": "Change#1"
868
- }
869
- }
870
-
871
- ```
872
-
873
- #### Response
874
-
875
- Upon successful authentication, the server returns two critical pieces of data:
876
-
877
- 1. **Response Body:** Contains the User object details.
878
- 2. **Response Headers:** Contains the initial JWT in the `token` header.
879
-
880
- **Example Body:**
881
-
882
- ```json
883
- {
884
- "id": 219,
885
- "email": "admin@example.com",
886
- "created_at": "2025-12-10T07:57:54.336Z",
887
- "admin": true,
888
- "locked": false,
889
- "locale": "en",
890
- // ... other user attributes
891
- "roles": []
892
- }
893
-
894
- ```
895
-
896
- **Example Headers:**
897
- Note the presence of the `token` header.
898
-
899
- ```http
900
- HTTP/1.1 200 OK
901
- content-type: application/json; charset=utf-8
902
- token: eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoyMTksImV4cCI6MTc2NjQ3ODMwN30.I0qJzOwA0Jxx0frL5-9jVH2PsakdZjSEY8Kqb9S3GKo
903
- x-request-id: 113cad63-11f8-4daf-b684-19322a053bcc
904
- ...
905
-
906
- ```
907
-
908
- ---
909
-
910
- ### 2. Sliding Expiration (Token Renewal)
911
-
912
- Once the client has the initial token, the `Api::V2::ApplicationController` handles the lifecycle. Instead of a fixed expiration time that forces a re-login, the API issues a **brand new token** with every successfully authenticated request.
913
-
914
- #### The Renewal Mechanism
915
-
916
- 1. **Client Request:** The client sends the *current* token in the `Authorization` header.
917
- ```http
918
- Authorization: Bearer <Current_Token>
919
-
920
- ```
921
-
922
-
923
- 2. **Verification:** The `authenticate_request` method verifies the token. If valid, it sets `@current_user`.
924
- 3. **Renewal:** Before sending the response, the controller generates a new JWT encoded with the current user's ID and sets it in the response header.
925
- ```ruby
926
- response.set_header("Token", JsonWebToken.encode(user_id: current_user.id))
927
-
928
- ```
929
-
930
- #### Client-Side Implementation Guide
931
-
932
- To maintain a valid session, the client must implement an interceptor to handle token rotation:
933
-
934
- 1. **Login:** Call `/api/v2/authenticate` and store the `token` from the response header.
935
- 2. **Subsequent Requests:** Attach the stored token to the `Authorization: Bearer ...` header.
936
- 3. **Update Storage:**
937
- * Check every response for a `Token` header.
938
- * **If present:** Immediately replace the stored token with this new value.
939
- * **If missing:** Continue using the existing token (unless the response was a 401/403 error).
940
-
941
- #### Failure Scenarios
942
-
943
- If the `authenticate_request` fails (e.g., token expired, invalid signature):
944
-
945
- * The controller returns an unauthenticated error (`unauthenticated!`).
946
- * The execution halts, and the line generating the new header is never reached.
947
- * **Result:** The client receives a 401 error and **no new token**, signaling that the user must perform the **Initial Authentication** (login) again.
948
-
949
- ---
950
-
951
- ## API Documentation: Search, Filtering, and Pagination Parameters
952
-
953
- ### 1. Pagination and Counting
954
-
955
- These parameters control the amount of data returned and navigation through result pages.
956
-
957
- * **`page`** (Integer): Indicates the page number to retrieve. If omitted, pagination is not applied (or defaults to the model's Kaminari configuration).
958
- * **`per`** (Integer): Indicates the number of records per page. Works in conjunction with `page`.
959
- * **`count`** (Boolean/Any value): If present and not empty, the API **does not** return the list of records but a JSON object containing only the total count of records matching the search criteria (e.g., `{ "count": 42 }`). Useful for displaying total results before loading them.
960
-
961
- ### 2. Field Selection
962
-
963
- It is possible to limit the fields returned in the JSON response or include associations. The controller looks for these parameters in `a` or `json_attrs`.
964
-
965
- * **`a`** (or `json_attrs`): An object or hash defining the output JSON structure.
966
- * **`only`**: Array of strings. Returns only the specified attributes of the main model.
967
- * **`methods`**: Array of strings. Includes model methods that are not database columns.
968
- * **`include`**: Object for including relationships (associations). Associations can also have their own `only` or `methods`.
969
-
970
- ### 3. Custom Actions
971
-
972
- * **`do`**: Specifies a custom action (`custom_action`) to execute on the model instead of the standard `index`.
973
- * Format: `?do=action_name` or `?do=action_name-token`.
974
- * The controller will look for a class method `custom_action_action_name` or a module `Endpoints::Model`.
975
-
976
- ### 4. Filters and Sorting (Ransack)
977
-
978
- The core of the search functionality lies in the **`q`** parameter. The controller implements the **Ransack** gem, allowing for complex queries, filtering on associations, and dynamic sorting.
979
-
980
- Basic structure: `q[field_name_predicate]=value`
981
-
982
- #### Common Predicates (Suffixes)
983
-
984
- * `_eq`: Equal to (e.g., `status_eq`).
985
- * `_cont`: Contains (LIKE %value%, case insensitive).
986
- * `_start`: Starts with.
987
- * `_end`: Ends with.
988
- * `_gt` / `_lt`: Greater than / Less than (for numbers or dates).
989
- * `_gteq` / `_lteq`: Greater than or equal to / Less than or equal to.
990
- * `_in`: Included in a list (accepts an array).
991
- * `_present`: If set to `1` or `true`, filters for non-null values. `_blank` for nulls.
992
-
993
- #### Sorting
994
-
995
- * `s`: Defines the sorting order. Format: `field_name asc` or `field_name desc`.
996
-
997
- ---
998
-
999
- ## Practical Examples
1000
-
1001
- Below are two usage scenarios to achieve the same result: a **GET** request (URL parameters) and a **POST** request (JSON Body parameters).
1002
-
1003
- #### Scenario A: Simple Search and Pagination
1004
-
1005
- **Goal:** Find users whose name contains "Mario", paginated (page 2, 10 per page).
1006
-
1007
- ##### 1. Using GET (Query String)
1008
-
1009
- Parameters are "flattened" and encoded in the URL.
1010
-
1011
- ```text
1012
- GET /api/v2/users?q[name_cont]=Mario&page=2&per=10
1013
-
1014
- ```
1015
-
1016
- ##### 2. Using POST (JSON Body)
1017
-
1018
- Ideal for complex searches to avoid exceeding URL length limits.
1019
-
1020
- ```json
1021
- POST /api/v2/users/search
1022
- Content-Type: application/json
1023
-
1024
- {
1025
- "q": {
1026
- "name_cont": "Mario"
1027
- },
1028
- "page": 2,
1029
- "per": 10
1030
- }
1031
-
1032
- ```
1033
-
1034
- ---
1035
-
1036
- #### Scenario B: Advanced Search, Sorting, and Field Selection
1037
-
1038
- **Goal:**
1039
-
1040
- 1. Search for orders (`orders`) where `total_price` is greater than 50.
1041
- 2. Belonging to a user (`user`) whose email ends with `@test.com`.
1042
- 3. Sort by creation date descending.
1043
- 4. Return only the `id` and `total_price` of the order, including the `email` of the associated user.
1044
-
1045
- ##### 1. Using GET (Query String)
1046
-
1047
- Note the square bracket syntax for nested structures (`q`, `a`).
1048
-
1049
- ```text
1050
- GET /api/v2/orders?q[total_price_gt]=50&q[user_email_end]=@test.com&q[s]=created_at desc&a[only][]=id&a[only][]=total_price&a[include][user][only][]=email
1051
-
1052
- ```
1053
-
1054
- ##### 2. Using POST (JSON Body)
1055
-
1056
- Much more readable for nested structures like `a` (json_attrs).
1057
-
1058
- ```json
1059
- POST /api/v2/orders/search
1060
- Content-Type: application/json
1061
-
1062
- {
1063
- "q": {
1064
- "total_price_gt": 50,
1065
- "user_email_end": "@test.com",
1066
- "s": "created_at desc"
1067
- },
1068
- "a": {
1069
- "only": ["id", "total_price"],
1070
- "include": {
1071
- "user": {
1072
- "only": ["email"]
1073
- }
1074
- }
1075
- }
1076
- }
1077
-
1078
- ```
1079
-
1080
- ---
1081
-
1082
- #### Scenario C: Multiple Search (OR) and Arrays
1083
-
1084
- **Goal:** Find products where status is "new" **OR** "refurbished" (using `_in`).
1085
-
1086
- ##### 1. Using GET (Query String)
1087
-
1088
- To pass an array in GET, repeat the empty square brackets `[]`.
1089
-
1090
- ```text
1091
- GET /api/v2/products?q[status_in][]=new&q[status_in][]=refurbished
1092
-
1093
- ```
1094
-
1095
- ##### 2. Using POST (JSON Body)
1096
-
1097
- ```json
1098
- POST /api/v2/products/search
1099
- Content-Type: application/json
1100
-
1101
- {
1102
- "q": {
1103
- "status_in": ["new", "refurbished"]
1104
- }
1105
- }
1106
-
1107
- ```
1108
-
1109
- ---
1110
-
1111
- #### Scenario D: Count Only
1112
-
1113
- **Goal:** Know how many users are active without downloading the data.
1114
-
1115
- ##### 1. Using GET
1116
-
1117
- ```text
1118
- GET /api/v2/users?q[active_eq]=true&count=true
1119
-
1120
- ```
1121
-
1122
- ##### 2. Using POST
1123
-
1124
- ```json
1125
- POST /api/v2/users/search
1126
- Content-Type: application/json
1127
-
1128
- {
1129
- "q": {
1130
- "active_eq": true
1131
- },
1132
- "count": true
1133
- }
1134
-
1135
- ```
1136
-
1137
- **Expected Response:**
1138
-
1139
- ```json
1140
- {
1141
- "count": 156
1142
- }
1143
-
1144
- ```
1145
-
1146
- ## ActiveStorage Integration: React Frontend & Rails Backend
1147
-
1148
- ### Overview
1149
-
1150
- This guide explains how to handle file uploads (via Camera or Gallery) and attachment deletions using a **React** frontend and a **Ruby on Rails** backend.
1151
-
1152
- The Rails model uses a virtual attribute strategy for deletion:
1153
-
1154
- * **Upload:** handled via `has_many_attached :assets`
1155
- * **Deletion:** handled via `attr_accessor :remove_assets`
1156
-
1157
- ---
1158
-
1159
- ### 1. Handling File Objects (No "Paths" needed)
1160
-
1161
- In a web/mobile context (React Web or PWA), you do not need a file system path. When a user takes a photo or selects a file, the browser creates a native **`File`** object (a type of `Blob`).
1162
-
1163
- You must send this binary object to the backend using **`FormData`**.
1164
-
1165
- #### React Component Example
1166
-
1167
- This component handles:
1168
-
1169
- 1. **File Input:** Supports both gallery selection and direct camera capture on mobile.
1170
- 2. **FormData:** Constructs the payload correctly for Rails.
1171
- 3. **API Call:** Sends the data via `fetch`.
1172
-
1173
- ```jsx
1174
- import React, { useState } from 'react';
1175
-
1176
- const ProductForm = () => {
1177
- const [title, setTitle] = useState('');
1178
- const [selectedFiles, setSelectedFiles] = useState([]);
1179
-
1180
- // Handle file selection
1181
- const handleFileChange = (event) => {
1182
- // event.target.files is a FileList; convert to Array for convenience
1183
- const filesArray = Array.from(event.target.files);
1184
- setSelectedFiles(filesArray);
1185
- };
1186
-
1187
- // Handle form submission
1188
- const handleSubmit = async (event) => {
1189
- event.preventDefault();
1190
-
1191
- // 1. Create the FormData object
1192
- const formData = new FormData();
1193
-
1194
- // 2. Append text fields
1195
- formData.append('product[title]', title);
1196
-
1197
- // 3. Append FILES
1198
- // It is crucial to use 'product[assets][]' with brackets.
1199
- // This tells Rails to treat it as an array of attachments.
1200
- selectedFiles.forEach((file) => {
1201
- formData.append('product[assets][]', file);
1202
- });
1203
-
1204
- try {
1205
- const response = await fetch('http://localhost:3000/api/products', {
1206
- method: 'POST',
1207
- // IMPORTANT NOTE:
1208
- // When using FormData, do NOT set 'Content-Type': 'application/json'
1209
- // and do NOT manually set 'multipart/form-data'.
1210
- // The browser will automatically set the header with the correct 'boundary'.
1211
- body: formData,
1212
- });
1213
-
1214
- if (response.ok) {
1215
- console.log("Upload successful!");
1216
- // Reset form or redirect...
1217
- } else {
1218
- console.error("Upload error");
1219
- }
1220
- } catch (error) {
1221
- console.error("Network error:", error);
1222
- }
1223
- };
1224
-
1225
- return (
1226
- <form onSubmit={handleSubmit} style={{ padding: '20px' }}>
1227
- <div>
1228
- <label>Product Title:</label>
1229
- <input
1230
- type="text"
1231
- value={title}
1232
- onChange={(e) => setTitle(e.target.value)}
1233
- />
1234
- </div>
1235
-
1236
- <div style={{ marginTop: '20px' }}>
1237
- <label>Photos (Camera or Gallery):</label>
1238
- {/* accept="image/*": Accepts only images.
1239
- capture="environment": On mobile, opens the rear camera directly.
1240
- Remove 'capture' if you want the user to choose between Gallery and Camera.
1241
- multiple: Allows selecting multiple photos.
1242
- */}
1243
- <input
1244
- type="file"
1245
- accept="image/*"
1246
- multiple
1247
- onChange={handleFileChange}
1248
- />
1249
- </div>
1250
-
1251
- <button type="submit" style={{ marginTop: '20px' }}>
1252
- Save Product
1253
- </button>
1254
- </form>
1255
- );
1256
- };
1257
-
1258
- export default ProductForm;
1259
-
1260
- ```
1261
-
1262
- ---
1263
-
1264
- ### 2. Key Implementation Details
1265
-
1266
- #### A. The `capture` Attribute
1267
-
1268
- * `<input type="file" capture="environment" />`: Opens the **rear camera** directly on iOS/Android.
1269
- * `<input type="file" capture="user" />`: Opens the **front camera** (selfie mode).
1270
- * **No `capture` attribute** (but with `accept="image/*"`): The device will prompt the user: *"Take Photo or Photo Library?"*. This is often the best UX.
1271
-
1272
- #### B. The `forEach` Loop
1273
-
1274
- You cannot pass an array directly into `FormData` (e.g., `formData.append('key', myArray)` will not work).
1275
- Rails expects multiple values for the same key. You must append each file individually:
1276
-
1277
- ```javascript
1278
- // Correct
1279
- files.forEach(file => formData.append('product[assets][]', file));
1280
-
1281
- ```
1282
-
1283
- #### C. The Content-Type Header
1284
-
1285
- This is a common pitfall. When using `fetch` or `axios` with a `FormData` body, **do not set the Content-Type header manually**.
1286
- The browser must generate it automatically to include the boundary:
1287
- `Content-Type: multipart/form-data; boundary=----WebKitFormBoundary...`
1288
-
1289
- ---
1290
-
1291
- ## 3. Handling Deletion (PATCH Request)
1292
-
1293
- To remove specific attachments using the `remove_assets` virtual attribute defined in your Rails model, send the **Attachment IDs** (not the file objects).
1294
-
1295
- ```javascript
1296
- const handleUpdate = async () => {
1297
- const formData = new FormData();
1298
-
1299
- // 1. Add new files (if any)
1300
- newFiles.forEach(file => formData.append('product[assets][]', file));
1301
-
1302
- // 2. Add IDs to remove
1303
- // (e.g., idsToRemove is an array like [12, 45])
1304
- idsToRemove.forEach(id => formData.append('product[remove_assets][]', id));
1305
-
1306
- await fetch(`http://localhost:3000/api/products/${productId}`, {
1307
- method: 'PATCH',
1308
- body: formData
1309
- });
1310
- };
1311
-
1312
- ```
1313
-
1314
- ### 4. Bare Metal HTTP Requests
1315
-
1316
- If you were to inspect the network traffic or manually construct the request (e.g., using raw sockets or a tool like Postman/Insomnia), this is exactly what the payload looks like "over the wire."
1317
-
1318
- #### A. POST Request (Upload)
1319
-
1320
- Notice how `multipart/form-data` uses a **boundary** string (randomly generated by the client) to separate different fields.
1321
-
1322
- ```http
1323
- POST /api/products HTTP/1.1
1324
- Host: localhost:3000
1325
- Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
1326
-
1327
- ------WebKitFormBoundary7MA4YWxkTrZu0gW
1328
- Content-Disposition: form-data; name="product[title]"
1329
-
1330
- My New Product
1331
- ------WebKitFormBoundary7MA4YWxkTrZu0gW
1332
- Content-Disposition: form-data; name="product[assets][]"; filename="camera_shot.jpg"
1333
- Content-Type: image/jpeg
1334
-
1335
- (Binary image data goes here...)
1336
- ------WebKitFormBoundary7MA4YWxkTrZu0gW
1337
- Content-Disposition: form-data; name="product[assets][]"; filename="gallery_photo.png"
1338
- Content-Type: image/png
1339
-
1340
- (Binary image data goes here...)
1341
- ------WebKitFormBoundary7MA4YWxkTrZu0gW--
1342
-
1343
- ```
1344
-
1345
- #### B. PATCH Request (Deletion via IDs)
1346
-
1347
- When sending the `remove_assets` array via `FormData`, the key is repeated for every ID. This is how HTTP handles arrays in form data.
1348
-
1349
- ```http
1350
- PATCH /api/products/100 HTTP/1.1
1351
- Host: localhost:3000
1352
- Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryXyZ123
1353
-
1354
- ------WebKitFormBoundaryXyZ123
1355
- Content-Disposition: form-data; name="product[remove_assets][]"
1356
-
1357
- 12
1358
- ------WebKitFormBoundaryXyZ123
1359
- Content-Disposition: form-data; name="product[remove_assets][]"
1360
-
1361
- 45
1362
- ------WebKitFormBoundaryXyZ123--
1363
-
1364
- ```
1365
- MARKDOWN
1366
- info
1367
- end
1368
-
1369
68
  # GET '/api/v2/info/schema'
1370
69
  def openapi
1371
70
  uri = URI(request.url)
@@ -1373,7 +72,7 @@ Content-Disposition: form-data; name="product[remove_assets][]"
1373
72
  "openapi": "3.0.0",
1374
73
  "info": {
1375
74
  "title": "#{Settings.ns(:main).app_name} API",
1376
- "description": info_description,
75
+ "description": Api::OpenApi::V2.new(ApplicationRecord.subclasses, request).description,
1377
76
  "version": "v2",
1378
77
  },
1379
78
  "servers": [
@@ -1403,7 +102,7 @@ Content-Disposition: form-data; name="product[remove_assets][]"
1403
102
  "bearerAuth": [], # use the same name as above
1404
103
  },
1405
104
  ],
1406
- "paths": generate_paths,
105
+ "paths": Api::OpenApi::V2.new(ApplicationRecord.subclasses, request).generate,
1407
106
  }
1408
107
 
1409
108
  render json: pivot.to_json, status: 200