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.
- checksums.yaml +4 -4
- data/README.md +521 -54
- data/Rakefile +3 -0
- data/app/controllers/api/v2/application_controller.rb +6 -47
- data/app/controllers/api/v2/info_controller.rb +2 -1303
- data/app/controllers/api/v3/application_controller.rb +132 -0
- data/app/controllers/api/v3/auth/oauth_controller.rb +4 -0
- data/app/controllers/api/v3/authentication_controller.rb +2 -0
- data/app/controllers/api/v3/info_controller.rb +37 -0
- data/app/controllers/api/v3/raw_controller.rb +14 -0
- data/app/controllers/api/v3/users_controller.rb +10 -0
- data/config/routes.rb +44 -0
- data/lib/api/custom_action_dispatcher.rb +41 -0
- data/lib/api/model_resolver.rb +20 -0
- data/lib/api/open_api/base.rb +91 -0
- data/lib/api/open_api/v2.rb +1238 -0
- data/lib/api/open_api/v3.rb +349 -0
- data/lib/api/resource_attribute_set.rb +25 -0
- data/lib/api/v3/serializer_factory.rb +65 -0
- data/lib/model_driven_api/engine.rb +7 -1
- data/lib/model_driven_api/version.rb +1 -1
- data/lib/model_driven_api.rb +8 -0
- metadata +75 -6
|
@@ -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":
|
|
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":
|
|
105
|
+
"paths": Api::OpenApi::V2.new(ApplicationRecord.subclasses, request).generate,
|
|
1407
106
|
}
|
|
1408
107
|
|
|
1409
108
|
render json: pivot.to_json, status: 200
|