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,556 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# lib/swagger_docs_rails/controller_parser.rb
|
|
3
|
+
#
|
|
4
|
+
# Escaneia app/controllers/api/**/*.rb e extrai por controller:
|
|
5
|
+
#
|
|
6
|
+
# Ações CRUD padrão (index, show, create, update, destroy)
|
|
7
|
+
# Ações customizadas (importar, upload_arquivo, etc.)
|
|
8
|
+
# params.permit por ação — direto no corpo ou via método privado referenciado
|
|
9
|
+
# Campos de arquivo (format: binary) detectados pelo nome e pelo corpo da ação
|
|
10
|
+
# Detecção de multipart/form-data (Roo::, .original_filename, params[:arquivo], etc.)
|
|
11
|
+
# Padrão Model.column_names.reject{}.permit(*permitted, :extra_field)
|
|
12
|
+
# Método HTTP de ações customizadas (heurística por nome + @swagger_method_X)
|
|
13
|
+
# Metadados de documentação via comentários @swagger_*
|
|
14
|
+
# Estruturas aninhadas recursivas (ex: responsaveis: [ { nome:, enderecos: [] } ])
|
|
15
|
+
|
|
16
|
+
module SwaggerDocsRails
|
|
17
|
+
class ControllerParser
|
|
18
|
+
CRUD_ACTIONS = %w[index show create update destroy].freeze
|
|
19
|
+
|
|
20
|
+
# Heurística nome → HTTP para ações customizadas
|
|
21
|
+
CUSTOM_HTTP_HINTS = {
|
|
22
|
+
/\A(import|importar|upload|enviar|processar|executar|sincronizar|
|
|
23
|
+
gerar|calcular|acionar|criar|cadastrar)/x => :post,
|
|
24
|
+
/\A(download|exportar|relatorio|report|preview|visualizar|listar|buscar)/x => :get,
|
|
25
|
+
/\A(ativar|desativar|aprovar|rejeitar|cancelar|bloquear|
|
|
26
|
+
desbloquear|definir|remover|atualizar|alterar)/x => :patch,
|
|
27
|
+
/\Adelete_/ => :delete
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
# Tipo OpenAPI pelo nome do campo
|
|
31
|
+
FIELD_TYPE_MAP = {
|
|
32
|
+
/_id\z/ => { type: "integer", format: "int64" },
|
|
33
|
+
/\Aemail|_email\z/ => { type: "string", format: "email" },
|
|
34
|
+
/password|senha/ => { type: "string", format: "password" },
|
|
35
|
+
/\Acpf\z|\Acnpj\z|cpf_cnpj/ => { type: "string" },
|
|
36
|
+
/\Adata_|\A.*_at\z|data_nascimento/ => { type: "string", format: "date" },
|
|
37
|
+
/valor|preco|percentual|taxa/ => { type: "number", format: "double" },
|
|
38
|
+
/\Aativo\z|proprio|embutido|possui|admin|bloqueado/ => { type: "boolean" },
|
|
39
|
+
/nivel|numero|tentativas/ => { type: "integer" }
|
|
40
|
+
}.freeze
|
|
41
|
+
|
|
42
|
+
# Indicadores de upload no corpo de uma ação ou nos seus params privados
|
|
43
|
+
MULTIPART_INDICATORS = [
|
|
44
|
+
/\.original_filename\b/,
|
|
45
|
+
/\.content_type\b/,
|
|
46
|
+
/\.read\b/,
|
|
47
|
+
/Roo::/,
|
|
48
|
+
/ActionDispatch.*UploadedFile/,
|
|
49
|
+
/CarrierWave|Shrine|ActiveStorage\.attach/,
|
|
50
|
+
/params\[:arquivo\]/,
|
|
51
|
+
/params\[:file\]/,
|
|
52
|
+
/params\[:anexo\]/,
|
|
53
|
+
/params\[:imagem\]/,
|
|
54
|
+
/params\[:foto\]/,
|
|
55
|
+
/\.tempfile\b/
|
|
56
|
+
].freeze
|
|
57
|
+
|
|
58
|
+
# Padrões de campo de arquivo pelo nome (excluindo _url)
|
|
59
|
+
FILE_FIELD_PATTERN = /
|
|
60
|
+
\Aimagem_|\Afoto_|\Aanexo_|\Adocumento_|\Aplanilha_| # prefixo
|
|
61
|
+
_imagem\z|_foto\z|_anexo\z|_arquivo\z| # sufixo
|
|
62
|
+
\Aarquivo\z|\Afile\z|_file\z| # exatos
|
|
63
|
+
\Aanexo\z|\Aimagem\z|\Afoto\z| # exatos pt-br
|
|
64
|
+
\Alogo\z|\Aavatar\z|\Athumbnail\z|\Acover\z| # imagens comuns
|
|
65
|
+
\Adocumento\z|\Aplanilha\z|\Arelatorio\z # docs
|
|
66
|
+
/x.freeze
|
|
67
|
+
|
|
68
|
+
attr_reader :controllers
|
|
69
|
+
|
|
70
|
+
def initialize(paths = nil)
|
|
71
|
+
@search_paths = Array(paths || [Rails.root.join("app", "controllers", "api")])
|
|
72
|
+
@controllers = []
|
|
73
|
+
@routes_map = parse_routes_file!
|
|
74
|
+
parse!
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def to_resources
|
|
78
|
+
@controllers
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def self.infer_field_type(field_name)
|
|
82
|
+
FIELD_TYPE_MAP.each do |pattern, type_def|
|
|
83
|
+
return type_def.dup if field_name.to_s.match?(pattern)
|
|
84
|
+
end
|
|
85
|
+
{ type: "string" }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
# ─────────────────────────────────────────────────────── parse ──
|
|
91
|
+
|
|
92
|
+
def parse!
|
|
93
|
+
@search_paths.each do |base|
|
|
94
|
+
Dir.glob(File.join(base.to_s, "**", "*.rb")).sort.each do |file|
|
|
95
|
+
info = extract_info(file, File.read(file))
|
|
96
|
+
@controllers << info if info
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def extract_info(file, content)
|
|
102
|
+
klass_match = content.match(/class\s+([\w:]+Controller)/)
|
|
103
|
+
return nil unless klass_match
|
|
104
|
+
|
|
105
|
+
klass = klass_match[1]
|
|
106
|
+
return nil unless klass.start_with?("Api::")
|
|
107
|
+
|
|
108
|
+
version = extract_version(klass)
|
|
109
|
+
resource_name = File.basename(file, ".rb").sub(/_controller$/, "")
|
|
110
|
+
model_name = infer_model_name(resource_name)
|
|
111
|
+
|
|
112
|
+
public_body, private_body = split_public_private(content)
|
|
113
|
+
public_actions = scan_public_actions(public_body)
|
|
114
|
+
return nil if public_actions.empty?
|
|
115
|
+
|
|
116
|
+
unauthenticated_actions = extract_unauthenticated_actions(content)
|
|
117
|
+
private_permit_map = extract_private_permit_methods(private_body)
|
|
118
|
+
actions_meta = build_actions_meta(public_actions, public_body, private_permit_map,
|
|
119
|
+
unauthenticated_actions)
|
|
120
|
+
|
|
121
|
+
crud = public_actions & CRUD_ACTIONS
|
|
122
|
+
custom = public_actions - CRUD_ACTIONS
|
|
123
|
+
|
|
124
|
+
{
|
|
125
|
+
controller_class: klass,
|
|
126
|
+
resource: resource_name,
|
|
127
|
+
model: model_name,
|
|
128
|
+
version: version,
|
|
129
|
+
actions: crud,
|
|
130
|
+
custom_actions: build_custom_actions(custom, content, actions_meta),
|
|
131
|
+
actions_meta: actions_meta,
|
|
132
|
+
unauthenticated_actions: unauthenticated_actions,
|
|
133
|
+
extra_meta: extract_swagger_meta(content)
|
|
134
|
+
}
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def split_public_private(content)
|
|
138
|
+
idx = content.index(/^\s+private\b/)
|
|
139
|
+
idx ? [content[0...idx], content[idx..]] : [content, ""]
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def scan_public_actions(body)
|
|
143
|
+
body.scan(/^\s+def\s+(\w+)\b/).flatten.uniq
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def extract_private_permit_methods(private_body)
|
|
147
|
+
result = {}
|
|
148
|
+
private_body.scan(/def\s+(\w+)(.*?)(?=\n\s+def\s|\z)/m) do |mname, mbody|
|
|
149
|
+
meta = extract_permit_meta(mbody)
|
|
150
|
+
result[mname] = meta if meta[:fields].any? || meta[:uses_column_names] || meta[:nested_arrays].any?
|
|
151
|
+
end
|
|
152
|
+
result
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def extract_permit_meta(body)
|
|
156
|
+
structure = extract_permit_fields(body)
|
|
157
|
+
uses_column_names = body.match?(/\.column_names\b/)
|
|
158
|
+
model_name = nil
|
|
159
|
+
extra_fields = []
|
|
160
|
+
|
|
161
|
+
if uses_column_names
|
|
162
|
+
m = body.match(/(\w+)\.column_names/)
|
|
163
|
+
model_name = m[1] if m
|
|
164
|
+
extra_fields = extract_extra_fields_after_splat(body)
|
|
165
|
+
structure[:scalar_fields] = (structure[:scalar_fields] | extra_fields)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
file_fields = detect_file_fields_by_name(structure[:scalar_fields]) | detect_file_fields_by_access(body)
|
|
169
|
+
|
|
170
|
+
{
|
|
171
|
+
fields: structure[:scalar_fields],
|
|
172
|
+
array_fields: structure[:array_fields],
|
|
173
|
+
nested_arrays: structure[:nested_arrays],
|
|
174
|
+
file_fields: file_fields,
|
|
175
|
+
extra_fields: extra_fields,
|
|
176
|
+
uses_column_names: uses_column_names,
|
|
177
|
+
column_names_model: model_name
|
|
178
|
+
}
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def extract_extra_fields_after_splat(body)
|
|
182
|
+
extras = []
|
|
183
|
+
body.scan(/\.permit\((?:[^)]*\*\w+[^,)]*),\s*(.*?)\)/m) do |after|
|
|
184
|
+
after.first.to_s.scan(/[:"'](\w+)["']?/).flatten.each { |f| extras << f unless f.empty? }
|
|
185
|
+
end
|
|
186
|
+
extras.uniq
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def build_actions_meta(actions, public_body, private_permit_map, unauthenticated_actions = [])
|
|
190
|
+
meta = {}
|
|
191
|
+
actions.each do |action|
|
|
192
|
+
body = extract_action_body(action, public_body)
|
|
193
|
+
next unless body
|
|
194
|
+
|
|
195
|
+
permit_meta = resolve_permit_meta(body, private_permit_map)
|
|
196
|
+
body_multipart = detect_multipart(body)
|
|
197
|
+
all_file_fields = (permit_meta[:file_fields] + detect_file_fields_by_access(body)).uniq
|
|
198
|
+
|
|
199
|
+
permit_has_file = permit_meta[:fields].any? { |f| file_field_by_name?(f) }
|
|
200
|
+
is_multipart = body_multipart || all_file_fields.any? || permit_has_file
|
|
201
|
+
|
|
202
|
+
if permit_has_file
|
|
203
|
+
permit_files = permit_meta[:fields].select { |f| file_field_by_name?(f) }
|
|
204
|
+
all_file_fields = (all_file_fields + permit_files).uniq
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
meta[action] = {
|
|
208
|
+
permit_fields: permit_meta[:fields],
|
|
209
|
+
array_fields: permit_meta[:array_fields] || [],
|
|
210
|
+
nested_arrays: permit_meta[:nested_arrays] || {},
|
|
211
|
+
file_fields: all_file_fields,
|
|
212
|
+
extra_fields: permit_meta[:extra_fields] || [],
|
|
213
|
+
uses_column_names: permit_meta[:uses_column_names],
|
|
214
|
+
column_names_model: permit_meta[:column_names_model],
|
|
215
|
+
grouped_params: permit_meta[:grouped_params],
|
|
216
|
+
is_multipart: is_multipart,
|
|
217
|
+
http_method: crud_http_method(action),
|
|
218
|
+
uses_service: body.match?(/Service\.new|\.call\b/),
|
|
219
|
+
unauthenticated: unauthenticated_actions.include?(action)
|
|
220
|
+
}
|
|
221
|
+
end
|
|
222
|
+
meta
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def extract_action_body(action, content)
|
|
226
|
+
m = content.match(/def\s+#{Regexp.escape(action)}\b(.*?)(?=\n\s+def\s|\z)/m)
|
|
227
|
+
m ? m[1] : nil
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def resolve_permit_meta(action_body, private_permit_map)
|
|
231
|
+
inline = extract_permit_meta(action_body)
|
|
232
|
+
if inline[:fields].any? || inline[:array_fields].any? || inline[:nested_arrays].any?
|
|
233
|
+
return inline
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
called = action_body.scan(/\b(\w+_params?\b|\w+_permit\w*)\b/).flatten.uniq
|
|
237
|
+
matched = called.filter_map { |m| [m, private_permit_map[m]] if private_permit_map[m] }
|
|
238
|
+
|
|
239
|
+
return empty_permit_meta if matched.empty?
|
|
240
|
+
|
|
241
|
+
if matched.size == 1
|
|
242
|
+
return matched.first[1].merge(grouped_params: nil)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
groups = matched.map do |method_name, meta|
|
|
246
|
+
group_key = method_name.sub(/_params?\z/, "").sub(/\Aparams_/, "")
|
|
247
|
+
{ key: group_key, meta: meta }
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
{
|
|
251
|
+
fields: [],
|
|
252
|
+
array_fields: [],
|
|
253
|
+
nested_arrays: {},
|
|
254
|
+
file_fields: [],
|
|
255
|
+
extra_fields: [],
|
|
256
|
+
uses_column_names: false,
|
|
257
|
+
column_names_model: nil,
|
|
258
|
+
grouped_params: groups
|
|
259
|
+
}
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def empty_permit_meta
|
|
263
|
+
{ fields: [], array_fields: [], nested_arrays: {}, file_fields: [], extra_fields: [],
|
|
264
|
+
uses_column_names: false, column_names_model: nil, grouped_params: nil }
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def extract_permit_fields(text)
|
|
268
|
+
result = { scalar_fields: [], array_fields: [], nested_arrays: {} }
|
|
269
|
+
|
|
270
|
+
normalized = text.gsub(/\n/, ' ').gsub(/\s+/, ' ')
|
|
271
|
+
|
|
272
|
+
normalized.scan(/params(?:\[[\w:"' ]+\])?\.(?:require\([\w:"']+\)\.)?permit\((.*?)\)/m) do |args|
|
|
273
|
+
parsed = parse_permit_args_recursive(args.first.to_s)
|
|
274
|
+
merge_permit_result(result, parsed)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
normalized.scan(/params\.expect\(\s*\w+:\s*\[(.*?)\]\s*\)/m) do |args|
|
|
278
|
+
parsed = parse_permit_args_recursive(args.first.to_s)
|
|
279
|
+
merge_permit_result(result, parsed)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
normalized.scan(/\b(\w+)\.permit\((.*?)\)/m) do |var, args|
|
|
283
|
+
next if var == "params"
|
|
284
|
+
if text.match?(/\b#{Regexp.escape(var)}\s*=.*\bparams\b/m)
|
|
285
|
+
parsed = parse_permit_args_recursive(args)
|
|
286
|
+
merge_permit_result(result, parsed)
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
result
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def merge_permit_result(target, source)
|
|
294
|
+
target[:scalar_fields] |= source[:scalar_fields]
|
|
295
|
+
target[:array_fields] |= source[:array_fields]
|
|
296
|
+
source[:nested_arrays].each do |k, v|
|
|
297
|
+
target[:nested_arrays][k] ||= { scalar_fields: [], array_fields: [], nested_arrays: {} }
|
|
298
|
+
merge_permit_result(target[:nested_arrays][k], v)
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def parse_permit_args_recursive(args_str)
|
|
303
|
+
result = { scalar_fields: [], array_fields: [], nested_arrays: {} }
|
|
304
|
+
return result if args_str.strip.empty?
|
|
305
|
+
|
|
306
|
+
tokens = tokenize_permit_args(args_str)
|
|
307
|
+
tokens.each do |token|
|
|
308
|
+
token = token.strip
|
|
309
|
+
next if token.empty?
|
|
310
|
+
|
|
311
|
+
if token.include?(':') && !token.start_with?(':')
|
|
312
|
+
key, value = token.split(':', 2)
|
|
313
|
+
key = clean_symbol(key)
|
|
314
|
+
value = value.strip
|
|
315
|
+
|
|
316
|
+
if value.start_with?('[') && value.end_with?(']')
|
|
317
|
+
inner = value[1..-2]
|
|
318
|
+
if inner.include?(':') && !inner.match(/^:\w+$/)
|
|
319
|
+
result[:nested_arrays][key] = parse_permit_args_recursive(inner)
|
|
320
|
+
else
|
|
321
|
+
result[:array_fields] << key
|
|
322
|
+
end
|
|
323
|
+
elsif value.start_with?('{') && value.end_with?('}')
|
|
324
|
+
inner = value[1..-2]
|
|
325
|
+
result[:nested_arrays][key] = parse_permit_args_recursive(inner)
|
|
326
|
+
else
|
|
327
|
+
result[:scalar_fields] << key
|
|
328
|
+
end
|
|
329
|
+
else
|
|
330
|
+
field = clean_symbol(token)
|
|
331
|
+
result[:scalar_fields] << field unless field.empty?
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
result
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def clean_symbol(str)
|
|
339
|
+
str.to_s.strip.gsub(/\A[:"]|["]\z/, '')
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def tokenize_permit_args(str)
|
|
343
|
+
tokens = []
|
|
344
|
+
current = +''
|
|
345
|
+
depth = 0
|
|
346
|
+
in_string = false
|
|
347
|
+
|
|
348
|
+
str.each_char do |ch|
|
|
349
|
+
case ch
|
|
350
|
+
when '[', '{', '('
|
|
351
|
+
depth += 1
|
|
352
|
+
current << ch
|
|
353
|
+
when ']', '}', ')'
|
|
354
|
+
depth -= 1
|
|
355
|
+
current << ch
|
|
356
|
+
when ','
|
|
357
|
+
if depth == 0 && !in_string
|
|
358
|
+
tokens << current
|
|
359
|
+
current = +''
|
|
360
|
+
else
|
|
361
|
+
current << ch
|
|
362
|
+
end
|
|
363
|
+
when '"', "'"
|
|
364
|
+
in_string = !in_string
|
|
365
|
+
current << ch
|
|
366
|
+
else
|
|
367
|
+
current << ch
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
tokens << current unless current.empty?
|
|
371
|
+
tokens.map(&:strip)
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def build_custom_actions(action_names, full_content, actions_meta)
|
|
375
|
+
action_names.map do |name|
|
|
376
|
+
ameta = actions_meta[name] || {}
|
|
377
|
+
all_file_fields = ameta[:file_fields] || []
|
|
378
|
+
{
|
|
379
|
+
name: name,
|
|
380
|
+
http_method: infer_custom_http_method(name, full_content),
|
|
381
|
+
on_member: infer_on_member(name, full_content),
|
|
382
|
+
extra_path_params: extra_path_params_for(name),
|
|
383
|
+
permit_fields: ameta[:permit_fields] || [],
|
|
384
|
+
array_fields: ameta[:array_fields] || [],
|
|
385
|
+
nested_arrays: ameta[:nested_arrays] || {},
|
|
386
|
+
file_fields: all_file_fields,
|
|
387
|
+
extra_fields: ameta[:extra_fields] || [],
|
|
388
|
+
grouped_params: ameta[:grouped_params],
|
|
389
|
+
uses_service: ameta[:uses_service] || false,
|
|
390
|
+
is_multipart: ameta[:is_multipart] || all_file_fields.any?,
|
|
391
|
+
uses_column_names: ameta[:uses_column_names] || false,
|
|
392
|
+
column_names_model: ameta[:column_names_model],
|
|
393
|
+
unauthenticated: ameta[:unauthenticated] || false
|
|
394
|
+
}
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def infer_custom_http_method(action_name, content)
|
|
399
|
+
comment = content.match(/#\s*@swagger_method_#{action_name}:\s*(\w+)/i)
|
|
400
|
+
return comment[1].downcase.to_sym if comment
|
|
401
|
+
|
|
402
|
+
if @routes_map.key?(action_name.to_s)
|
|
403
|
+
entry = @routes_map[action_name.to_s]
|
|
404
|
+
return entry.is_a?(Hash) ? entry[:method] : entry
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
CUSTOM_HTTP_HINTS.each { |pattern, method| return method if action_name.match?(pattern) }
|
|
408
|
+
:post
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def extra_path_params_for(action_name)
|
|
412
|
+
entry = @routes_map[action_name.to_s]
|
|
413
|
+
return [] unless entry.is_a?(Hash)
|
|
414
|
+
entry[:path_params] || []
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
def infer_on_member(action_name, full_content)
|
|
418
|
+
routes_path = Rails.root.join("config", "routes.rb")
|
|
419
|
+
return false unless File.exist?(routes_path)
|
|
420
|
+
|
|
421
|
+
routes_content = File.read(routes_path)
|
|
422
|
+
|
|
423
|
+
return false if routes_content.match?(/\b(get|post|put|patch|delete)\s+['":][^'"\n]*\b#{Regexp.escape(action_name)}\b[^'"\n]*['"]?[^\n]*on:\s*:collection/i)
|
|
424
|
+
return false if inside_block?(routes_content, "collection", action_name)
|
|
425
|
+
return true if routes_content.match?(/\b(get|post|put|patch|delete)\s+['":][^'"\n]*\b#{Regexp.escape(action_name)}\b[^'"\n]*['"]?[^\n]*on:\s*:member/i)
|
|
426
|
+
return true if inside_block?(routes_content, "member", action_name)
|
|
427
|
+
|
|
428
|
+
false
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def inside_block?(content, block_type, action_name)
|
|
432
|
+
lines = content.lines
|
|
433
|
+
lines.each_with_index do |line, idx|
|
|
434
|
+
next unless line.match?(/\b#{block_type}\s+do\b/)
|
|
435
|
+
|
|
436
|
+
depth = 1
|
|
437
|
+
block_lines = []
|
|
438
|
+
lines[(idx + 1)..].each do |inner_line|
|
|
439
|
+
depth += inner_line.scan(/\bdo\b|\bbegin\b/).size
|
|
440
|
+
depth -= inner_line.scan(/\bend\b/).size
|
|
441
|
+
break if depth <= 0
|
|
442
|
+
block_lines << inner_line
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
block_text = block_lines.join
|
|
446
|
+
return true if block_text.match?(/\b(get|post|put|patch|delete)\s+:#{Regexp.escape(action_name)}\b/) ||
|
|
447
|
+
block_text.match?(/\b(get|post|put|patch|delete)\s+['"][^'"]*#{Regexp.escape(action_name)}[^'"]*['"]/) ||
|
|
448
|
+
block_text.match?(/action:\s*:#{Regexp.escape(action_name)}\b/)
|
|
449
|
+
end
|
|
450
|
+
false
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
def extract_unauthenticated_actions(content)
|
|
454
|
+
actions = []
|
|
455
|
+
content.scan(/skip_before_action\s+:(?:authenticate\w*)\s*(?:,\s*only:\s*[\[%]i?\[?(.*?)\]?\s*\n)/m) do |match|
|
|
456
|
+
match.first.to_s.scan(/[:"'](\w+)["']?/).flatten.each { |a| actions << a }
|
|
457
|
+
end
|
|
458
|
+
content.scan(/skip_before_action\s+[:'"]\w+['"]?\s*,\s*only:\s*[\[%]i?\[?([^\]]+)\]?/m) do |match|
|
|
459
|
+
match.first.to_s.scan(/[:"'](\w+)["']?/).flatten.each { |a| actions << a }
|
|
460
|
+
end
|
|
461
|
+
actions.uniq
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
def detect_multipart(body)
|
|
465
|
+
MULTIPART_INDICATORS.any? { |p| body.match?(p) }
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
def detect_file_fields_by_name(fields)
|
|
469
|
+
fields.select { |f| file_field_by_name?(f) }
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
def detect_file_fields_by_access(body)
|
|
473
|
+
fields = []
|
|
474
|
+
body.scan(/params\[:(\w+)\]/).flatten.each do |f|
|
|
475
|
+
fields << f if f.match?(/arquivo|file|planilha|documento|imagem|foto|anexo/)
|
|
476
|
+
end
|
|
477
|
+
body.scan(/params\[:(\w+)\][^\n]*(?:\.original_filename|\.read|\.content_type)/).flatten.each do |f|
|
|
478
|
+
fields << f
|
|
479
|
+
end
|
|
480
|
+
fields.uniq
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
def file_field_by_name?(name)
|
|
484
|
+
n = name.to_s
|
|
485
|
+
return false if n.end_with?("_url") || n.end_with?("url")
|
|
486
|
+
n.match?(FILE_FIELD_PATTERN)
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
def parse_routes_file!
|
|
490
|
+
routes_path = Rails.root.join("config", "routes.rb")
|
|
491
|
+
return {} unless File.exist?(routes_path)
|
|
492
|
+
|
|
493
|
+
content = File.read(routes_path)
|
|
494
|
+
routes_map = {}
|
|
495
|
+
|
|
496
|
+
content.scan(/\b(get|post|put|patch|delete)\s+:(\w+)/) do |method, action|
|
|
497
|
+
next if CRUD_ACTIONS.include?(action)
|
|
498
|
+
routes_map[action] ||= { method: method.downcase.to_sym, path_params: [] }
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
content.scan(/\b(get|post|put|patch|delete)\s+['"]([^'"]+)['"]\s*,\s*action:\s*:(\w+)/) do |method, path, action|
|
|
502
|
+
next if CRUD_ACTIONS.include?(action)
|
|
503
|
+
path_params = path.scan(/:(\w+)/).flatten
|
|
504
|
+
routes_map[action] ||= { method: method.downcase.to_sym, path_params: path_params }
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
content.scan(/\b(get|post|put|patch|delete)\s+['"]([^'"]+)['"]\s*,\s*to:\s*['"][^'"#]*#(\w+)['"]/) do |method, path, action|
|
|
508
|
+
next if CRUD_ACTIONS.include?(action)
|
|
509
|
+
path_params = path.scan(/:(\w+)/).flatten
|
|
510
|
+
routes_map[action] ||= { method: method.downcase.to_sym, path_params: path_params }
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
content.scan(/\b(get|post|put|patch|delete)\s+['"]([^'"]+)['"]\s*(?!,)/) do |method, path|
|
|
514
|
+
action = path.split("/").last.to_s.gsub(/:.*/, "").gsub(/[^a-z_]/, "")
|
|
515
|
+
next if action.empty? || CRUD_ACTIONS.include?(action)
|
|
516
|
+
path_params = path.scan(/:(\w+)/).flatten
|
|
517
|
+
routes_map[action] ||= { method: method.downcase.to_sym, path_params: path_params }
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
routes_map
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
def extract_version(klass)
|
|
524
|
+
m = klass.match(/::V(\d+)::/)
|
|
525
|
+
m ? "v#{m[1]}" : "v1"
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
def infer_model_name(resource_name)
|
|
529
|
+
singular = resource_name.singularize rescue resource_name.sub(/s$/, "")
|
|
530
|
+
parts = singular.split("_")
|
|
531
|
+
parts.first == "g" ? "G" + parts[1..].map(&:capitalize).join : parts.map(&:capitalize).join
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
def crud_http_method(action)
|
|
535
|
+
{ "index" => :get, "show" => :get, "create" => :post,
|
|
536
|
+
"update" => :patch, "destroy" => :delete }.fetch(action, :get)
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
def extract_swagger_meta(content)
|
|
540
|
+
meta = {}
|
|
541
|
+
tag_m = content.match(/#\s*@swagger_tag:\s*(.+)/)
|
|
542
|
+
meta[:tag] = tag_m[1].strip if tag_m
|
|
543
|
+
|
|
544
|
+
content.scan(/#\s*@swagger_summary_(\w+):\s*(.+)/).each { |a, v| meta[:"summary_#{a}"] = v.strip }
|
|
545
|
+
content.scan(/#\s*@swagger_description_(\w+):\s*(.+)/).each { |a, v| meta[:"description_#{a}"] = v.strip }
|
|
546
|
+
content.scan(/#\s*@swagger_params_(\w+):\s*(.+)/).each do |action, fields_str|
|
|
547
|
+
meta[:"params_#{action}"] = fields_str.strip.split(/\s*,\s*/).map(&:strip)
|
|
548
|
+
end
|
|
549
|
+
content.scan(/#\s*@swagger_response_(\w+):\s*(.+)/).each do |action, fields_str|
|
|
550
|
+
meta[:"response_#{action}"] = fields_str.strip.split(/\s*,\s*/).map(&:strip)
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
meta
|
|
554
|
+
end
|
|
555
|
+
end
|
|
556
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails"
|
|
4
|
+
require "action_controller/railtie"
|
|
5
|
+
|
|
6
|
+
module SwaggerDocsRails
|
|
7
|
+
class Engine < ::Rails::Engine
|
|
8
|
+
isolate_namespace SwaggerDocsRails
|
|
9
|
+
|
|
10
|
+
rake_tasks do
|
|
11
|
+
load File.expand_path("../tasks/swagger_docs_rails.rake", __dir__)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# lib/swagger_docs_rails/generator.rb
|
|
3
|
+
|
|
4
|
+
module SwaggerDocsRails
|
|
5
|
+
class Generator
|
|
6
|
+
def initialize(opts = {})
|
|
7
|
+
@schema_path = opts[:schema_path]
|
|
8
|
+
@controllers_path = opts[:controllers_path]
|
|
9
|
+
@config = opts.fetch(:config, {})
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def generate
|
|
13
|
+
schema_parser = SchemaParser.new(@schema_path)
|
|
14
|
+
controller_parser = ControllerParser.new(controller_paths)
|
|
15
|
+
OpenapiBuilder.new(schema_parser, controller_parser, @config).build
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def generate_json
|
|
19
|
+
JSON.pretty_generate(generate)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def write(output_path = nil)
|
|
23
|
+
output_path ||= SwaggerDocsRails.configuration.output_path
|
|
24
|
+
FileUtils.mkdir_p(File.dirname(output_path.to_s))
|
|
25
|
+
File.write(output_path.to_s, JSON.pretty_generate(generate))
|
|
26
|
+
output_path
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def controller_paths
|
|
32
|
+
return @controllers_path if @controllers_path
|
|
33
|
+
|
|
34
|
+
[
|
|
35
|
+
Rails.root.join("app", "controllers", "api"),
|
|
36
|
+
*Array(SwaggerDocsRails.configuration.additional_controller_paths)
|
|
37
|
+
]
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|