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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +8 -0
- data/MIT-LICENSE +21 -0
- data/README.md +71 -0
- data/app/controllers/swagger_docs_rails/swagger_controller.rb +228 -0
- data/config/routes.rb +7 -0
- data/lib/generators/swagger_docs_rails/install/install_generator.rb +23 -0
- data/lib/generators/swagger_docs_rails/install/templates/swagger_docs_rails.rb +14 -0
- data/lib/swagger_docs_rails/configuration.rb +22 -0
- data/lib/swagger_docs_rails/controller_parser.rb +556 -0
- data/lib/swagger_docs_rails/engine.rb +14 -0
- data/lib/swagger_docs_rails/generator.rb +40 -0
- data/lib/swagger_docs_rails/openapi_builder.rb +712 -0
- data/lib/swagger_docs_rails/schema_parser.rb +150 -0
- data/lib/swagger_docs_rails/version.rb +5 -0
- data/lib/swagger_docs_rails.rb +25 -0
- data/lib/tasks/swagger_docs_rails.rake +72 -0
- metadata +81 -0
|
@@ -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
|