grape-swagger 0.10.1 → 0.10.2

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,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