Module: Api::OpenApiHelper

Included in:
FactoryBot::ExampleBot
Defined in:
app/helpers/api/open_api_helper.rb

Instance Method Summary collapse

Instance Method Details

#automatic_components_for(model, **options) ⇒ Object

[View source]

50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
# File 'app/helpers/api/open_api_helper.rb', line 50

def automatic_components_for(model, **options)
  locals = options.delete(:locals) || {}

  path = "app/views/api/#{@version}"
  paths = [path, "app/views"] + gem_paths.product(%W[/#{path} /app/views]).map(&:join)

  # Transform values the same way we do for Jbuilder templates
  Jbuilder::Schema::Template.prepend ValuesTransformer

  jbuilder = Jbuilder::Schema.renderer(paths, locals: {
    # If we ever get to the point where we need a real model here, we should implement an example team in seeds that we can source it from.
    model.name.underscore.split("/").last.to_sym => model.new,
    # Same here, if we ever need this to be a real object, this should be `test@example.com` with an `SecureRandom.hex` password.
    :current_user => User.new
  }.merge(locals))

  factory_path = "test/factories/#{model.model_name.collection}.rb"
  cache_key = [:example, model.model_name.param_key, File.ctime(factory_path)]
  example = if model.name.constantize.singleton_methods.any?
    FactoryBot.example(model.model_name.param_key.to_sym)
  else
    Rails.cache.fetch(cache_key) { FactoryBot.example(model.model_name.param_key.to_sym) }
  end

  schema_json = jbuilder.json(
    example || model.new,
    title: I18n.t("#{model.name.underscore.pluralize}.label"),
    # TODO Improve this. We don't have a generic description for models we can use here.
    description: I18n.t("#{model.name.underscore.pluralize}.label")
  )

  attributes_output = JSON.parse(schema_json)

  # Allow customization of Attributes
  customize_component!(attributes_output, options[:attributes]) if options[:attributes]

  # Add "Attributes" part to $ref's
  update_ref_values!(attributes_output)

  # Rails attachments aren't technically attributes in a model,
  # so we add the attributes manually to make them available in the API.
  if model.attachment_reflections.any?
    model.attachment_reflections.each do |reflection|
      attribute_name = reflection.first

      attributes_output["properties"][attribute_name] = {
        "type" => "object",
        "description" => attribute_name.titleize.to_s
      }

      attributes_output["example"].merge!({attribute_name.to_s => nil})
    end
  end

  if has_strong_parameters?("Api::#{@version.upcase}::#{model.name.pluralize}Controller")
    strong_parameter_keys = strong_parameter_keys_for(model.name, @version)
    strong_parameter_keys_for_update = strong_parameter_keys_for(model.name, @version, "update")

    # Create separate parameter schema for create and update methods
    create_parameters_output = process_strong_parameters(model, strong_parameter_keys, schema_json, "create", **options)
    update_parameters_output = process_strong_parameters(model, strong_parameter_keys_for_update, schema_json, "update", **options)

    # We need to skip TeamParameters, UserParameters & InvitationParametersUpdate as they are not present in
    # the bullet train api schema
    if model.name == "Team" || model.name == "User"
      create_parameters_output = nil
    elsif model.name == "Invitation"
      update_parameters_output = nil
    end

    output = indent(attributes_output.to_yaml.gsub("---", "#{model.name.gsub("::", "")}Attributes:"), 3)
    output += indent("    " + create_parameters_output.to_yaml.gsub("---", "#{model.name.gsub("::", "")}Parameters:"), 3) if create_parameters_output
    output += indent("    " + update_parameters_output.to_yaml.gsub("---", "#{model.name.gsub("::", "")}ParametersUpdate:"), 3) if update_parameters_output
    output.html_safe
  else

    indent(attributes_output.to_yaml.gsub("---", "#{model.name.gsub("::", "")}Attributes:"), 3)
      .html_safe
  end
end

#automatic_paths_for(model, parent, except: []) ⇒ Object

[View source]

26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# File 'app/helpers/api/open_api_helper.rb', line 26

def automatic_paths_for(model, parent, except: [])
  output = render("api/#{@version}/open_api/shared/paths", model_name: model.model_name.collection, except: except)
  output = Scaffolding::Transformer.new(model.name, [parent&.name]).transform_string(output).html_safe

  custom_actions_file_path = "api/#{@version}/open_api/#{model.model_name.collection}/paths"
  custom_output = render(custom_actions_file_path).html_safe if lookup_context.exists?(custom_actions_file_path, [], true)

  FactoryBot::ExampleBot::REST_METHODS.each do |method|
    if (code = FactoryBot.send(method, model.model_name.param_key.to_sym, version: @version))
      output.gsub!("🚅 #{method}", code)
      custom_output&.gsub!("🚅 #{method}", code)
    end
  end

  if custom_output
    merge = deep_merge(YAML.safe_load(output), YAML.safe_load(custom_output)).to_yaml.html_safe
    # YAML.safe_load escapes emojis https://github.com/ruby/psych/issues/371
    # Next line returns emojis back and removes yaml garbage
    output = merge.gsub("---", "").gsub(/\\u[\da-f]{8}/i) { |m| [m[-8..].to_i(16)].pack("U") }
  end

  indent(output, 1)
end

#current_modelObject

[View source]

10
11
12
# File 'app/helpers/api/open_api_helper.rb', line 10

def current_model
  @model_stack.last
end

#description_for(model) ⇒ Object

[View source]

180
181
182
# File 'app/helpers/api/open_api_helper.rb', line 180

def description_for(model)
  external_doc "#{model.name.underscore}_description"
end

#external_doc(filename) ⇒ Object

[View source]

168
169
170
171
172
173
174
175
176
177
178
# File 'app/helpers/api/open_api_helper.rb', line 168

def external_doc(filename)
  caller_path, line_number = caller.find { |line| line.include?(".yaml.erb:") }.split(":")
  indentation = File.readlines(caller_path)[line_number.to_i - 1].match(/^(\s*)/)[1]
  path = "app/views/api/#{@version}/open_api/docs/#{filename}.md"

  raise "Markdown file not found: #{path}" unless File.exist?(path)

  File.read(path).lines.map { |line| "  #{indentation}#{line}".rstrip }.join("\n").prepend("|\n").html_safe
rescue Errno::ENOENT, Errno::EACCES, RuntimeError => e
  "Error loading markdown description: #{e.message}"
end

#for_model(model) ⇒ Object

[View source]

14
15
16
17
18
19
20
# File 'app/helpers/api/open_api_helper.rb', line 14

def for_model(model)
  @model_stack ||= []
  @model_stack << model
  result = yield
  @model_stack.pop
  result
end

#gem_pathsObject

[View source]

22
23
24
# File 'app/helpers/api/open_api_helper.rb', line 22

def gem_paths
  @gem_paths ||= `bundle show --paths`.lines.map { |gem_path| gem_path.chomp }
end

#indent(string, count) ⇒ Object

[View source]

3
4
5
6
7
8
# File 'app/helpers/api/open_api_helper.rb', line 3

def indent(string, count)
  lines = string.lines
  first_line = lines.shift
  lines = lines.map { |line| ("  " * count).to_s + line }
  lines.unshift(first_line).join.html_safe
end

#paths_for(model) ⇒ Object

[View source]

162
163
164
165
166
# File 'app/helpers/api/open_api_helper.rb', line 162

def paths_for(model)
  for_model model do
    indent(render("api/#{@version}/open_api/#{model.name.underscore.pluralize}/paths"), 1)
  end
end

#process_strong_parameters(model, strong_parameter_keys, schema_json, method_type, **options) ⇒ Object

[View source]

131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
# File 'app/helpers/api/open_api_helper.rb', line 131

def process_strong_parameters(model, strong_parameter_keys, schema_json, method_type, **options)
  parameters_output = JSON.parse(schema_json)
  parameters_output["required"].select! { |key| strong_parameter_keys.include?(key.to_sym) }
  parameters_output["properties"].select! { |key| strong_parameter_keys.include?(key.to_sym) }
  parameters_output["example"]&.select! { |key, value| strong_parameter_keys.include?(key.to_sym) }

  # Allow customization of Parameters
  parameters_custom = options[:parameters][method_type] if options[:parameters].is_a?(Hash) && options[:parameters].key?(method_type)
  parameters_custom ||= options[:parameters]
  customize_component!(parameters_output, parameters_custom, method_type) if parameters_custom

  # We need to wrap the example parameters with the model name as expected by the API controllers
  if parameters_output["example"]
    parameters_output["example"] = {model.model_name.param_key => parameters_output["example"]}
  end

  parameters_output
end

#strong_parameter_keys_for(model_name, version, method_type = "create") ⇒ Object

[View source]

150
151
152
153
154
155
156
157
158
159
160
# File 'app/helpers/api/open_api_helper.rb', line 150

def strong_parameter_keys_for(model_name, version, method_type = "create")
  strong_params_module = "::Api::#{version.upcase}::#{model_name.pluralize}Controller::StrongParameters".constantize
  strong_params_reporter = BulletTrain::Api::StrongParametersReporter.new(model_name.constantize, strong_params_module)
  strong_parameter_keys = strong_params_reporter.report(method_type)

  if strong_parameter_keys.last.is_a?(Hash)
    strong_parameter_keys += strong_parameter_keys.pop.keys
  end

  strong_parameter_keys
end