redmine_airbrake_backend 0.4.3 → 0.5.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 +4 -4
- data/Gemfile.lock +91 -0
- data/Rakefile +3 -8
- data/app/controllers/airbrake_controller.rb +107 -127
- data/app/controllers/airbrake_notice_controller.rb +25 -0
- data/app/controllers/airbrake_project_settings_controller.rb +5 -4
- data/app/controllers/airbrake_report_controller.rb +15 -0
- data/app/helpers/airbrake_helper.rb +40 -15
- data/app/models/airbrake_project_setting.rb +1 -0
- data/app/views/airbrake/issue_description/default.erb +52 -0
- data/config/routes.rb +5 -1
- data/db/migrate/20130708102357_create_airbrake_project_settings.rb +0 -2
- data/db/migrate/20131003151228_add_reopen_repeat_description.rb +0 -2
- data/lib/redmine_airbrake_backend.rb +0 -2
- data/lib/redmine_airbrake_backend/engine.rb +10 -10
- data/lib/redmine_airbrake_backend/error.rb +78 -0
- data/lib/redmine_airbrake_backend/patches/projects_helper.rb +8 -7
- data/lib/redmine_airbrake_backend/report/ios.rb +71 -0
- data/lib/redmine_airbrake_backend/request.rb +132 -0
- data/lib/redmine_airbrake_backend/request/json.rb +73 -0
- data/lib/redmine_airbrake_backend/request/xml.rb +121 -0
- data/lib/redmine_airbrake_backend/version.rb +2 -1
- data/redmine_airbrake_backend.gemspec +3 -3
- data/test/unit/notice_test.rb +1 -3
- metadata +11 -4
- data/app/views/airbrake/_issue_description.erb +0 -50
- data/lib/redmine_airbrake_backend/notice.rb +0 -109
@@ -0,0 +1,15 @@
|
|
1
|
+
# Controller for airbrake notices
|
2
|
+
class AirbrakeReportController < ::AirbrakeController
|
3
|
+
prepend_before_filter :parse_json_request
|
4
|
+
|
5
|
+
accept_api_auth :report
|
6
|
+
|
7
|
+
# Handle airbrake reports
|
8
|
+
def report
|
9
|
+
render json: {
|
10
|
+
report: {
|
11
|
+
id: (@results.first[:hash] rescue nil)
|
12
|
+
}
|
13
|
+
}
|
14
|
+
end
|
15
|
+
end
|
@@ -1,5 +1,7 @@
|
|
1
|
-
|
1
|
+
require 'htmlentities'
|
2
2
|
|
3
|
+
module AirbrakeHelper
|
4
|
+
# Wiki markup for a table
|
3
5
|
def format_table(data)
|
4
6
|
lines = []
|
5
7
|
data.each do |key, value|
|
@@ -9,6 +11,7 @@ module AirbrakeHelper
|
|
9
11
|
lines.join("\n")
|
10
12
|
end
|
11
13
|
|
14
|
+
# Wiki markup for logs
|
12
15
|
def format_log(data)
|
13
16
|
lines = []
|
14
17
|
data.each do |log|
|
@@ -18,41 +21,63 @@ module AirbrakeHelper
|
|
18
21
|
lines.join("\n")
|
19
22
|
end
|
20
23
|
|
24
|
+
# Wiki markup for a list item
|
21
25
|
def format_list_item(name, value)
|
22
26
|
return '' if value.blank?
|
27
|
+
|
23
28
|
"* *#{name}:* #{value}"
|
24
29
|
end
|
25
30
|
|
31
|
+
# Wiki markup for backtrace element with link to repository if possible
|
26
32
|
def format_backtrace_element(element)
|
27
33
|
@htmlentities ||= HTMLEntities.new
|
28
34
|
|
29
35
|
repository = repository_for_backtrace_element(element)
|
30
36
|
|
31
37
|
if repository.blank?
|
32
|
-
|
33
|
-
|
34
|
-
|
38
|
+
if element[:number].blank?
|
39
|
+
markup = "@#{@htmlentities.decode(element[:file])}@"
|
40
|
+
else
|
41
|
+
markup = "@#{@htmlentities.decode(element[:file])}:#{element[:number]}@"
|
42
|
+
end
|
35
43
|
else
|
36
|
-
|
44
|
+
filename = @htmlentities.decode(filename_for_backtrace_element(element))
|
45
|
+
|
46
|
+
if repository.identifier.blank?
|
47
|
+
markup = "source:\"#{filename}#L#{element[:number]}\""
|
48
|
+
else
|
49
|
+
markup = "source:\"#{repository.identifier}|#{filename}#L#{element[:number]}\""
|
50
|
+
end
|
37
51
|
end
|
38
52
|
|
39
|
-
|
53
|
+
markup + " in ??<notextile>#{@htmlentities.decode(element[:method])}</notextile>??"
|
40
54
|
end
|
41
55
|
|
42
56
|
private
|
43
57
|
|
44
58
|
def repository_for_backtrace_element(element)
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
59
|
+
return nil unless element[:file].start_with?('[PROJECT_ROOT]')
|
60
|
+
|
61
|
+
filename = filename_for_backtrace_element(element)
|
62
|
+
|
63
|
+
repositories_for_backtrace.find { |r| r.entry(filename) }
|
64
|
+
end
|
65
|
+
|
66
|
+
def repositories_for_backtrace
|
67
|
+
return @_bactrace_repositories unless @_bactrace_repositories.nil?
|
68
|
+
|
69
|
+
if request.repository.present?
|
70
|
+
@_bactrace_repositories = [request.repository]
|
71
|
+
else
|
72
|
+
@_bactrace_repositories = request.project.repositories.to_a
|
53
73
|
end
|
54
74
|
|
55
|
-
|
75
|
+
@_bactrace_repositories
|
56
76
|
end
|
57
77
|
|
78
|
+
def filename_for_backtrace_element(element)
|
79
|
+
return nil if element[:file].blank?
|
80
|
+
|
81
|
+
element[:file][14..-1]
|
82
|
+
end
|
58
83
|
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
<% if error.present? %>
|
2
|
+
h1. <%= error.message %>
|
3
|
+
|
4
|
+
<% if error.backtrace.present? %>
|
5
|
+
h2. Stacktrace:
|
6
|
+
|
7
|
+
p((. <%=raw error.backtrace.collect { |e| format_backtrace_element(e) }.join("\n") %>
|
8
|
+
<% end %>
|
9
|
+
<% end %>
|
10
|
+
|
11
|
+
<% if request.request.present? %>
|
12
|
+
h2. Request:
|
13
|
+
|
14
|
+
<%=format_list_item('URL', request.request[:url]) %>
|
15
|
+
<%=format_list_item('Component', request.request[:component]) %>
|
16
|
+
<%=format_list_item('Action', request.request[:action]) %>
|
17
|
+
|
18
|
+
<% if request.request[:params].present? %>
|
19
|
+
h2. Parameters:
|
20
|
+
|
21
|
+
<%=format_table(request.request[:params]) %>
|
22
|
+
<% end %>
|
23
|
+
|
24
|
+
<% if request.request[:cgi_data].present? %>
|
25
|
+
h2. Headers:
|
26
|
+
|
27
|
+
<%=format_table(request.request[:cgi_data]) %>
|
28
|
+
<% end %>
|
29
|
+
|
30
|
+
<% log = request.request[:session].delete(:log) if request.request[:session].present? %>
|
31
|
+
<% if request.request[:session].present? %>
|
32
|
+
h2. Session:
|
33
|
+
|
34
|
+
<%=format_table(request.request[:session]) %>
|
35
|
+
<% end %>
|
36
|
+
|
37
|
+
<% if log.present? %>
|
38
|
+
h2. Log:
|
39
|
+
|
40
|
+
<pre>
|
41
|
+
<%=format_log(log) %>
|
42
|
+
</pre>
|
43
|
+
<% end %>
|
44
|
+
<% end %>
|
45
|
+
|
46
|
+
<% if request.env.present? %>
|
47
|
+
h2. Environment
|
48
|
+
|
49
|
+
<%=format_list_item('Name', request.environment_name) %>
|
50
|
+
<%=format_list_item('Directory', request.env[:project_root]) %>
|
51
|
+
<%=format_list_item('Hostname', request.env[:hostname]) %>
|
52
|
+
<% end %>
|
data/config/routes.rb
CHANGED
@@ -1,6 +1,10 @@
|
|
1
1
|
Rails.application.routes.draw do
|
2
|
-
|
2
|
+
# notifier api
|
3
|
+
post '/notifier_api/v2/notices' => 'airbrake_notice#notice_xml'
|
4
|
+
post '/notifier_api/v3/notices' => 'airbrake_notice#notice_json'
|
5
|
+
post '/notifier_api/v3/reports/:type' => 'airbrake_report#report'
|
3
6
|
|
7
|
+
# settings
|
4
8
|
resources :projects do
|
5
9
|
member do
|
6
10
|
post 'airbrake_settings' => 'airbrake_project_settings#update'
|
@@ -1,5 +1,4 @@
|
|
1
1
|
class CreateAirbrakeProjectSettings < ActiveRecord::Migration
|
2
|
-
|
3
2
|
def up
|
4
3
|
create_table :airbrake_project_settings do |t|
|
5
4
|
t.references :project, index: true, null: false
|
@@ -14,5 +13,4 @@ class CreateAirbrakeProjectSettings < ActiveRecord::Migration
|
|
14
13
|
def down
|
15
14
|
drop_table :airbrake_project_settings
|
16
15
|
end
|
17
|
-
|
18
16
|
end
|
@@ -1,5 +1,4 @@
|
|
1
1
|
class AddReopenRepeatDescription < ActiveRecord::Migration
|
2
|
-
|
3
2
|
def up
|
4
3
|
add_column :airbrake_project_settings, :reopen_repeat_description, :boolean
|
5
4
|
|
@@ -16,5 +15,4 @@ class AddReopenRepeatDescription < ActiveRecord::Migration
|
|
16
15
|
def down
|
17
16
|
remove_column :airbrake_project_settings, :reopen_repeat_description
|
18
17
|
end
|
19
|
-
|
20
18
|
end
|
@@ -30,20 +30,20 @@ module RedmineAirbrakeBackend
|
|
30
30
|
|
31
31
|
def register_redmine_plugin
|
32
32
|
Redmine::Plugin.register :redmine_airbrake_backend do
|
33
|
-
name
|
34
|
-
author
|
35
|
-
author_url
|
36
|
-
description
|
37
|
-
url
|
38
|
-
version
|
39
|
-
requires_redmine :
|
40
|
-
directory
|
33
|
+
name 'Airbrake Backend'
|
34
|
+
author 'Florian Schwab'
|
35
|
+
author_url 'https://github.com/ydkn'
|
36
|
+
description 'Airbrake Backend for Redmine'
|
37
|
+
url 'https://github.com/ydkn/redmine_airbrake_backend'
|
38
|
+
version ::RedmineAirbrakeBackend::VERSION
|
39
|
+
requires_redmine version_or_higher: '2.4.0'
|
40
|
+
directory RedmineAirbrakeBackend.directory
|
41
41
|
|
42
42
|
project_module :airbrake do
|
43
|
-
permission :manage_airbrake, {airbrake: [:update]}
|
43
|
+
permission :manage_airbrake, { airbrake: [:update] }
|
44
44
|
end
|
45
45
|
|
46
|
-
settings default: {hash_field: '', occurrences_field: ''}, partial: 'settings/airbrake'
|
46
|
+
settings default: { hash_field: '', occurrences_field: '' }, partial: 'settings/airbrake'
|
47
47
|
end
|
48
48
|
end
|
49
49
|
end
|
@@ -0,0 +1,78 @@
|
|
1
|
+
require 'digest/md5'
|
2
|
+
|
3
|
+
module RedmineAirbrakeBackend
|
4
|
+
# Error received by airbrake
|
5
|
+
class Error
|
6
|
+
attr_accessor :request
|
7
|
+
attr_reader :type, :message, :backtrace
|
8
|
+
attr_reader :airbrake_hash, :subject, :attachments
|
9
|
+
|
10
|
+
def initialize(error_data)
|
11
|
+
# Data
|
12
|
+
@data = error_data
|
13
|
+
|
14
|
+
# Type
|
15
|
+
@type = @data[:type]
|
16
|
+
|
17
|
+
# Message
|
18
|
+
@message = @data[:message]
|
19
|
+
|
20
|
+
# Backtrace
|
21
|
+
@backtrace = @data[:backtrace]
|
22
|
+
|
23
|
+
# Attachments
|
24
|
+
@attachments = @data[:attachments]
|
25
|
+
|
26
|
+
# Hash
|
27
|
+
@airbrake_hash = generate_hash
|
28
|
+
|
29
|
+
# Subject
|
30
|
+
@subject = generate_subject
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def generate_hash
|
36
|
+
h = []
|
37
|
+
h << filter_hex_values(@type)
|
38
|
+
h << filter_hex_values(@message)
|
39
|
+
h += normalized_backtrace
|
40
|
+
|
41
|
+
Digest::MD5.hexdigest(h.compact.join("\n"))
|
42
|
+
end
|
43
|
+
|
44
|
+
def generate_subject
|
45
|
+
s = ''
|
46
|
+
|
47
|
+
if @type.blank? || @message.starts_with?("#{@type}:")
|
48
|
+
s = "[#{@airbrake_hash[0..7]}] #{@message}"
|
49
|
+
else
|
50
|
+
s = "[#{@airbrake_hash[0..7]}] #{@type}: #{@message}"
|
51
|
+
end
|
52
|
+
|
53
|
+
s[0..254].strip
|
54
|
+
end
|
55
|
+
|
56
|
+
def normalized_backtrace
|
57
|
+
if @backtrace.present?
|
58
|
+
@backtrace.map do |e|
|
59
|
+
"#{e[:file]}|#{normalize_method_name(e[:method])}|#{e[:number]}" rescue nil
|
60
|
+
end.compact
|
61
|
+
else
|
62
|
+
[]
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
def normalize_method_name(method_name)
|
67
|
+
name = e[:method]
|
68
|
+
.downcase
|
69
|
+
.gsub(/_\d+_/, '') # ruby blocks
|
70
|
+
|
71
|
+
filter_hex_values(name)
|
72
|
+
end
|
73
|
+
|
74
|
+
def filter_hex_values(value)
|
75
|
+
value.gsub(/0x[0-9a-f]+/, '')
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
@@ -8,17 +8,18 @@ module RedmineAirbrakeBackend::Patches
|
|
8
8
|
alias_method_chain :project_settings_tabs, :airbrake_backend_tab
|
9
9
|
end
|
10
10
|
|
11
|
+
# add airbrake tab to project settings
|
11
12
|
def project_settings_tabs_with_airbrake_backend_tab
|
12
13
|
tabs = project_settings_tabs_without_airbrake_backend_tab
|
13
14
|
|
14
|
-
tabs.push(
|
15
|
-
:
|
16
|
-
|
17
|
-
:
|
18
|
-
|
19
|
-
|
15
|
+
tabs.push(
|
16
|
+
name: 'airbrake',
|
17
|
+
action: :manage_airbrake,
|
18
|
+
partial: 'projects/settings/airbrake',
|
19
|
+
label: :project_module_airbrake
|
20
|
+
)
|
20
21
|
|
21
|
-
tabs.select{|tab| User.current.allowed_to?(tab[:action], @project)}
|
22
|
+
tabs.select { |tab| User.current.allowed_to?(tab[:action], @project) }
|
22
23
|
end
|
23
24
|
end
|
24
25
|
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
module RedmineAirbrakeBackend
|
2
|
+
# Represents a report contained in a request
|
3
|
+
class Report
|
4
|
+
# iOS report
|
5
|
+
class Ios
|
6
|
+
# Parse an iOS crash log
|
7
|
+
def self.parse(data)
|
8
|
+
error = {
|
9
|
+
backtrace: [],
|
10
|
+
attachments: []
|
11
|
+
}
|
12
|
+
|
13
|
+
header_finished = false
|
14
|
+
next_line_is_message = false
|
15
|
+
crashed_thread = false
|
16
|
+
indicent_identifier = nil
|
17
|
+
|
18
|
+
data.split("\n").each do |line|
|
19
|
+
header_finished = true if line =~ /^(Application Specific Information|Last Exception Backtrace|Thread \d+( Crashed)?):$/
|
20
|
+
|
21
|
+
unless header_finished
|
22
|
+
key, value = line.split(':', 2)
|
23
|
+
|
24
|
+
next if key.blank? || value.blank?
|
25
|
+
|
26
|
+
error[:type] = value.strip if key.strip == 'Exception Type'
|
27
|
+
error[:message] = value.strip if key.strip == 'Exception Codes'
|
28
|
+
|
29
|
+
indicent_identifier = value.strip if key.strip == 'Incident Identifier'
|
30
|
+
end
|
31
|
+
|
32
|
+
if next_line_is_message
|
33
|
+
next_line_is_message = false
|
34
|
+
|
35
|
+
error[:message] = line
|
36
|
+
|
37
|
+
if line =~ /^\*\*\* Terminating app due to uncaught exception '([^']+)', reason: '\*\*\* (.*)'$/
|
38
|
+
error[:type] = Regexp.last_match(1)
|
39
|
+
error[:message] = Regexp.last_match(2)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
crashed_thread = false if line =~ /^Thread \d+:$/
|
44
|
+
|
45
|
+
if crashed_thread
|
46
|
+
if line =~ /^(\d+)\s+([^\s]+)\s+(0x[0-9a-f]+)\s+(.+) \+ (\d+)$/
|
47
|
+
error[:backtrace] << {
|
48
|
+
file: Regexp.last_match(2),
|
49
|
+
method: Regexp.last_match(4),
|
50
|
+
line: Regexp.last_match(5),
|
51
|
+
}
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
crashed_thread = true if error[:backtrace].blank? && line =~ /^(Last Exception Backtrace|Thread \d+ Crashed):$/
|
56
|
+
|
57
|
+
next_line_is_message = true if line =~ /^Application Specific Information:$/
|
58
|
+
end
|
59
|
+
|
60
|
+
return nil if error.blank?
|
61
|
+
|
62
|
+
error[:attachments] << {
|
63
|
+
filename: "#{indicent_identifier}.crash",
|
64
|
+
data: data
|
65
|
+
} if indicent_identifier.present?
|
66
|
+
|
67
|
+
error
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
@@ -0,0 +1,132 @@
|
|
1
|
+
require 'json'
|
2
|
+
require 'base64'
|
3
|
+
require 'redmine_airbrake_backend/error'
|
4
|
+
|
5
|
+
module RedmineAirbrakeBackend
|
6
|
+
# Represents a request received by airbrake
|
7
|
+
class Request
|
8
|
+
class Error < StandardError; end
|
9
|
+
class Invalid < Error; end
|
10
|
+
|
11
|
+
attr_reader :api_key, :project, :tracker, :category, :priority, :assignee, :repository, :type
|
12
|
+
attr_reader :params, :notifier, :errors, :context, :request, :env
|
13
|
+
attr_reader :environment_name
|
14
|
+
|
15
|
+
def initialize(config, data = {})
|
16
|
+
# config
|
17
|
+
@config = self.class.parse_config(config)
|
18
|
+
raise Invalid.new('Encoded configuration in api-key is missing') if @config.blank?
|
19
|
+
|
20
|
+
# API key
|
21
|
+
@api_key = @config[:api_key]
|
22
|
+
raise Invalid.new('No or invalid api-key') if @api_key.blank?
|
23
|
+
|
24
|
+
# Project
|
25
|
+
@project = Project.where(identifier: @config[:project]).first
|
26
|
+
raise Invalid.new('No or invalid project') if @project.blank?
|
27
|
+
|
28
|
+
# Check configuration
|
29
|
+
raise Invalid.new('Custom field for notice hash is not configured!') if notice_hash_field.blank?
|
30
|
+
|
31
|
+
# Tracker
|
32
|
+
@tracker = record_for(@project.trackers, :tracker)
|
33
|
+
raise Invalid.new('No or invalid tracker') if @tracker.blank?
|
34
|
+
raise Invalid.new('Custom field for notice hash not available on selected tracker') if @tracker.custom_fields.where(id: notice_hash_field.id).first.blank?
|
35
|
+
|
36
|
+
# Category
|
37
|
+
@category = record_for(@project.issue_categories, :category)
|
38
|
+
|
39
|
+
# Priority
|
40
|
+
@priority = record_for(IssuePriority, :priority) || IssuePriority.default
|
41
|
+
|
42
|
+
# Assignee
|
43
|
+
@assignee = record_for(@project.users, :assignee, [:id, :login])
|
44
|
+
|
45
|
+
# Repository
|
46
|
+
@repository = @project.repositories.where(identifier: (@config[:repository] || '')).first
|
47
|
+
|
48
|
+
# Type
|
49
|
+
@type = @config[:type]
|
50
|
+
|
51
|
+
# Errors
|
52
|
+
@errors = (data[:errors] || [])
|
53
|
+
@errors.each { |e| e.request = self }
|
54
|
+
|
55
|
+
# Environment
|
56
|
+
@env = data[:env]
|
57
|
+
|
58
|
+
# Request
|
59
|
+
@request = data[:request]
|
60
|
+
|
61
|
+
# Context
|
62
|
+
@context = data[:context]
|
63
|
+
|
64
|
+
# Notifier
|
65
|
+
@notifier = data[:notifier]
|
66
|
+
|
67
|
+
# Params
|
68
|
+
@params = data[:params]
|
69
|
+
|
70
|
+
# Environment name
|
71
|
+
@environment_name = (@env[:environment_name].presence || @env[:name].presence) if @env.present?
|
72
|
+
@environment_name ||= @context[:environment].presence if @context.present?
|
73
|
+
end
|
74
|
+
|
75
|
+
def notice_hash_field
|
76
|
+
custom_field(:hash_field)
|
77
|
+
end
|
78
|
+
|
79
|
+
def occurrences_field
|
80
|
+
custom_field(:occurrences_field)
|
81
|
+
end
|
82
|
+
|
83
|
+
def reopen?
|
84
|
+
reopen_regexp = project_setting(:reopen_regexp)
|
85
|
+
|
86
|
+
return false if environment_name.blank? || reopen_regexp.blank?
|
87
|
+
|
88
|
+
!!(environment_name =~ /#{reopen_regexp}/i)
|
89
|
+
end
|
90
|
+
|
91
|
+
def reopen_repeat_description?
|
92
|
+
!!project_setting(:reopen_repeat_description)
|
93
|
+
end
|
94
|
+
|
95
|
+
private
|
96
|
+
|
97
|
+
def record_for(on, config_key, fields = [:id, :name])
|
98
|
+
fields.each do |field|
|
99
|
+
val = on.where(field => @config[config_key]).first
|
100
|
+
return val if val.present?
|
101
|
+
end
|
102
|
+
|
103
|
+
project_setting(config_key)
|
104
|
+
end
|
105
|
+
|
106
|
+
def project_setting(key)
|
107
|
+
return nil if @project.airbrake_settings.blank?
|
108
|
+
|
109
|
+
@project.airbrake_settings.send(key) if @project.airbrake_settings.respond_to?(key)
|
110
|
+
end
|
111
|
+
|
112
|
+
def custom_field(key)
|
113
|
+
@project.issue_custom_fields.where(id: global_setting(key)).first || CustomField.where(id: global_setting(key), is_for_all: true).first
|
114
|
+
end
|
115
|
+
|
116
|
+
def global_setting(key)
|
117
|
+
Setting.plugin_redmine_airbrake_backend[key]
|
118
|
+
end
|
119
|
+
|
120
|
+
def self.parse_config(api_key_data)
|
121
|
+
config = ::JSON.parse(api_key_data).symbolize_keys rescue nil
|
122
|
+
|
123
|
+
return config.symbolize_keys if config.is_a?(Hash)
|
124
|
+
|
125
|
+
config = ::JSON.parse(Base64.decode64(api_key_data)) rescue nil
|
126
|
+
|
127
|
+
return config.symbolize_keys if config.is_a?(Hash)
|
128
|
+
|
129
|
+
nil
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|