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.
@@ -65,1312 +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
- # Check if any HTTP method has path parameters with 'in: path', and if so, add {id} to the path
601
- path = "/#{model}/custom_action/#{action}"
602
- has_path_params = openapi_definition.any? { |_k, v| v.is_a?(Hash) && v[:parameters]&.any? { |p| p[:in] == 'path' } }
603
- path += "/{id}" if has_path_params
604
-
605
- pivot[path] = openapi_definition if openapi_definition
606
- end
607
- pivot["/#{model}/search"] = {
608
- # Complex queries are made using ranskac search via a post endpoint
609
- "post": {
610
- "summary": "Search",
611
- "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.",
612
- "tags": [model.classify],
613
- "security": [
614
- "bearerAuth": [],
615
- ],
616
- "requestBody": {
617
- "content": {
618
- "application/json": {
619
- "schema": {
620
- "type": "object",
621
- "properties": {
622
- "q": {
623
- "type": "object",
624
- "properties": {
625
- "name_or_description_cont": {
626
- "type": "string",
627
- },
628
- "first_name_eq": {
629
- "type": "string",
630
- },
631
- },
632
- },
633
- },
634
- },
635
- },
636
- },
637
- },
638
- "responses": {
639
- "200": {
640
- "description": "List of #{model}",
641
- "content": {
642
- "application/json": {
643
- "schema": {
644
- "type": "array",
645
- "items": {
646
- "type": "object",
647
- "properties": create_properties_from_model(d, (d.json_attrs rescue {})),
648
- },
649
- },
650
- },
651
- },
652
- },
653
- "404": {
654
- "description": "No #{model} found",
655
- },
656
- },
657
- },
658
- }
659
- pivot["/#{model}/{id}"] = {
660
- "put": {
661
- "summary": "Update",
662
- "description": "Updates the complete #{model}",
663
- "parameters": [
664
- {
665
- "name": "id",
666
- "in": "path",
667
- "required": true,
668
- "schema": {
669
- "type": "integer",
670
- },
671
- },
672
- ],
673
- "tags": [model.classify],
674
- "security": [
675
- "bearerAuth": [],
676
- ],
677
- "requestBody": {
678
- "content": {
679
- "application/json": {
680
- "schema": {
681
- "type": "object",
682
- "properties": {
683
- "#{model.singularize}": {
684
- "type": "object",
685
- "properties": create_properties_from_model(d, {}, true),
686
- },
687
- },
688
- },
689
- },
690
- },
691
- },
692
- "responses": {
693
- "200": {
694
- "description": "#{model} Updated",
695
- "content": {
696
- "application/json": {
697
- "schema": {
698
- "type": "object",
699
- "properties": create_properties_from_model(d, (d.json_attrs rescue {})),
700
- },
701
- },
702
- },
703
- },
704
- "404": {
705
- "description": "No #{model} found",
706
- },
707
- },
708
- },
709
- "patch": {
710
- "summary": "Patch",
711
- "description": "Updates the partial #{model}",
712
- "parameters": [
713
- {
714
- "name": "id",
715
- "in": "path",
716
- "required": true,
717
- "schema": {
718
- "type": "integer",
719
- },
720
- },
721
- ],
722
- "tags": [model.classify],
723
- "security": [
724
- "bearerAuth": [],
725
- ],
726
- "requestBody": {
727
- "content": {
728
- "application/json": {
729
- "schema": {
730
- "type": "object",
731
- "properties": {
732
- "#{model.singularize}": {
733
- "type": "object",
734
- "properties": create_properties_from_model(d, {}, true),
735
- },
736
- },
737
- },
738
- },
739
- },
740
- },
741
- "responses": {
742
- "200": {
743
- "description": "#{model} Patched",
744
- "content": {
745
- "application/json": {
746
- "schema": {
747
- "type": "object",
748
- "properties": create_properties_from_model(d, (d.json_attrs rescue {})),
749
- },
750
- },
751
- },
752
- },
753
- "404": {
754
- "description": "No #{model} found",
755
- },
756
- },
757
- },
758
- "delete": {
759
- "summary": "Delete",
760
- "description": "Deletes the #{model}",
761
- "parameters": [
762
- {
763
- "name": "id",
764
- "in": "path",
765
- "required": true,
766
- "schema": {
767
- "type": "integer",
768
- },
769
- },
770
- ],
771
- "tags": [model.classify],
772
- "security": [
773
- "bearerAuth": [],
774
- ],
775
- "responses": {
776
- "200": {
777
- "description": "#{model} Deleted",
778
- },
779
- "404": {
780
- "description": "No #{model} found",
781
- },
782
- },
783
- },
784
- "get": {
785
- "summary": "Show",
786
- "description": "Shows the #{model}",
787
- "parameters": [
788
- {
789
- "name": "id",
790
- "in": "path",
791
- "required": true,
792
- "schema": {
793
- "type": "integer",
794
- },
795
- },
796
- ],
797
- "tags": [model.classify],
798
- "security": [
799
- "bearerAuth": [],
800
- ],
801
- "responses": {
802
- "200": {
803
- "description": "Show #{model}",
804
- "content": {
805
- "application/json": {
806
- "schema": {
807
- "type": "object",
808
- "properties": create_properties_from_model(d, (d.json_attrs rescue {})),
809
- },
810
- },
811
- },
812
- },
813
- "404": {
814
- "description": "No #{model} found",
815
- },
816
- },
817
- },
818
- }
819
- # d.columns_hash.each_pair do |key, val|
820
- # pivot[model][key] = val.type unless key.ends_with? "_id"
821
- # end
822
- # # Only application record descendants in order to have a clean schema
823
- # pivot[model][:associations] ||= {
824
- # has_many: d.reflect_on_all_associations(:has_many).map { |a|
825
- # a.name if (((a.options[:class_name].presence || a.name).to_s.classify.constantize.new.is_a? ApplicationRecord) rescue false)
826
- # }.compact,
827
- # belongs_to: d.reflect_on_all_associations(:belongs_to).map { |a|
828
- # a.name if (((a.options[:class_name].presence || a.name).to_s.classify.constantize.new.is_a? ApplicationRecord) rescue false)
829
- # }.compact
830
- # }
831
- # pivot[model][:methods] ||= (d.instance_methods(false).include?(:json_attrs) && !d.json_attrs.blank?) ? d.json_attrs[:methods] : nil
832
- end
833
- end
834
- pivot
835
- end
836
-
837
- def info_description
838
- info = <<-MARKDOWN
839
-
840
- ## About this API Documentation
841
-
842
- 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.
843
-
844
- 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).
845
-
846
- The documentation starts from the authentication mechanism, integrated with the details from the provided code.
847
- 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.
848
-
849
- ---
850
-
851
- ## Authentication & Token Management
852
-
853
- The API implements a **stateless JWT (JSON Web Token)** authentication mechanism. It consists of two distinct phases:
854
-
855
- 1. **Initial Authentication:** Exchanging credentials for the first Token.
856
- 2. **Session Maintenance:** Using a **Sliding Expiration** strategy where every subsequent successful request issues a fresh token.
857
-
858
- ### 1. Initial Authentication (Login)
859
-
860
- 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.
861
-
862
- #### Request
863
-
864
- **Endpoint:** `POST /api/v2/authenticate`
865
-
866
- **Body:**
867
-
868
- ```json
869
- {
870
- "auth": {
871
- "email": "admin@example.com",
872
- "password": "Change#1"
873
- }
874
- }
875
-
876
- ```
877
-
878
- #### Response
879
-
880
- Upon successful authentication, the server returns two critical pieces of data:
881
-
882
- 1. **Response Body:** Contains the User object details.
883
- 2. **Response Headers:** Contains the initial JWT in the `token` header.
884
-
885
- **Example Body:**
886
-
887
- ```json
888
- {
889
- "id": 219,
890
- "email": "admin@example.com",
891
- "created_at": "2025-12-10T07:57:54.336Z",
892
- "admin": true,
893
- "locked": false,
894
- "locale": "en",
895
- // ... other user attributes
896
- "roles": []
897
- }
898
-
899
- ```
900
-
901
- **Example Headers:**
902
- Note the presence of the `token` header.
903
-
904
- ```http
905
- HTTP/1.1 200 OK
906
- content-type: application/json; charset=utf-8
907
- token: eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoyMTksImV4cCI6MTc2NjQ3ODMwN30.I0qJzOwA0Jxx0frL5-9jVH2PsakdZjSEY8Kqb9S3GKo
908
- x-request-id: 113cad63-11f8-4daf-b684-19322a053bcc
909
- ...
910
-
911
- ```
912
-
913
- ---
914
-
915
- ### 2. Sliding Expiration (Token Renewal)
916
-
917
- 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.
918
-
919
- #### The Renewal Mechanism
920
-
921
- 1. **Client Request:** The client sends the *current* token in the `Authorization` header.
922
- ```http
923
- Authorization: Bearer <Current_Token>
924
-
925
- ```
926
-
927
-
928
- 2. **Verification:** The `authenticate_request` method verifies the token. If valid, it sets `@current_user`.
929
- 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.
930
- ```ruby
931
- response.set_header("Token", JsonWebToken.encode(user_id: current_user.id))
932
-
933
- ```
934
-
935
- #### Client-Side Implementation Guide
936
-
937
- To maintain a valid session, the client must implement an interceptor to handle token rotation:
938
-
939
- 1. **Login:** Call `/api/v2/authenticate` and store the `token` from the response header.
940
- 2. **Subsequent Requests:** Attach the stored token to the `Authorization: Bearer ...` header.
941
- 3. **Update Storage:**
942
- * Check every response for a `Token` header.
943
- * **If present:** Immediately replace the stored token with this new value.
944
- * **If missing:** Continue using the existing token (unless the response was a 401/403 error).
945
-
946
- #### Failure Scenarios
947
-
948
- If the `authenticate_request` fails (e.g., token expired, invalid signature):
949
-
950
- * The controller returns an unauthenticated error (`unauthenticated!`).
951
- * The execution halts, and the line generating the new header is never reached.
952
- * **Result:** The client receives a 401 error and **no new token**, signaling that the user must perform the **Initial Authentication** (login) again.
953
-
954
- ---
955
-
956
- ## API Documentation: Search, Filtering, and Pagination Parameters
957
-
958
- ### 1. Pagination and Counting
959
-
960
- These parameters control the amount of data returned and navigation through result pages.
961
-
962
- * **`page`** (Integer): Indicates the page number to retrieve. If omitted, pagination is not applied (or defaults to the model's Kaminari configuration).
963
- * **`per`** (Integer): Indicates the number of records per page. Works in conjunction with `page`.
964
- * **`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.
965
-
966
- ### 2. Field Selection
967
-
968
- 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`.
969
-
970
- * **`a`** (or `json_attrs`): An object or hash defining the output JSON structure.
971
- * **`only`**: Array of strings. Returns only the specified attributes of the main model.
972
- * **`methods`**: Array of strings. Includes model methods that are not database columns.
973
- * **`include`**: Object for including relationships (associations). Associations can also have their own `only` or `methods`.
974
-
975
- ### 3. Custom Actions
976
-
977
- * **`do`**: Specifies a custom action (`custom_action`) to execute on the model instead of the standard `index`.
978
- * Format: `?do=action_name` or `?do=action_name-token`.
979
- * The controller will look for a class method `custom_action_action_name` or a module `Endpoints::Model`.
980
-
981
- ### 4. Filters and Sorting (Ransack)
982
-
983
- 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.
984
-
985
- Basic structure: `q[field_name_predicate]=value`
986
-
987
- #### Common Predicates (Suffixes)
988
-
989
- * `_eq`: Equal to (e.g., `status_eq`).
990
- * `_cont`: Contains (LIKE %value%, case insensitive).
991
- * `_start`: Starts with.
992
- * `_end`: Ends with.
993
- * `_gt` / `_lt`: Greater than / Less than (for numbers or dates).
994
- * `_gteq` / `_lteq`: Greater than or equal to / Less than or equal to.
995
- * `_in`: Included in a list (accepts an array).
996
- * `_present`: If set to `1` or `true`, filters for non-null values. `_blank` for nulls.
997
-
998
- #### Sorting
999
-
1000
- * `s`: Defines the sorting order. Format: `field_name asc` or `field_name desc`.
1001
-
1002
- ---
1003
-
1004
- ## Practical Examples
1005
-
1006
- Below are two usage scenarios to achieve the same result: a **GET** request (URL parameters) and a **POST** request (JSON Body parameters).
1007
-
1008
- #### Scenario A: Simple Search and Pagination
1009
-
1010
- **Goal:** Find users whose name contains "Mario", paginated (page 2, 10 per page).
1011
-
1012
- ##### 1. Using GET (Query String)
1013
-
1014
- Parameters are "flattened" and encoded in the URL.
1015
-
1016
- ```text
1017
- GET /api/v2/users?q[name_cont]=Mario&page=2&per=10
1018
-
1019
- ```
1020
-
1021
- ##### 2. Using POST (JSON Body)
1022
-
1023
- Ideal for complex searches to avoid exceeding URL length limits.
1024
-
1025
- ```json
1026
- POST /api/v2/users/search
1027
- Content-Type: application/json
1028
-
1029
- {
1030
- "q": {
1031
- "name_cont": "Mario"
1032
- },
1033
- "page": 2,
1034
- "per": 10
1035
- }
1036
-
1037
- ```
1038
-
1039
- ---
1040
-
1041
- #### Scenario B: Advanced Search, Sorting, and Field Selection
1042
-
1043
- **Goal:**
1044
-
1045
- 1. Search for orders (`orders`) where `total_price` is greater than 50.
1046
- 2. Belonging to a user (`user`) whose email ends with `@test.com`.
1047
- 3. Sort by creation date descending.
1048
- 4. Return only the `id` and `total_price` of the order, including the `email` of the associated user.
1049
-
1050
- ##### 1. Using GET (Query String)
1051
-
1052
- Note the square bracket syntax for nested structures (`q`, `a`).
1053
-
1054
- ```text
1055
- 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
1056
-
1057
- ```
1058
-
1059
- ##### 2. Using POST (JSON Body)
1060
-
1061
- Much more readable for nested structures like `a` (json_attrs).
1062
-
1063
- ```json
1064
- POST /api/v2/orders/search
1065
- Content-Type: application/json
1066
-
1067
- {
1068
- "q": {
1069
- "total_price_gt": 50,
1070
- "user_email_end": "@test.com",
1071
- "s": "created_at desc"
1072
- },
1073
- "a": {
1074
- "only": ["id", "total_price"],
1075
- "include": {
1076
- "user": {
1077
- "only": ["email"]
1078
- }
1079
- }
1080
- }
1081
- }
1082
-
1083
- ```
1084
-
1085
- ---
1086
-
1087
- #### Scenario C: Multiple Search (OR) and Arrays
1088
-
1089
- **Goal:** Find products where status is "new" **OR** "refurbished" (using `_in`).
1090
-
1091
- ##### 1. Using GET (Query String)
1092
-
1093
- To pass an array in GET, repeat the empty square brackets `[]`.
1094
-
1095
- ```text
1096
- GET /api/v2/products?q[status_in][]=new&q[status_in][]=refurbished
1097
-
1098
- ```
1099
-
1100
- ##### 2. Using POST (JSON Body)
1101
-
1102
- ```json
1103
- POST /api/v2/products/search
1104
- Content-Type: application/json
1105
-
1106
- {
1107
- "q": {
1108
- "status_in": ["new", "refurbished"]
1109
- }
1110
- }
1111
-
1112
- ```
1113
-
1114
- ---
1115
-
1116
- #### Scenario D: Count Only
1117
-
1118
- **Goal:** Know how many users are active without downloading the data.
1119
-
1120
- ##### 1. Using GET
1121
-
1122
- ```text
1123
- GET /api/v2/users?q[active_eq]=true&count=true
1124
-
1125
- ```
1126
-
1127
- ##### 2. Using POST
1128
-
1129
- ```json
1130
- POST /api/v2/users/search
1131
- Content-Type: application/json
1132
-
1133
- {
1134
- "q": {
1135
- "active_eq": true
1136
- },
1137
- "count": true
1138
- }
1139
-
1140
- ```
1141
-
1142
- **Expected Response:**
1143
-
1144
- ```json
1145
- {
1146
- "count": 156
1147
- }
1148
-
1149
- ```
1150
-
1151
- ## ActiveStorage Integration: React Frontend & Rails Backend
1152
-
1153
- ### Overview
1154
-
1155
- 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.
1156
-
1157
- The Rails model uses a virtual attribute strategy for deletion:
1158
-
1159
- * **Upload:** handled via `has_many_attached :assets`
1160
- * **Deletion:** handled via `attr_accessor :remove_assets`
1161
-
1162
- ---
1163
-
1164
- ### 1. Handling File Objects (No "Paths" needed)
1165
-
1166
- 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`).
1167
-
1168
- You must send this binary object to the backend using **`FormData`**.
1169
-
1170
- #### React Component Example
1171
-
1172
- This component handles:
1173
-
1174
- 1. **File Input:** Supports both gallery selection and direct camera capture on mobile.
1175
- 2. **FormData:** Constructs the payload correctly for Rails.
1176
- 3. **API Call:** Sends the data via `fetch`.
1177
-
1178
- ```jsx
1179
- import React, { useState } from 'react';
1180
-
1181
- const ProductForm = () => {
1182
- const [title, setTitle] = useState('');
1183
- const [selectedFiles, setSelectedFiles] = useState([]);
1184
-
1185
- // Handle file selection
1186
- const handleFileChange = (event) => {
1187
- // event.target.files is a FileList; convert to Array for convenience
1188
- const filesArray = Array.from(event.target.files);
1189
- setSelectedFiles(filesArray);
1190
- };
1191
-
1192
- // Handle form submission
1193
- const handleSubmit = async (event) => {
1194
- event.preventDefault();
1195
-
1196
- // 1. Create the FormData object
1197
- const formData = new FormData();
1198
-
1199
- // 2. Append text fields
1200
- formData.append('product[title]', title);
1201
-
1202
- // 3. Append FILES
1203
- // It is crucial to use 'product[assets][]' with brackets.
1204
- // This tells Rails to treat it as an array of attachments.
1205
- selectedFiles.forEach((file) => {
1206
- formData.append('product[assets][]', file);
1207
- });
1208
-
1209
- try {
1210
- const response = await fetch('http://localhost:3000/api/products', {
1211
- method: 'POST',
1212
- // IMPORTANT NOTE:
1213
- // When using FormData, do NOT set 'Content-Type': 'application/json'
1214
- // and do NOT manually set 'multipart/form-data'.
1215
- // The browser will automatically set the header with the correct 'boundary'.
1216
- body: formData,
1217
- });
1218
-
1219
- if (response.ok) {
1220
- console.log("Upload successful!");
1221
- // Reset form or redirect...
1222
- } else {
1223
- console.error("Upload error");
1224
- }
1225
- } catch (error) {
1226
- console.error("Network error:", error);
1227
- }
1228
- };
1229
-
1230
- return (
1231
- <form onSubmit={handleSubmit} style={{ padding: '20px' }}>
1232
- <div>
1233
- <label>Product Title:</label>
1234
- <input
1235
- type="text"
1236
- value={title}
1237
- onChange={(e) => setTitle(e.target.value)}
1238
- />
1239
- </div>
1240
-
1241
- <div style={{ marginTop: '20px' }}>
1242
- <label>Photos (Camera or Gallery):</label>
1243
- {/* accept="image/*": Accepts only images.
1244
- capture="environment": On mobile, opens the rear camera directly.
1245
- Remove 'capture' if you want the user to choose between Gallery and Camera.
1246
- multiple: Allows selecting multiple photos.
1247
- */}
1248
- <input
1249
- type="file"
1250
- accept="image/*"
1251
- multiple
1252
- onChange={handleFileChange}
1253
- />
1254
- </div>
1255
-
1256
- <button type="submit" style={{ marginTop: '20px' }}>
1257
- Save Product
1258
- </button>
1259
- </form>
1260
- );
1261
- };
1262
-
1263
- export default ProductForm;
1264
-
1265
- ```
1266
-
1267
- ---
1268
-
1269
- ### 2. Key Implementation Details
1270
-
1271
- #### A. The `capture` Attribute
1272
-
1273
- * `<input type="file" capture="environment" />`: Opens the **rear camera** directly on iOS/Android.
1274
- * `<input type="file" capture="user" />`: Opens the **front camera** (selfie mode).
1275
- * **No `capture` attribute** (but with `accept="image/*"`): The device will prompt the user: *"Take Photo or Photo Library?"*. This is often the best UX.
1276
-
1277
- #### B. The `forEach` Loop
1278
-
1279
- You cannot pass an array directly into `FormData` (e.g., `formData.append('key', myArray)` will not work).
1280
- Rails expects multiple values for the same key. You must append each file individually:
1281
-
1282
- ```javascript
1283
- // Correct
1284
- files.forEach(file => formData.append('product[assets][]', file));
1285
-
1286
- ```
1287
-
1288
- #### C. The Content-Type Header
1289
-
1290
- This is a common pitfall. When using `fetch` or `axios` with a `FormData` body, **do not set the Content-Type header manually**.
1291
- The browser must generate it automatically to include the boundary:
1292
- `Content-Type: multipart/form-data; boundary=----WebKitFormBoundary...`
1293
-
1294
- ---
1295
-
1296
- ## 3. Handling Deletion (PATCH Request)
1297
-
1298
- To remove specific attachments using the `remove_assets` virtual attribute defined in your Rails model, send the **Attachment IDs** (not the file objects).
1299
-
1300
- ```javascript
1301
- const handleUpdate = async () => {
1302
- const formData = new FormData();
1303
-
1304
- // 1. Add new files (if any)
1305
- newFiles.forEach(file => formData.append('product[assets][]', file));
1306
-
1307
- // 2. Add IDs to remove
1308
- // (e.g., idsToRemove is an array like [12, 45])
1309
- idsToRemove.forEach(id => formData.append('product[remove_assets][]', id));
1310
-
1311
- await fetch(`http://localhost:3000/api/products/${productId}`, {
1312
- method: 'PATCH',
1313
- body: formData
1314
- });
1315
- };
1316
-
1317
- ```
1318
-
1319
- ### 4. Bare Metal HTTP Requests
1320
-
1321
- 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."
1322
-
1323
- #### A. POST Request (Upload)
1324
-
1325
- Notice how `multipart/form-data` uses a **boundary** string (randomly generated by the client) to separate different fields.
1326
-
1327
- ```http
1328
- POST /api/products HTTP/1.1
1329
- Host: localhost:3000
1330
- Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
1331
-
1332
- ------WebKitFormBoundary7MA4YWxkTrZu0gW
1333
- Content-Disposition: form-data; name="product[title]"
1334
-
1335
- My New Product
1336
- ------WebKitFormBoundary7MA4YWxkTrZu0gW
1337
- Content-Disposition: form-data; name="product[assets][]"; filename="camera_shot.jpg"
1338
- Content-Type: image/jpeg
1339
-
1340
- (Binary image data goes here...)
1341
- ------WebKitFormBoundary7MA4YWxkTrZu0gW
1342
- Content-Disposition: form-data; name="product[assets][]"; filename="gallery_photo.png"
1343
- Content-Type: image/png
1344
-
1345
- (Binary image data goes here...)
1346
- ------WebKitFormBoundary7MA4YWxkTrZu0gW--
1347
-
1348
- ```
1349
-
1350
- #### B. PATCH Request (Deletion via IDs)
1351
-
1352
- When sending the `remove_assets` array via `FormData`, the key is repeated for every ID. This is how HTTP handles arrays in form data.
1353
-
1354
- ```http
1355
- PATCH /api/products/100 HTTP/1.1
1356
- Host: localhost:3000
1357
- Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryXyZ123
1358
-
1359
- ------WebKitFormBoundaryXyZ123
1360
- Content-Disposition: form-data; name="product[remove_assets][]"
1361
-
1362
- 12
1363
- ------WebKitFormBoundaryXyZ123
1364
- Content-Disposition: form-data; name="product[remove_assets][]"
1365
-
1366
- 45
1367
- ------WebKitFormBoundaryXyZ123--
1368
-
1369
- ```
1370
- MARKDOWN
1371
- info
1372
- end
1373
-
1374
68
  # GET '/api/v2/info/schema'
1375
69
  def openapi
1376
70
  uri = URI(request.url)
@@ -1378,7 +72,7 @@ Content-Disposition: form-data; name="product[remove_assets][]"
1378
72
  "openapi": "3.0.0",
1379
73
  "info": {
1380
74
  "title": "#{Settings.ns(:main).app_name} API",
1381
- "description": info_description,
75
+ "description": Api::OpenApi::V2.new(ApplicationRecord.subclasses, request).description,
1382
76
  "version": "v2",
1383
77
  },
1384
78
  "servers": [
@@ -1408,7 +102,7 @@ Content-Disposition: form-data; name="product[remove_assets][]"
1408
102
  "bearerAuth": [], # use the same name as above
1409
103
  },
1410
104
  ],
1411
- "paths": generate_paths,
105
+ "paths": Api::OpenApi::V2.new(ApplicationRecord.subclasses, request).generate,
1412
106
  }
1413
107
 
1414
108
  render json: pivot.to_json, status: 200