apipie-rails 0.0.12 → 0.0.13
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.
- data/lib/apipie/client/base.rb +98 -96
- data/lib/apipie/client/cli_command.rb +97 -95
- data/lib/apipie/client/main.rb +76 -74
- data/lib/apipie/client/thor.rb +14 -12
- data/lib/apipie/version.rb +1 -1
- metadata +4 -4
data/lib/apipie/client/base.rb
CHANGED
@@ -3,129 +3,131 @@ require 'oauth'
|
|
3
3
|
require 'json'
|
4
4
|
require 'apipie/client/rest_client_oauth'
|
5
5
|
|
6
|
-
module Apipie
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
6
|
+
module Apipie
|
7
|
+
module Client
|
8
|
+
|
9
|
+
class Base
|
10
|
+
attr_reader :client, :config
|
11
|
+
|
12
|
+
def initialize(config, options = { })
|
13
|
+
@client = RestClient::Resource.new config[:base_url],
|
14
|
+
:user => config[:username],
|
15
|
+
:password => config[:password],
|
16
|
+
:oauth => config[:oauth],
|
17
|
+
:headers => { :content_type => 'application/json',
|
18
|
+
:accept => 'application/json' }
|
19
|
+
@config = config
|
20
|
+
end
|
20
21
|
|
21
|
-
|
22
|
-
|
22
|
+
def call(method, path, params = { }, headers = { })
|
23
|
+
headers ||= { }
|
23
24
|
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
25
|
+
args = [method]
|
26
|
+
if [:post, :put].include?(method)
|
27
|
+
args << params.to_json
|
28
|
+
else
|
29
|
+
headers[:params] = params if params
|
30
|
+
end
|
30
31
|
|
31
|
-
|
32
|
-
|
33
|
-
|
32
|
+
args << headers if headers
|
33
|
+
process_data client[path].send(*args)
|
34
|
+
end
|
34
35
|
|
35
|
-
|
36
|
-
|
37
|
-
|
36
|
+
def self.doc
|
37
|
+
raise NotImplementedError
|
38
|
+
end
|
38
39
|
|
39
|
-
|
40
|
-
|
41
|
-
|
40
|
+
def self.validation_hash(method)
|
41
|
+
validation_hashes[method.to_s]
|
42
|
+
end
|
42
43
|
|
43
|
-
|
44
|
-
|
45
|
-
|
44
|
+
def self.method_doc(method)
|
45
|
+
method_docs[method.to_s]
|
46
|
+
end
|
46
47
|
|
47
|
-
|
48
|
-
|
48
|
+
def validate_params!(params, rules)
|
49
|
+
return unless params.is_a?(Hash)
|
49
50
|
|
50
|
-
|
51
|
-
|
51
|
+
invalid_keys = params.keys.map(&:to_s) - (rules.is_a?(Hash) ? rules.keys : rules)
|
52
|
+
raise ArgumentError, "Invalid keys: #{invalid_keys.join(", ")}" unless invalid_keys.empty?
|
52
53
|
|
53
|
-
|
54
|
-
|
55
|
-
|
54
|
+
if rules.is_a? Hash
|
55
|
+
rules.each do |key, sub_keys|
|
56
|
+
validate_params!(params[key], sub_keys) if params[key]
|
57
|
+
end
|
56
58
|
end
|
57
59
|
end
|
58
|
-
end
|
59
60
|
|
60
|
-
|
61
|
+
protected
|
61
62
|
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
63
|
+
def process_data(response)
|
64
|
+
data = begin
|
65
|
+
JSON.parse(response.body)
|
66
|
+
rescue JSON::ParserError
|
67
|
+
response.body
|
68
|
+
end
|
69
|
+
return data, response
|
67
70
|
end
|
68
|
-
return data, response
|
69
|
-
end
|
70
|
-
|
71
|
-
def check_params(params, options = { })
|
72
|
-
raise ArgumentError unless (method = options[:method])
|
73
|
-
return unless config[:enable_validations]
|
74
71
|
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
72
|
+
def check_params(params, options = { })
|
73
|
+
raise ArgumentError unless (method = options[:method])
|
74
|
+
return unless config[:enable_validations]
|
75
|
+
|
76
|
+
case options[:allowed]
|
77
|
+
when true
|
78
|
+
validate_params!(params, self.class.validation_hash(method))
|
79
|
+
when false
|
80
|
+
raise ArgumentError, "this method '#{method}' does not support params" if params && !params.empty?
|
81
|
+
else
|
82
|
+
raise ArgumentError, "options :allowed should be true or false, it was #{options[:allowed]}"
|
83
|
+
end
|
82
84
|
end
|
83
|
-
end
|
84
85
|
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
86
|
+
# @return url and rest of the params
|
87
|
+
def fill_params_in_url(url, params)
|
88
|
+
params ||= { }
|
89
|
+
# insert param values
|
90
|
+
url_param_names = params_in_path(url)
|
91
|
+
url = params_in_path(url).inject(url) do |url, param_name|
|
92
|
+
param_value = params[param_name] or
|
93
|
+
raise ArgumentError, "missing param '#{param_name}' in parameters"
|
94
|
+
url.sub(":#{param_name}", param_value.to_s)
|
95
|
+
end
|
95
96
|
|
96
|
-
|
97
|
-
|
97
|
+
return url, params.reject { |param_name, _| url_param_names.include? param_name }
|
98
|
+
end
|
98
99
|
|
99
|
-
|
100
|
+
private
|
100
101
|
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
102
|
+
def self.method_docs
|
103
|
+
@method_docs ||= doc['methods'].inject({ }) do |hash, method|
|
104
|
+
hash[method['name']] = method
|
105
|
+
hash
|
106
|
+
end
|
105
107
|
end
|
106
|
-
end
|
107
108
|
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
109
|
+
def self.validation_hashes
|
110
|
+
@validation_hashes ||= method_docs.inject({ }) do |hash, pair|
|
111
|
+
name, method_doc = pair
|
112
|
+
hash[name] = construct_validation_hash method_doc
|
113
|
+
hash
|
114
|
+
end
|
113
115
|
end
|
114
|
-
end
|
115
116
|
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
117
|
+
def self.construct_validation_hash(method)
|
118
|
+
if method['params'].any? { |p| p['params'] }
|
119
|
+
method['params'].reduce({ }) do |h, p|
|
120
|
+
h.update(p['name'] => (p['params'] ? p['params'].map { |pp| pp['name'] } : nil))
|
121
|
+
end
|
122
|
+
else
|
123
|
+
method['params'].map { |p| p['name'] }
|
120
124
|
end
|
121
|
-
else
|
122
|
-
method['params'].map { |p| p['name'] }
|
123
125
|
end
|
124
|
-
end
|
125
126
|
|
126
|
-
|
127
|
-
|
128
|
-
|
127
|
+
def params_in_path(url)
|
128
|
+
url.scan(/:([^\/]*)/).map { |m| m.first }
|
129
|
+
end
|
129
130
|
|
131
|
+
end
|
130
132
|
end
|
131
133
|
end
|
@@ -1,127 +1,129 @@
|
|
1
|
-
module Apipie
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
1
|
+
module Apipie
|
2
|
+
module Client
|
3
|
+
class CliCommand < Thor
|
4
|
+
no_tasks do
|
5
|
+
def client
|
6
|
+
resource_class = apipie_options[:client_module]::Resources.const_get(self.class.name[/[^:]*$/])
|
7
|
+
@client ||= resource_class.new(apipie_options[:config])
|
8
|
+
end
|
9
|
+
|
10
|
+
def transform_options(inline_params, transform_hash = { })
|
11
|
+
# we use not mentioned params without change
|
12
|
+
transformed_options = (options.keys - transform_hash.values.flatten - inline_params).reduce({ }) { |h, k| h.update(k => options[k]) }
|
8
13
|
|
9
|
-
|
10
|
-
# we use not mentioned params without change
|
11
|
-
transformed_options = (options.keys - transform_hash.values.flatten - inline_params).reduce({ }) { |h, k| h.update(k => options[k]) }
|
14
|
+
inline_params.each { |p| transformed_options[p] = options[p] }
|
12
15
|
|
13
|
-
|
16
|
+
transform_hash.each do |sub_key, params|
|
17
|
+
transformed_options[sub_key] = { }
|
18
|
+
params.each { |p| transformed_options[sub_key][p] = options[p] if options.has_key?(p) }
|
19
|
+
end
|
14
20
|
|
15
|
-
|
16
|
-
transformed_options[sub_key] = { }
|
17
|
-
params.each { |p| transformed_options[sub_key][p] = options[p] if options.has_key?(p) }
|
21
|
+
return transformed_options
|
18
22
|
end
|
19
23
|
|
20
|
-
|
21
|
-
|
24
|
+
def print_data(data)
|
25
|
+
case data
|
26
|
+
when Array
|
27
|
+
print_big_table(table_from_array(data))
|
28
|
+
when Hash
|
29
|
+
print_table(table_from_hash(data))
|
30
|
+
else
|
31
|
+
print_unknown(data)
|
32
|
+
end
|
33
|
+
end
|
22
34
|
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
35
|
+
# unifies the data for further processing. e.g.
|
36
|
+
#
|
37
|
+
# { "user" => {"username" => "test", "password" => "changeme" }
|
38
|
+
#
|
39
|
+
# becomes:
|
40
|
+
#
|
41
|
+
# { "username" => "test", "password" => "changeme" }
|
42
|
+
def normalize_item_data(item)
|
43
|
+
if item.size == 1 && item.values.first.is_a?(Hash)
|
44
|
+
item.values.first
|
29
45
|
else
|
30
|
-
|
46
|
+
item
|
47
|
+
end
|
31
48
|
end
|
32
|
-
end
|
33
49
|
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
item
|
50
|
+
def table_from_array(data)
|
51
|
+
return [] if data.empty?
|
52
|
+
table = []
|
53
|
+
items = data.map { |item| normalize_item_data(item) }
|
54
|
+
columns = items.first.keys
|
55
|
+
table << columns
|
56
|
+
items.each do |item|
|
57
|
+
row = columns.map { |c| item[c] }
|
58
|
+
table << row.map(&:to_s)
|
59
|
+
end
|
60
|
+
return table
|
46
61
|
end
|
47
|
-
end
|
48
62
|
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
row = columns.map { |c| item[c] }
|
57
|
-
table << row.map(&:to_s)
|
63
|
+
def table_from_hash(data)
|
64
|
+
return [] if data.empty?
|
65
|
+
table = []
|
66
|
+
normalize_item_data(data).each do |k, v|
|
67
|
+
table << ["#{k}:", v].map(&:to_s)
|
68
|
+
end
|
69
|
+
table
|
58
70
|
end
|
59
|
-
return table
|
60
|
-
end
|
61
71
|
|
62
|
-
|
63
|
-
|
64
|
-
table = []
|
65
|
-
normalize_item_data(data).each do |k, v|
|
66
|
-
table << ["#{k}:", v].map(&:to_s)
|
72
|
+
def print_unknown(data)
|
73
|
+
say data
|
67
74
|
end
|
68
|
-
table
|
69
|
-
end
|
70
75
|
|
71
|
-
|
72
|
-
|
73
|
-
end
|
76
|
+
def print_big_table(table, options={ })
|
77
|
+
return if table.empty?
|
74
78
|
|
75
|
-
|
76
|
-
|
79
|
+
formats, ident, colwidth = [], options[:ident].to_i, options[:colwidth]
|
80
|
+
options[:truncate] = terminal_width if options[:truncate] == true
|
77
81
|
|
78
|
-
|
79
|
-
|
82
|
+
formats << "%-#{colwidth + 2}s" if colwidth
|
83
|
+
start = colwidth ? 1 : 0
|
80
84
|
|
81
|
-
|
82
|
-
|
85
|
+
start.upto(table.first.length - 2) do |i|
|
86
|
+
maxima ||= table.max { |a, b| a[i].size <=> b[i].size }[i].size
|
87
|
+
formats << "%-#{maxima + 2}s"
|
88
|
+
end
|
83
89
|
|
84
|
-
|
85
|
-
|
86
|
-
formats << "%-#{maxima + 2}s"
|
87
|
-
end
|
90
|
+
formats << "%s"
|
91
|
+
formats[0] = formats[0].insert(0, " " * ident)
|
88
92
|
|
89
|
-
|
90
|
-
|
93
|
+
header_printed = false
|
94
|
+
table.each do |row|
|
95
|
+
sentence = ""
|
91
96
|
|
92
|
-
|
93
|
-
|
94
|
-
|
97
|
+
row.each_with_index do |column, i|
|
98
|
+
sentence << formats[i] % column.to_s
|
99
|
+
end
|
95
100
|
|
96
|
-
|
97
|
-
sentence
|
101
|
+
sentence = truncate(sentence, options[:truncate]) if options[:truncate]
|
102
|
+
$stdout.puts sentence
|
103
|
+
say(set_color("-" * sentence.size, :green)) unless header_printed
|
104
|
+
header_printed = true
|
98
105
|
end
|
99
|
-
|
100
|
-
sentence = truncate(sentence, options[:truncate]) if options[:truncate]
|
101
|
-
$stdout.puts sentence
|
102
|
-
say(set_color("-" * sentence.size, :green)) unless header_printed
|
103
|
-
header_printed = true
|
104
106
|
end
|
107
|
+
|
105
108
|
end
|
106
109
|
|
107
|
-
|
110
|
+
class << self
|
111
|
+
def help(shell, subcommand = true)
|
112
|
+
list = self.printable_tasks(true, subcommand)
|
113
|
+
Thor::Util.thor_classes_in(self).each do |klass|
|
114
|
+
list += printable_tasks(false)
|
115
|
+
end
|
116
|
+
list.sort! { |a, b| a[0] <=> b[0] }
|
108
117
|
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
list += printable_tasks(false)
|
118
|
+
shell.say
|
119
|
+
shell.print_table(list, :indent => 2, :truncate => true)
|
120
|
+
shell.say
|
121
|
+
Thor.send(:class_options_help, shell)
|
114
122
|
end
|
115
|
-
list.sort! { |a, b| a[0] <=> b[0] }
|
116
123
|
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
Thor.send(:class_options_help, shell)
|
121
|
-
end
|
122
|
-
|
123
|
-
def banner(task, namespace = nil, subcommand = false)
|
124
|
-
task.name
|
124
|
+
def banner(task, namespace = nil, subcommand = false)
|
125
|
+
task.name
|
126
|
+
end
|
125
127
|
end
|
126
128
|
end
|
127
129
|
end
|
data/lib/apipie/client/main.rb
CHANGED
@@ -1,97 +1,99 @@
|
|
1
1
|
require "apipie/client/thor"
|
2
2
|
require "apipie/client/cli_command"
|
3
3
|
|
4
|
-
module Apipie
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
4
|
+
module Apipie
|
5
|
+
module Client
|
6
|
+
class Main < Thor
|
7
|
+
|
8
|
+
def help(meth = nil)
|
9
|
+
if meth && !self.respond_to?(meth)
|
10
|
+
initialize_thorfiles(meth)
|
11
|
+
klass, task = Thor::Util.find_class_and_task_by_namespace(meth)
|
12
|
+
self.class.handle_no_task_error(task, false) if klass.nil?
|
13
|
+
klass.start(["-h", task].compact, :shell => self.shell)
|
14
|
+
else
|
15
|
+
say "#{apipie_options[:name].capitalize} CLI"
|
16
|
+
say
|
17
|
+
invoke :commands
|
18
|
+
end
|
17
19
|
end
|
18
|
-
end
|
19
20
|
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
21
|
+
desc "commands [SEARCH]", "List the available commands"
|
22
|
+
def commands(search="")
|
23
|
+
initialize_thorfiles
|
24
|
+
klasses = Thor::Base.subclasses
|
25
|
+
display_klasses(false, false, klasses)
|
26
|
+
end
|
26
27
|
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
28
|
+
class << self
|
29
|
+
private
|
30
|
+
def dispatch(task, given_args, given_options, config)
|
31
|
+
parser = Thor::Options.new :auth => Thor::Option.parse(%w[auth -a], :string)
|
32
|
+
opts = parser.parse(given_args)
|
33
|
+
if opts['auth']
|
34
|
+
username, password = opts['auth'].split(':')
|
35
|
+
apipie_options[:config][:username] = username
|
36
|
+
apipie_options[:config][:password] = password
|
37
|
+
end
|
38
|
+
remaining = parser.remaining
|
39
|
+
|
40
|
+
super(task, remaining, given_options, config)
|
36
41
|
end
|
37
|
-
remaining = parser.remaining
|
38
|
-
|
39
|
-
super(task, remaining, given_options, config)
|
40
42
|
end
|
41
|
-
end
|
42
43
|
|
43
|
-
|
44
|
+
private
|
44
45
|
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
46
|
+
def method_missing(meth, *args)
|
47
|
+
meth = meth.to_s
|
48
|
+
initialize_thorfiles(meth)
|
49
|
+
klass, task = Thor::Util.find_class_and_task_by_namespace(meth)
|
50
|
+
args.unshift(task) if task
|
51
|
+
klass.start(args, :shell => self.shell)
|
52
|
+
end
|
52
53
|
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
54
|
+
# Load the thorfiles. If relevant_to is supplied, looks for specific files
|
55
|
+
# in the thor_root instead of loading them all.
|
56
|
+
#
|
57
|
+
# By default, it also traverses the current path until find Thor files, as
|
58
|
+
# described in thorfiles. This look up can be skipped by suppliying
|
59
|
+
# skip_lookup true.
|
60
|
+
#
|
61
|
+
def initialize_thorfiles(relevant_to=nil, skip_lookup=false)
|
62
|
+
thorfiles.each do |f|
|
63
|
+
Thor::Util.load_thorfile(f, nil, options[:debug])
|
64
|
+
end
|
63
65
|
end
|
64
|
-
end
|
65
66
|
|
66
|
-
|
67
|
-
|
68
|
-
|
67
|
+
def thorfiles
|
68
|
+
Dir[File.expand_path("*/commands/*.thor", apipie_options[:root])]
|
69
|
+
end
|
69
70
|
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
71
|
+
# Display information about the given klasses. If with_module is given,
|
72
|
+
# it shows a table with information extracted from the yaml file.
|
73
|
+
#
|
74
|
+
def display_klasses(with_modules=false, show_internal=false, klasses=Thor::Base.subclasses)
|
75
|
+
klasses -= [Thor, Main, ::Apipie::Client::CliCommand, ::Thor] unless show_internal
|
75
76
|
|
76
|
-
|
77
|
+
show_modules if with_modules && !thor_yaml.empty?
|
77
78
|
|
78
|
-
|
79
|
-
|
79
|
+
list = Hash.new { |h, k| h[k] = [] }
|
80
|
+
groups = []
|
80
81
|
|
81
|
-
|
82
|
-
|
82
|
+
# Get classes which inherit from Thor
|
83
|
+
(klasses - groups).each { |k| list[k.namespace.split(":").first] += k.printable_tasks(false) }
|
83
84
|
|
84
|
-
|
85
|
-
|
86
|
-
|
85
|
+
# Get classes which inherit from Thor::Base
|
86
|
+
groups.map! { |k| k.printable_tasks(false).first }
|
87
|
+
list["root"] = groups
|
87
88
|
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
89
|
+
# Order namespaces with default coming first
|
90
|
+
list = list.sort { |a, b| a[0].sub(/^default/, '') <=> b[0].sub(/^default/, '') }
|
91
|
+
list.each { |n, tasks| display_tasks(n, tasks) unless tasks.empty? }
|
92
|
+
end
|
92
93
|
|
93
|
-
|
94
|
-
|
94
|
+
def display_tasks(namespace, list) #:nodoc:
|
95
|
+
say namespace
|
96
|
+
end
|
95
97
|
end
|
96
98
|
end
|
97
99
|
|
data/lib/apipie/client/thor.rb
CHANGED
@@ -1,19 +1,21 @@
|
|
1
|
-
module Apipie
|
2
|
-
|
1
|
+
module Apipie
|
2
|
+
module Client
|
3
|
+
class Thor < ::Thor
|
3
4
|
|
4
|
-
|
5
|
-
|
6
|
-
|
5
|
+
def self.apipie_options
|
6
|
+
Apipie::Client::Thor.instance_variable_get :@apipie_options
|
7
|
+
end
|
7
8
|
|
8
|
-
|
9
|
-
|
10
|
-
|
9
|
+
no_tasks do
|
10
|
+
def apipie_options
|
11
|
+
self.class.apipie_options
|
12
|
+
end
|
11
13
|
end
|
12
|
-
end
|
13
14
|
|
14
|
-
|
15
|
-
|
16
|
-
|
15
|
+
def self.apipie_options=(options)
|
16
|
+
Apipie::Client::Thor.instance_variable_set :@apipie_options, options
|
17
|
+
end
|
17
18
|
|
19
|
+
end
|
18
20
|
end
|
19
21
|
end
|
data/lib/apipie/version.rb
CHANGED
metadata
CHANGED
@@ -1,13 +1,13 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: apipie-rails
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
hash:
|
4
|
+
hash: 5
|
5
5
|
prerelease:
|
6
6
|
segments:
|
7
7
|
- 0
|
8
8
|
- 0
|
9
|
-
-
|
10
|
-
version: 0.0.
|
9
|
+
- 13
|
10
|
+
version: 0.0.13
|
11
11
|
platform: ruby
|
12
12
|
authors:
|
13
13
|
- Pavel Pokorny
|
@@ -16,7 +16,7 @@ autorequire:
|
|
16
16
|
bindir: bin
|
17
17
|
cert_chain: []
|
18
18
|
|
19
|
-
date: 2012-10-
|
19
|
+
date: 2012-10-25 00:00:00 Z
|
20
20
|
dependencies:
|
21
21
|
- !ruby/object:Gem::Dependency
|
22
22
|
name: rspec-rails
|