grape-swagger 0.10.1 → 0.10.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,495 @@
1
+ module GrapeSwagger
2
+ module DocMethods
3
+ PRIMITIVE_MAPPINGS = {
4
+ 'integer' => %w(integer int32),
5
+ 'long' => %w(integer int64),
6
+ 'float' => %w(number float),
7
+ 'double' => %w(number double),
8
+ 'byte' => %w(string byte),
9
+ 'date' => %w(string date),
10
+ 'dateTime' => %w(string date-time)
11
+ }
12
+
13
+ def name
14
+ @@class_name
15
+ end
16
+
17
+ def as_markdown(description)
18
+ description && @@markdown ? @@markdown.as_markdown(strip_heredoc(description)) : description
19
+ end
20
+
21
+ def parse_params(params, path, method)
22
+ params ||= []
23
+
24
+ parsed_array_params = parse_array_params(params)
25
+
26
+ non_nested_parent_params = get_non_nested_params(parsed_array_params)
27
+
28
+ non_nested_parent_params.map do |param, value|
29
+ items = {}
30
+
31
+ raw_data_type = value[:type] if value.is_a?(Hash)
32
+ raw_data_type ||= 'string'
33
+ data_type = case raw_data_type.to_s
34
+ when 'Hash'
35
+ 'object'
36
+ when 'Rack::Multipart::UploadedFile'
37
+ 'File'
38
+ when 'Virtus::Attribute::Boolean'
39
+ 'boolean'
40
+ when 'Boolean', 'Date', 'Integer', 'String', 'Float'
41
+ raw_data_type.to_s.downcase
42
+ when 'BigDecimal'
43
+ 'long'
44
+ when 'DateTime'
45
+ 'dateTime'
46
+ when 'Numeric'
47
+ 'double'
48
+ when 'Symbol'
49
+ 'string'
50
+ when /^\[(?<type>.*)\]$/
51
+ items[:type] = Regexp.last_match[:type].downcase
52
+ if PRIMITIVE_MAPPINGS.key?(items[:type])
53
+ items[:type], items[:format] = PRIMITIVE_MAPPINGS[items[:type]]
54
+ end
55
+ 'array'
56
+ else
57
+ @@documentation_class.parse_entity_name(raw_data_type)
58
+ end
59
+
60
+ additional_documentation = value.is_a?(Hash) ? value[:documentation] : nil
61
+
62
+ if additional_documentation && value.is_a?(Hash)
63
+ value = additional_documentation.merge(value)
64
+ end
65
+
66
+ description = value.is_a?(Hash) ? value[:desc] || value[:description] : ''
67
+ required = value.is_a?(Hash) ? !!value[:required] : false
68
+ default_value = value.is_a?(Hash) ? value[:default] : nil
69
+ example = value.is_a?(Hash) ? value[:example] : nil
70
+ is_array = value.is_a?(Hash) ? (value[:is_array] || false) : false
71
+ values = value.is_a?(Hash) ? value[:values] : nil
72
+ enum_or_range_values = parse_enum_or_range_values(values)
73
+
74
+ if value.is_a?(Hash) && value.key?(:documentation) && value[:documentation].key?(:param_type)
75
+ param_type = value[:documentation][:param_type]
76
+ if is_array
77
+ items = { '$ref' => data_type }
78
+ data_type = 'array'
79
+ end
80
+ else
81
+ param_type = case
82
+ when path.include?(":#{param}")
83
+ 'path'
84
+ when %w(POST PUT PATCH).include?(method)
85
+ if is_primitive?(data_type)
86
+ 'form'
87
+ else
88
+ 'body'
89
+ end
90
+ else
91
+ 'query'
92
+ end
93
+ end
94
+ name = (value.is_a?(Hash) && value[:full_name]) || param
95
+
96
+ parsed_params = {
97
+ paramType: param_type,
98
+ name: name,
99
+ description: as_markdown(description),
100
+ type: data_type,
101
+ required: required,
102
+ allowMultiple: is_array
103
+ }
104
+
105
+ if PRIMITIVE_MAPPINGS.key?(data_type)
106
+ parsed_params[:type], parsed_params[:format] = PRIMITIVE_MAPPINGS[data_type]
107
+ end
108
+
109
+ parsed_params[:items] = items if items.present?
110
+
111
+ parsed_params[:defaultValue] = example if example
112
+ if default_value && example.blank?
113
+ parsed_params[:defaultValue] = default_value
114
+ end
115
+
116
+ parsed_params.merge!(enum_or_range_values) if enum_or_range_values
117
+ parsed_params
118
+ end
119
+ end
120
+
121
+ def content_types_for(target_class)
122
+ content_types = (target_class.content_types || {}).values
123
+
124
+ if content_types.empty?
125
+ formats = [target_class.format, target_class.default_format].compact.uniq
126
+ formats = Grape::Formatter::Base.formatters({}).keys if formats.empty?
127
+ content_types = Grape::ContentTypes::CONTENT_TYPES.select { |content_type, _mime_type| formats.include? content_type }.values
128
+ end
129
+
130
+ content_types.uniq
131
+ end
132
+
133
+ def parse_info(info)
134
+ {
135
+ contact: info[:contact],
136
+ description: as_markdown(info[:description]),
137
+ license: info[:license],
138
+ licenseUrl: info[:license_url],
139
+ termsOfServiceUrl: info[:terms_of_service_url],
140
+ title: info[:title]
141
+ }.delete_if { |_, value| value.blank? }
142
+ end
143
+
144
+ def parse_header_params(params)
145
+ params ||= []
146
+
147
+ params.map do |param, value|
148
+ data_type = 'string'
149
+ description = value.is_a?(Hash) ? value[:description] : ''
150
+ required = value.is_a?(Hash) ? !!value[:required] : false
151
+ default_value = value.is_a?(Hash) ? value[:default] : nil
152
+ param_type = 'header'
153
+
154
+ parsed_params = {
155
+ paramType: param_type,
156
+ name: param,
157
+ description: as_markdown(description),
158
+ type: data_type,
159
+ required: required
160
+ }
161
+
162
+ parsed_params.merge!(defaultValue: default_value) if default_value
163
+
164
+ parsed_params
165
+ end
166
+ end
167
+
168
+ def parse_path(path, version)
169
+ # adapt format to swagger format
170
+ parsed_path = path.sub(/\(\..*\)$/, @@hide_format ? '' : '.{format}')
171
+
172
+ # This is attempting to emulate the behavior of
173
+ # Rack::Mount::Strexp. We cannot use Strexp directly because
174
+ # all it does is generate regular expressions for parsing URLs.
175
+ # TODO: Implement a Racc tokenizer to properly generate the
176
+ # parsed path.
177
+ parsed_path = parsed_path.gsub(/:([a-zA-Z_]\w*)/, '{\1}')
178
+
179
+ # add the version
180
+ version ? parsed_path.gsub('{version}', version) : parsed_path
181
+ end
182
+
183
+ def parse_entity_name(model)
184
+ if model.respond_to?(:entity_name)
185
+ model.entity_name
186
+ else
187
+ name = model.to_s
188
+ entity_parts = name.split('::')
189
+ entity_parts.reject! { |p| p == 'Entity' || p == 'Entities' }
190
+ entity_parts.join('::')
191
+ end
192
+ end
193
+
194
+ def parse_entity_models(models)
195
+ result = {}
196
+ models.each do |model|
197
+ name = (model.instance_variable_get(:@root) || parse_entity_name(model))
198
+ properties = {}
199
+ required = []
200
+
201
+ model.documentation.each do |property_name, property_info|
202
+ p = property_info.dup
203
+
204
+ required << property_name.to_s if p.delete(:required)
205
+
206
+ type = if p[:type]
207
+ p.delete(:type)
208
+ else
209
+ exposure = model.exposures[property_name]
210
+ parse_entity_name(exposure[:using]) if exposure
211
+ end
212
+
213
+ if p.delete(:is_array)
214
+ p[:items] = generate_typeref(type)
215
+ p[:type] = 'array'
216
+ else
217
+ p.merge! generate_typeref(type)
218
+ end
219
+
220
+ # rename Grape Entity's "desc" to "description"
221
+ property_description = p.delete(:desc)
222
+ p[:description] = property_description if property_description
223
+
224
+ # rename Grape's 'values' to 'enum'
225
+ select_values = p.delete(:values)
226
+ if select_values
227
+ select_values = select_values.call if select_values.is_a?(Proc)
228
+ p[:enum] = select_values
229
+ end
230
+
231
+ if PRIMITIVE_MAPPINGS.key?(p['type'])
232
+ p['type'], p['format'] = PRIMITIVE_MAPPINGS[p['type']]
233
+ end
234
+
235
+ properties[property_name] = p
236
+ end
237
+
238
+ result[name] = {
239
+ id: name,
240
+ properties: properties
241
+ }
242
+ result[name].merge!(required: required) unless required.empty?
243
+ end
244
+
245
+ result
246
+ end
247
+
248
+ def models_with_included_presenters(models)
249
+ all_models = models
250
+
251
+ models.each do |model|
252
+ # get model references from exposures with a documentation
253
+ nested_models = model.exposures.map do |_, config|
254
+ if config.key?(:documentation)
255
+ model = config[:using]
256
+ model.respond_to?(:constantize) ? model.constantize : model
257
+ end
258
+ end.compact
259
+
260
+ # get all nested models recursively
261
+ additional_models = nested_models.map do |nested_model|
262
+ models_with_included_presenters([nested_model])
263
+ end.flatten
264
+
265
+ all_models += additional_models
266
+ end
267
+
268
+ all_models
269
+ end
270
+
271
+ def is_primitive?(type)
272
+ %w(object integer long float double string byte boolean date dateTime).include? type
273
+ end
274
+
275
+ def generate_typeref(type)
276
+ type_s = type.to_s.sub(/^[A-Z]/) { |f| f.downcase }
277
+ if is_primitive? type_s
278
+ { 'type' => type_s }
279
+ else
280
+ { '$ref' => parse_entity_name(type) }
281
+ end
282
+ end
283
+
284
+ def parse_http_codes(codes, models)
285
+ codes ||= {}
286
+ codes.map do |k, v, m|
287
+ models << m if m
288
+ http_code_hash = {
289
+ code: k,
290
+ message: v
291
+ }
292
+ http_code_hash[:responseModel] = parse_entity_name(m) if m
293
+ http_code_hash
294
+ end
295
+ end
296
+
297
+ def strip_heredoc(string)
298
+ indent = string.scan(/^[ \t]*(?=\S)/).min.try(:size) || 0
299
+ string.gsub(/^[ \t]{#{indent}}/, '')
300
+ end
301
+
302
+ def parse_base_path(base_path, request)
303
+ if base_path.is_a?(Proc)
304
+ base_path.call(request)
305
+ elsif base_path.is_a?(String)
306
+ URI(base_path).relative? ? URI.join(request.base_url, base_path).to_s : base_path
307
+ else
308
+ request.base_url
309
+ end
310
+ end
311
+
312
+ def hide_documentation_path
313
+ @@hide_documentation_path
314
+ end
315
+
316
+ def mount_path
317
+ @@mount_path
318
+ end
319
+
320
+ def setup(options)
321
+ defaults = {
322
+ target_class: nil,
323
+ mount_path: '/swagger_doc',
324
+ base_path: nil,
325
+ api_version: '0.1',
326
+ markdown: nil,
327
+ hide_documentation_path: false,
328
+ hide_format: false,
329
+ format: nil,
330
+ models: [],
331
+ info: {},
332
+ authorizations: nil,
333
+ root_base_path: true,
334
+ api_documentation: { desc: 'Swagger compatible API description' },
335
+ specific_api_documentation: { desc: 'Swagger compatible API description for specific API' }
336
+ }
337
+
338
+ options = defaults.merge(options)
339
+
340
+ target_class = options[:target_class]
341
+ @@mount_path = options[:mount_path]
342
+ @@class_name = options[:class_name] || options[:mount_path].gsub('/', '')
343
+ @@markdown = options[:markdown] ? GrapeSwagger::Markdown.new(options[:markdown]) : nil
344
+ @@hide_format = options[:hide_format]
345
+ api_version = options[:api_version]
346
+ authorizations = options[:authorizations]
347
+ root_base_path = options[:root_base_path]
348
+ extra_info = options[:info]
349
+ api_doc = options[:api_documentation].dup
350
+ specific_api_doc = options[:specific_api_documentation].dup
351
+ @@models = options[:models] || []
352
+
353
+ @@hide_documentation_path = options[:hide_documentation_path]
354
+
355
+ if options[:format]
356
+ [:format, :default_format, :default_error_formatter].each do |method|
357
+ send(method, options[:format])
358
+ end
359
+ end
360
+
361
+ @@documentation_class = self
362
+
363
+ desc api_doc.delete(:desc), api_doc
364
+ get @@mount_path do
365
+ header['Access-Control-Allow-Origin'] = '*'
366
+ header['Access-Control-Request-Method'] = '*'
367
+
368
+ namespaces = target_class.combined_namespaces
369
+ namespace_routes = target_class.combined_namespace_routes
370
+
371
+ if @@hide_documentation_path
372
+ namespace_routes.reject! { |route, _value| "/#{route}/".index(@@documentation_class.parse_path(@@mount_path, nil) << '/') == 0 }
373
+ end
374
+
375
+ namespace_routes_array = namespace_routes.keys.map do |local_route|
376
+ next if namespace_routes[local_route].map(&:route_hidden).all? { |value| value.respond_to?(:call) ? value.call : value }
377
+
378
+ url_format = '.{format}' unless @@hide_format
379
+
380
+ original_namespace_name = target_class.combined_namespace_identifiers.key?(local_route) ? target_class.combined_namespace_identifiers[local_route] : local_route
381
+ description = namespaces[original_namespace_name] && namespaces[original_namespace_name].options[:desc]
382
+ description ||= "Operations about #{original_namespace_name.pluralize}"
383
+
384
+ {
385
+ path: "/#{local_route}#{url_format}",
386
+ description: description
387
+ }
388
+ end.compact
389
+
390
+ output = {
391
+ apiVersion: api_version,
392
+ swaggerVersion: '1.2',
393
+ produces: @@documentation_class.content_types_for(target_class),
394
+ apis: namespace_routes_array,
395
+ info: @@documentation_class.parse_info(extra_info)
396
+ }
397
+
398
+ output[:authorizations] = authorizations unless authorizations.nil? || authorizations.empty?
399
+
400
+ output
401
+ end
402
+
403
+ desc specific_api_doc.delete(:desc), { params:
404
+ specific_api_doc.delete(:params) || {} }.merge(specific_api_doc)
405
+ params do
406
+ requires :name, type: String, desc: 'Resource name of mounted API'
407
+ end
408
+ get "#{@@mount_path}/:name" do
409
+ header['Access-Control-Allow-Origin'] = '*'
410
+ header['Access-Control-Request-Method'] = '*'
411
+
412
+ models = []
413
+ routes = target_class.combined_namespace_routes[params[:name]]
414
+ error!('Not Found', 404) unless routes
415
+
416
+ visible_ops = routes.reject do |route|
417
+ route.route_hidden.respond_to?(:call) ? route.route_hidden.call : route.route_hidden
418
+ end
419
+
420
+ ops = visible_ops.group_by do |route|
421
+ @@documentation_class.parse_path(route.route_path, api_version)
422
+ end
423
+
424
+ error!('Not Found', 404) unless ops.any?
425
+
426
+ apis = []
427
+
428
+ ops.each do |path, op_routes|
429
+ operations = op_routes.map do |route|
430
+ notes = @@documentation_class.as_markdown(route.route_detail || route.route_notes)
431
+
432
+ http_codes = @@documentation_class.parse_http_codes(route.route_http_codes, models)
433
+
434
+ models |= @@models if @@models.present?
435
+
436
+ models |= Array(route.route_entity) if route.route_entity.present?
437
+
438
+ models = @@documentation_class.models_with_included_presenters(models.flatten.compact)
439
+
440
+ operation = {
441
+ notes: notes.to_s,
442
+ summary: route.route_description || '',
443
+ nickname: route.route_nickname || (route.route_method + route.route_path.gsub(/[\/:\(\)\.]/, '-')),
444
+ method: route.route_method,
445
+ parameters: @@documentation_class.parse_header_params(route.route_headers) + @@documentation_class.parse_params(route.route_params, route.route_path, route.route_method),
446
+ type: route.route_is_array ? 'array' : 'void'
447
+ }
448
+ operation[:authorizations] = route.route_authorizations unless route.route_authorizations.nil? || route.route_authorizations.empty?
449
+ if operation[:parameters].any? { |param| param[:type] == 'File' }
450
+ operation.merge!(consumes: ['multipart/form-data'])
451
+ end
452
+ operation.merge!(responseMessages: http_codes) unless http_codes.empty?
453
+
454
+ if route.route_entity
455
+ type = @@documentation_class.parse_entity_name(Array(route.route_entity).first)
456
+ if route.route_is_array
457
+ operation.merge!(items: { '$ref' => type })
458
+ else
459
+ operation.merge!(type: type)
460
+ end
461
+ end
462
+
463
+ operation[:nickname] = route.route_nickname if route.route_nickname
464
+ operation
465
+ end.compact
466
+ apis << {
467
+ path: path,
468
+ operations: operations
469
+ }
470
+ end
471
+
472
+ # use custom resource naming if available
473
+ if target_class.combined_namespace_identifiers.key? params[:name]
474
+ resource_path = target_class.combined_namespace_identifiers[params[:name]]
475
+ else
476
+ resource_path = params[:name]
477
+ end
478
+ api_description = {
479
+ apiVersion: api_version,
480
+ swaggerVersion: '1.2',
481
+ resourcePath: "/#{resource_path}",
482
+ produces: @@documentation_class.content_types_for(target_class),
483
+ apis: apis
484
+ }
485
+
486
+ base_path = @@documentation_class.parse_base_path(options[:base_path], request)
487
+ api_description[:basePath] = base_path if base_path && base_path.size > 0 && root_base_path != false
488
+ api_description[:models] = @@documentation_class.parse_entity_models(models) unless models.empty?
489
+ api_description[:authorizations] = authorizations if authorizations
490
+
491
+ api_description
492
+ end
493
+ end
494
+ end
495
+ end