swagger_docs_rails 0.1.0

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.
@@ -0,0 +1,712 @@
1
+ # frozen_string_literal: true
2
+ # lib/swagger_docs_rails/openapi_builder.rb
3
+ #
4
+ # Monta o documento OpenAPI 3.0 completo a partir do SchemaParser + ControllerParser.
5
+ #
6
+ # Lógica de schemas de input por ação:
7
+ # 1. Se a ação usa Model.column_names → pega campos do schema.rb + extras como binary
8
+ # 2. Se tem params.permit com campos → gera schema dinâmico com belongs-to expandido
9
+ # 3. Se não tem permit detectado → para create/update usa fallback; caso contrário, omite body
10
+ #
11
+ # Belongs-to:
12
+ # Campos _id → mantém FK integer
13
+ # + adiciona objeto aninhado opcional com campos da tabela relacionada
14
+ # (apenas tabelas "ricas" — lookup/enum tables são ignoradas)
15
+ #
16
+ # Multipart:
17
+ # Se qualquer campo for arquivo ou a ação usar uploads → content-type multipart/form-data
18
+
19
+ module SwaggerDocsRails
20
+ class OpenapiBuilder
21
+ ID_PARAM = {
22
+ name: "id", in: "path", required: true,
23
+ schema: { type: "integer", format: "int64" },
24
+ description: "ID do registro"
25
+ }.freeze
26
+
27
+ PAGINATION_PARAMS = [
28
+ { name: "page", in: "query", required: false,
29
+ schema: { type: "integer", default: 1 }, description: "Número da página" },
30
+ { name: "per_page", in: "query", required: false,
31
+ schema: { type: "integer", default: 25 }, description: "Itens por página" }
32
+ ].freeze
33
+
34
+ RANSACK_PARAM = {
35
+ name: "q", in: "query", required: false,
36
+ style: "deepObject", explode: true,
37
+ schema: {
38
+ type: "object",
39
+ additionalProperties: { type: "string" },
40
+ example: {
41
+ "nome_cont" => "joao",
42
+ "email_i_cont" => "JOAO",
43
+ "cpf_eq" => "12345678900",
44
+ "created_at_gteq" => "2024-01-01",
45
+ "created_at_lteq" => "2024-12-31",
46
+ "ativo_eq" => "true",
47
+ "s" => "nome asc"
48
+ }
49
+ },
50
+ description: <<~DESC.strip
51
+ DESC
52
+ }.freeze
53
+
54
+ # Tabelas que são apenas lookups/enums — não expandir como objeto aninhado
55
+ LOOKUP_TABLE_PATTERN = /\A(g_tipos_|g_status\z|g_sexos\z|g_regioes\z|
56
+ g_paises\z|g_periodos_|g_graus_|g_estados_civis\z)/x.freeze
57
+
58
+ def initialize(schema_parser, controller_parser, config = {})
59
+ @schema_parser = schema_parser
60
+ @controller_parser = controller_parser
61
+ @config = config
62
+ @dynamic_schemas = {}
63
+ end
64
+
65
+ def build
66
+ paths = build_paths
67
+ components = build_components
68
+ versions = detected_versions
69
+
70
+ {
71
+ openapi: "3.0.3",
72
+ info: build_info(versions),
73
+ servers: build_servers(versions),
74
+ tags: build_tags(versions),
75
+ paths: paths,
76
+ components: components,
77
+ security: [{ BearerAuth: [] }]
78
+ }
79
+ end
80
+
81
+ private
82
+
83
+ # ──────────────────────────────────────── info / tags ──
84
+
85
+ def build_info(versions = [])
86
+ versions_str = versions.any? ? " | #{versions.map(&:upcase).join(" + ")}" : ""
87
+ {
88
+ title: "#{@config.fetch(:title, "API")}#{versions_str}",
89
+ version: @config.fetch(:version, "1.0.0"),
90
+ description: @config.fetch(:description, "Documentação OpenAPI gerada automaticamente")
91
+ }
92
+ end
93
+
94
+ def detected_versions
95
+ @controller_parser.to_resources
96
+ .map { |r| r[:version] }
97
+ .uniq
98
+ .sort
99
+ end
100
+
101
+ def build_servers(versions)
102
+ base = @config.fetch(:server_url, "/").chomp("/")
103
+ if versions.size > 1
104
+ versions.map do |v|
105
+ { url: "#{base}/api/#{v}", description: "API #{v.upcase}" }
106
+ end
107
+ else
108
+ [{ url: base, description: "Servidor da API" }]
109
+ end
110
+ end
111
+
112
+ def build_tags(versions)
113
+ multi = versions.size > 1
114
+ tags = []
115
+
116
+ @controller_parser.to_resources.each do |r|
117
+ base_tag = r[:extra_meta][:tag] || r[:model]
118
+ tag_name = multi ? "#{r[:version].upcase} — #{base_tag}" : base_tag
119
+ desc = multi ? "#{r[:model]} (#{r[:version].upcase})" : "Gerenciamento de #{r[:model]}"
120
+ tags << { name: tag_name, description: desc }
121
+ end
122
+
123
+ tags.uniq { |t| t[:name] }
124
+ end
125
+
126
+ # ──────────────────────────────────────────── paths ──
127
+
128
+ def build_paths
129
+ paths = {}
130
+ multi_version = detected_versions.size > 1
131
+
132
+ @controller_parser.to_resources.each do |r|
133
+ base = "/api/#{r[:version]}/#{r[:resource]}"
134
+ model = r[:model]
135
+ version = r[:version]
136
+ meta = r[:extra_meta]
137
+ ameta = r[:actions_meta] || {}
138
+
139
+ base_tag = meta[:tag] || model
140
+ tag = multi_version ? "#{version.upcase} — #{base_tag}" : base_tag
141
+ vid = multi_version ? "#{version.upcase}_" : ""
142
+
143
+ # Coleção
144
+ col = {}
145
+ col[:get] = index_op(model, tag, meta, ameta["index"], vid) if r[:actions].include?("index")
146
+ col[:post] = create_op(model, tag, meta, ameta["create"], vid) if r[:actions].include?("create")
147
+ paths[base] = col unless col.empty?
148
+
149
+ # Membro
150
+ mem = {}
151
+ mem[:get] = show_op(model, tag, meta, ameta["show"], vid) if r[:actions].include?("show")
152
+ mem[:patch] = update_op(model, tag, meta, ameta["update"], vid) if r[:actions].include?("update")
153
+ mem[:put] = update_op(model, tag, meta, ameta["update"], vid, "put") if r[:actions].include?("update")
154
+ mem[:delete] = destroy_op(model, tag, meta, ameta["destroy"], vid) if r[:actions].include?("destroy")
155
+ paths["#{base}/{id}"] = mem unless mem.empty?
156
+
157
+ # Ações customizadas
158
+ Array(r[:custom_actions]).each do |ca|
159
+ base_route = ca[:on_member] ? "#{base}/{id}" : base
160
+ extra_segments = Array(ca[:extra_path_params]).map { |p| "{#{p}}" }.join("/")
161
+ route = extra_segments.empty? ? "#{base_route}/#{ca[:name]}" : "#{base_route}/#{ca[:name]}/#{extra_segments}"
162
+
163
+ paths[route] ||= {}
164
+ paths[route][ca[:http_method]] = custom_op(ca, model, tag, meta, vid)
165
+ end
166
+ end
167
+
168
+ paths
169
+ end
170
+
171
+ # ───────────────────────────────────────── operações CRUD ──
172
+
173
+ def index_op(model, tag, meta, action_meta = nil, vid = "")
174
+ plural = model.pluralize rescue "#{model}s"
175
+ op = {
176
+ tags: [tag],
177
+ summary: meta[:summary_index] || "Listar #{plural}",
178
+ operationId: "#{vid}list#{model}",
179
+ parameters: [RANSACK_PARAM, *PAGINATION_PARAMS],
180
+ responses: {
181
+ "200" => json_response("Lista paginada de #{plural}", "#{model}List"),
182
+ "401" => ref_response("Unauthorized")
183
+ }
184
+ }
185
+ op[:security] = [] if action_meta&.dig(:unauthenticated)
186
+ op
187
+ end
188
+
189
+ def export_op(model, tag, meta, action_meta = nil, vid = "")
190
+ plural = model.pluralize rescue "#{model}s"
191
+ op = {
192
+ tags: [tag],
193
+ summary: meta[:summary_export] || "Exportar #{plural}",
194
+ operationId: "#{vid}export#{model}",
195
+ parameters: [RANSACK_PARAM],
196
+ responses: {
197
+ "200" => {
198
+ description: "Arquivo exportado com #{plural}",
199
+ content: {
200
+ "text/csv" => { schema: { type: "string", format: "binary" } },
201
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => {
202
+ schema: { type: "string", format: "binary" }
203
+ }
204
+ }
205
+ },
206
+ "401" => ref_response("Unauthorized")
207
+ }
208
+ }
209
+ op[:security] = [] if action_meta&.dig(:unauthenticated)
210
+ op
211
+ end
212
+
213
+ def show_op(model, tag, meta, action_meta = nil, vid = "")
214
+ op = {
215
+ tags: [tag],
216
+ summary: meta[:summary_show] || "Buscar #{model} por ID",
217
+ operationId: "#{vid}get#{model}",
218
+ parameters: [ID_PARAM],
219
+ responses: {
220
+ "200" => json_response("#{model} encontrado", model),
221
+ "401" => ref_response("Unauthorized"),
222
+ "404" => ref_response("NotFound")
223
+ }
224
+ }
225
+ op[:security] = [] if action_meta&.dig(:unauthenticated)
226
+ op
227
+ end
228
+
229
+ def create_op(model, tag, meta, action_meta = nil, vid = "")
230
+ schema_ref = resolve_input_schema(model, "create", action_meta)
231
+ content = build_request_content(schema_ref, action_meta)
232
+ op = {
233
+ tags: [tag],
234
+ summary: meta[:summary_create] || "Criar #{model}",
235
+ operationId: "#{vid}create#{model}",
236
+ requestBody: { required: true, content: content },
237
+ responses: {
238
+ "201" => json_response("#{model} criado com sucesso", model),
239
+ "401" => ref_response("Unauthorized"),
240
+ "422" => ref_response("UnprocessableEntity")
241
+ }
242
+ }
243
+ op[:description] = meta[:description_create] if meta[:description_create]
244
+ op
245
+ end
246
+
247
+ def update_op(model, tag, meta, action_meta = nil, vid = "", method = "patch")
248
+ schema_ref = resolve_input_schema(model, "update", action_meta)
249
+ content = build_request_content(schema_ref, action_meta)
250
+ {
251
+ tags: [tag],
252
+ summary: meta[:summary_update] || "Atualizar #{model}",
253
+ operationId: method == "put" ? "#{vid}replace#{model}" : "#{vid}update#{model}",
254
+ parameters: [ID_PARAM],
255
+ requestBody: { required: true, content: content },
256
+ responses: {
257
+ "200" => json_response("#{model} atualizado", model),
258
+ "401" => ref_response("Unauthorized"),
259
+ "404" => ref_response("NotFound"),
260
+ "422" => ref_response("UnprocessableEntity")
261
+ }
262
+ }
263
+ end
264
+
265
+ def destroy_op(model, tag, meta, action_meta = nil, vid = "")
266
+ op = {
267
+ tags: [tag],
268
+ summary: meta[:summary_destroy] || "Remover #{model}",
269
+ operationId: "#{vid}delete#{model}",
270
+ parameters: [ID_PARAM],
271
+ responses: {
272
+ "204" => { description: "#{model} removido com sucesso" },
273
+ "401" => ref_response("Unauthorized"),
274
+ "404" => ref_response("NotFound")
275
+ }
276
+ }
277
+ op[:security] = [] if action_meta&.dig(:unauthenticated)
278
+ op
279
+ end
280
+
281
+ # ──────────────────────────────── operação customizada ──
282
+
283
+ def custom_op(ca, model, tag, meta, vid = "")
284
+ action = ca[:name]
285
+ http_method = ca[:http_method]
286
+ on_member = ca[:on_member]
287
+
288
+ op = {
289
+ tags: [tag],
290
+ summary: meta[:"summary_#{action}"] || action.tr("_", " ").capitalize,
291
+ operationId: "#{vid}#{action}#{model}"
292
+ }
293
+ op[:description] = meta[:"description_#{action}"] if meta[:"description_#{action}"]
294
+ op[:security] = [] if ca[:unauthenticated]
295
+
296
+ # Path parameters
297
+ path_params = []
298
+ path_params << ID_PARAM if on_member
299
+
300
+ Array(ca[:extra_path_params]).each do |param_name|
301
+ next if redundant_path_param?(param_name, model)
302
+
303
+ path_params << {
304
+ name: param_name,
305
+ in: "path",
306
+ required: true,
307
+ schema: ControllerParser.infer_field_type(param_name),
308
+ description: param_name.tr("_", " ").capitalize
309
+ }
310
+ end
311
+
312
+ # Query parameters
313
+ query_params = []
314
+ if http_method == :get
315
+ if export_action?(action)
316
+ # Exportações: apenas filtros, sem paginação
317
+ query_params = [RANSACK_PARAM]
318
+ elsif list_action?(action)
319
+ query_params = [RANSACK_PARAM, *PAGINATION_PARAMS]
320
+ elsif !on_member
321
+ query_params = PAGINATION_PARAMS.dup
322
+ end
323
+ end
324
+
325
+ all_params = path_params + query_params
326
+ op[:parameters] = all_params unless all_params.empty?
327
+
328
+ # Request body (apenas para POST/PUT/PATCH com schema detectado)
329
+ unless %i[get delete].include?(http_method)
330
+ schema_ref = resolve_input_schema(model, action, ca)
331
+ if schema_ref
332
+ content = build_request_content(schema_ref, ca)
333
+ op[:requestBody] = { required: true, content: content }
334
+ end
335
+ end
336
+
337
+ # Responses
338
+ op[:responses] = if export_action?(action)
339
+ {
340
+ "200" => {
341
+ description: "Arquivo exportado",
342
+ content: {
343
+ "text/csv" => { schema: { type: "string", format: "binary" } },
344
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => {
345
+ schema: { type: "string", format: "binary" }
346
+ }
347
+ }
348
+ },
349
+ "401" => ref_response("Unauthorized")
350
+ }
351
+ else
352
+ {
353
+ "200" => json_response("Operação concluída com sucesso", model),
354
+ "400" => ref_response("BadRequest"),
355
+ "401" => ref_response("Unauthorized"),
356
+ "404" => ref_response("NotFound"),
357
+ "422" => ref_response("UnprocessableEntity")
358
+ }
359
+ end
360
+
361
+ op
362
+ end
363
+
364
+ def list_action?(action_name)
365
+ action_name.to_s.match?(
366
+ /\A(listar|list|buscar|search|filtrar|filter|index|all|todos|todas)|
367
+ _por_\w+\z|
368
+ _(list|all|veiculos|estudantes|contratos|usuarios|documentos|rotas)\z/ix
369
+ )
370
+ end
371
+
372
+ def export_action?(action_name)
373
+ action_name.to_s.match?(/\A(exportar|export|download|baixar)/i) ||
374
+ action_name.to_s.match?(/(exportar|export|download|baixar)\z/i)
375
+ end
376
+
377
+ def redundant_path_param?(param_name, model)
378
+ model_snake = model
379
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
380
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
381
+ .downcase
382
+ param_base = param_name.to_s.sub(/_id\z/, "")
383
+ param_base == model_snake
384
+ end
385
+
386
+ def build_request_content(schema_ref, action_meta)
387
+ is_multipart = action_meta&.dig(:is_multipart) ||
388
+ action_meta&.dig(:file_fields)&.any?
389
+
390
+ if schema_ref.is_a?(Hash) && schema_ref[:_multipart]
391
+ ref = { "$ref" => schema_ref[:_ref] }
392
+ { "multipart/form-data" => { schema: ref } }
393
+ elsif is_multipart
394
+ { "multipart/form-data" => { schema: schema_ref } }
395
+ else
396
+ { "application/json" => { schema: schema_ref } }
397
+ end
398
+ end
399
+
400
+ # ────────────────────── resolve schema de input por ação ──
401
+
402
+ def resolve_input_schema(model, action, action_meta)
403
+ unless action_meta
404
+ fallback = schema_exists?("#{model}Input") ? "#{model}Input" : "GenericInput"
405
+ return { "$ref" => "#/components/schemas/#{fallback}" }
406
+ end
407
+
408
+ if action_meta[:grouped_params]&.any?
409
+ schema_name = "#{model}#{camelize(action)}Input"
410
+ @dynamic_schemas[schema_name] ||= build_grouped_schema(action_meta[:grouped_params])
411
+ return { "$ref" => "#/components/schemas/#{schema_name}" }
412
+ end
413
+
414
+ path_param_names = Array(action_meta[:extra_path_params]).map(&:to_s)
415
+ path_param_names << "id" if action_meta[:on_member]
416
+
417
+ fields = (action_meta[:permit_fields] || []).reject { |f| path_param_names.include?(f.to_s) }
418
+ array_fields = (action_meta[:array_fields] || []).reject { |f| path_param_names.include?(f.to_s) }
419
+ nested_arrays = (action_meta[:nested_arrays] || {}).reject { |k, _| path_param_names.include?(k.to_s) }
420
+ file_fields = (action_meta[:file_fields] || []).reject { |f| path_param_names.include?(f.to_s) }
421
+ extra_fields = (action_meta[:extra_fields] || []).reject { |f| path_param_names.include?(f.to_s) }
422
+
423
+ is_multipart = action_meta[:is_multipart] || file_fields.any?
424
+ uses_column_names = action_meta[:uses_column_names] || false
425
+ col_model = action_meta[:column_names_model]
426
+
427
+ has_content = fields.any? || file_fields.any? || uses_column_names ||
428
+ array_fields.any? || nested_arrays.any?
429
+
430
+ unless has_content
431
+ # Para ações CRUD que sabidamente recebem body, usa fallback.
432
+ # Para ações customizadas sem params, omite completamente o requestBody.
433
+ if %w[create update].include?(action.to_s)
434
+ fallback = schema_exists?("#{model}Input") ? "#{model}Input" : "GenericInput"
435
+ return { "$ref" => "#/components/schemas/#{fallback}" }
436
+ else
437
+ return nil
438
+ end
439
+ end
440
+
441
+ schema_name = "#{model}#{camelize(action)}Input"
442
+
443
+ @dynamic_schemas[schema_name] ||= if uses_column_names
444
+ build_column_names_schema(col_model || model, file_fields, extra_fields)
445
+ else
446
+ build_fields_schema(fields, file_fields: file_fields,
447
+ array_fields: array_fields,
448
+ nested_arrays: nested_arrays)
449
+ end
450
+
451
+ ref = "#/components/schemas/#{schema_name}"
452
+ is_multipart ? { _multipart: true, _ref: ref } : { "$ref" => ref }
453
+ end
454
+
455
+ def build_grouped_schema(groups)
456
+ properties = {}
457
+
458
+ groups.each do |group|
459
+ key = group[:key]
460
+ meta = group[:meta]
461
+ fields = meta[:fields] || []
462
+ next if fields.empty?
463
+
464
+ group_props = {}
465
+ fields.each do |field|
466
+ group_props[field] = ControllerParser.infer_field_type(field)
467
+ .merge(description: field.tr("_", " ").capitalize)
468
+ end
469
+
470
+ properties[key] = {
471
+ type: "object",
472
+ description: "Dados de #{key.tr("_", " ")}",
473
+ properties: group_props
474
+ }
475
+ end
476
+
477
+ {
478
+ type: "object",
479
+ description: "Corpo agrupado por contexto — cada chave corresponde a um grupo de parâmetros",
480
+ properties: properties
481
+ }
482
+ end
483
+
484
+ def build_column_names_schema(model_name, file_fields, extra_fields)
485
+ ignored = %w[id created_at updated_at created_by updated_by deleted_at
486
+ encrypted_password reset_password_token reset_password_sent_at
487
+ remember_created_at refresh_token token_primeiro_acesso]
488
+
489
+ table_name = find_table_for_model(model_name)
490
+ properties = {}
491
+
492
+ if table_name && @schema_parser.tables[table_name]
493
+ @schema_parser.tables[table_name]
494
+ .reject { |c| ignored.include?(c[:name]) }
495
+ .each do |col|
496
+ type_def = SchemaParser::COLUMN_TYPE_MAP[col[:type]] || { type: "string" }
497
+ properties[col[:name]] = type_def.dup.merge(
498
+ description: col[:name].tr("_", " ").capitalize
499
+ )
500
+ end
501
+ end
502
+
503
+ extra_fields.each do |field|
504
+ next if properties.key?(field)
505
+ if file_field_by_name?(field) || file_fields.include?(field)
506
+ properties[field] = { type: "string", format: "binary",
507
+ description: field.tr("_", " ").capitalize }
508
+ else
509
+ properties[field] = ControllerParser.infer_field_type(field)
510
+ .merge(description: field.tr("_", " ").capitalize)
511
+ end
512
+ end
513
+
514
+ file_fields.each do |ff|
515
+ properties[ff] = { type: "string", format: "binary",
516
+ description: ff.tr("_", " ").capitalize }
517
+ end
518
+
519
+ {
520
+ type: "object",
521
+ description: "Enviado como multipart/form-data: campos de texto como form-fields, arquivos como binary",
522
+ properties: properties
523
+ }
524
+ end
525
+
526
+ def build_fields_schema(fields, file_fields: [], array_fields: [], nested_arrays: {})
527
+ properties = {}
528
+
529
+ fields.each do |field|
530
+ if file_fields.include?(field) || file_field_by_name?(field)
531
+ properties[field] = { type: "string", format: "binary",
532
+ description: field.tr("_", " ").capitalize }
533
+ else
534
+ properties[field] = ControllerParser.infer_field_type(field)
535
+ .merge(description: field.tr("_", " ").capitalize)
536
+ end
537
+ end
538
+
539
+ array_fields.each do |field|
540
+ item_type = field.to_s.end_with?("_ids") ? { type: "integer", format: "int64" } : { type: "string" }
541
+ properties[field] = {
542
+ type: "array",
543
+ description: field.tr("_", " ").capitalize,
544
+ items: item_type
545
+ }
546
+ end
547
+
548
+ nested_arrays.each do |field_name, nested_structure|
549
+ item_schema = build_fields_schema(
550
+ nested_structure[:scalar_fields] || [],
551
+ file_fields: nested_structure[:file_fields] || [],
552
+ array_fields: nested_structure[:array_fields] || [],
553
+ nested_arrays: nested_structure[:nested_arrays] || {}
554
+ )
555
+ properties[field_name] = {
556
+ type: "array",
557
+ description: field_name.tr("_", " ").capitalize,
558
+ items: item_schema
559
+ }
560
+ end
561
+
562
+ file_fields.each do |ff|
563
+ next if properties.key?(ff)
564
+ properties[ff] = { type: "string", format: "binary",
565
+ description: ff.tr("_", " ").capitalize }
566
+ end
567
+
568
+ { type: "object", properties: properties }
569
+ end
570
+
571
+ def nested_object_for_fk(fk_field, all_tables)
572
+ base = fk_field.sub(/_id\z/, "")
573
+ table_name = ["#{base}s", "#{base}es", base].find { |c| all_tables.key?(c) }
574
+ return nil unless table_name
575
+ return nil if table_name.match?(LOOKUP_TABLE_PATTERN)
576
+
577
+ ignored = %w[id created_at updated_at created_by updated_by deleted_at]
578
+ cols = all_tables[table_name].reject { |c| ignored.include?(c[:name]) }
579
+ return nil if cols.size <= 1
580
+ return nil if cols.map { |c| c[:name] } == ["descricao"]
581
+
582
+ nested_props = {}
583
+ cols.each do |col|
584
+ next if col[:name].end_with?("_id")
585
+ type_def = SchemaParser::COLUMN_TYPE_MAP[col[:type]] || { type: "string" }
586
+ nested_props[col[:name]] = type_def.dup.merge(
587
+ description: col[:name].tr("_", " ").capitalize
588
+ )
589
+ end
590
+ return nil if nested_props.empty?
591
+
592
+ {
593
+ key: base,
594
+ schema: {
595
+ type: "object",
596
+ description: "Dados de #{base.tr("_", " ")} — alternativa ao #{fk_field}",
597
+ properties: nested_props
598
+ }
599
+ }
600
+ end
601
+
602
+ def build_components
603
+ {
604
+ securitySchemes: {
605
+ BearerAuth: {
606
+ type: "http", scheme: "bearer", bearerFormat: "JWT",
607
+ description: "Token JWT obtido em /api/v1/auth/login"
608
+ }
609
+ },
610
+ schemas: @schema_parser.to_openapi_schemas
611
+ .merge(@dynamic_schemas)
612
+ .merge(shared_schemas),
613
+ responses: {
614
+ "Unauthorized" => error_response("Autenticação necessária"),
615
+ "NotFound" => error_response("Registro não encontrado"),
616
+ "BadRequest" => error_response("Requisição inválida"),
617
+ "UnprocessableEntity" => {
618
+ description: "Erros de validação",
619
+ content: { "application/json" => {
620
+ schema: { "$ref" => "#/components/schemas/ValidationError" }
621
+ } }
622
+ }
623
+ }
624
+ }
625
+ end
626
+
627
+ def schema_exists?(schema_name)
628
+ return true if %w[Error ValidationError GenericResponse GenericInput].include?(schema_name)
629
+ return true if @dynamic_schemas.key?(schema_name)
630
+
631
+ @schema_parser.tables.any? do |table_name, _|
632
+ model = @schema_parser.model_name_for_table(table_name)
633
+ schema_name == model ||
634
+ schema_name == "#{model}Input" ||
635
+ schema_name == "#{model}List"
636
+ end
637
+ end
638
+
639
+ def safe_schema_ref(schema_name)
640
+ if schema_exists?(schema_name)
641
+ { "$ref" => "#/components/schemas/#{schema_name}" }
642
+ else
643
+ { "$ref" => "#/components/schemas/GenericResponse" }
644
+ end
645
+ end
646
+
647
+ def json_response(description, schema_name)
648
+ { description: description,
649
+ content: { "application/json" => {
650
+ schema: safe_schema_ref(schema_name)
651
+ } } }
652
+ end
653
+
654
+ def ref_response(name)
655
+ { "$ref" => "#/components/responses/#{name}" }
656
+ end
657
+
658
+ def error_response(description)
659
+ { description: description,
660
+ content: { "application/json" => {
661
+ schema: { "$ref" => "#/components/schemas/Error" }
662
+ } } }
663
+ end
664
+
665
+ def shared_schemas
666
+ {
667
+ "Error" => {
668
+ type: "object",
669
+ properties: { error: { type: "string" } }
670
+ },
671
+ "ValidationError" => {
672
+ type: "object",
673
+ properties: {
674
+ errors: {
675
+ type: "object",
676
+ additionalProperties: { type: "array", items: { type: "string" } }
677
+ }
678
+ }
679
+ },
680
+ "GenericResponse" => {
681
+ type: "object",
682
+ description: "Resposta do endpoint",
683
+ additionalProperties: true
684
+ },
685
+ "GenericInput" => {
686
+ type: "object",
687
+ description: "Parâmetros do endpoint",
688
+ additionalProperties: true
689
+ }
690
+ }
691
+ end
692
+
693
+ def camelize(str)
694
+ str.to_s.split("_").map(&:capitalize).join
695
+ end
696
+
697
+ def find_table_for_model(model_name)
698
+ underscored = model_name.to_s
699
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
700
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
701
+ .downcase
702
+ ["#{underscored}s", "#{underscored}es", underscored]
703
+ .find { |c| @schema_parser.tables.key?(c) }
704
+ end
705
+
706
+ def file_field_by_name?(name)
707
+ n = name.to_s
708
+ return false if n.end_with?("_url") || n.end_with?("url")
709
+ n.match?(ControllerParser::FILE_FIELD_PATTERN)
710
+ end
711
+ end
712
+ end