renren-api 0.3.3
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/README +1 -0
- data/lib/renren-api.rb +13 -0
- data/lib/renren-api/authentication.rb +41 -0
- data/lib/renren-api/http_adapter.rb +48 -0
- data/lib/renren-api/signature_calculator.rb +12 -0
- data/spec/authentication_spec.rb +163 -0
- data/spec/helpers.rb +12 -0
- data/spec/http_adapter_spec.rb +98 -0
- data/spec/signature_calculator_spec.rb +22 -0
- metadata +66 -0
data/README
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
renren-api provides capability to request the service of Renren Social Network.
|
data/lib/renren-api.rb
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
module RenrenAPI
|
2
|
+
|
3
|
+
VERSION = [0, 3, 3]
|
4
|
+
|
5
|
+
def self.version
|
6
|
+
VERSION * "."
|
7
|
+
end
|
8
|
+
|
9
|
+
autoload :Authentication, "renren-api/authentication"
|
10
|
+
autoload :SignatureCalculator, "renren-api/signature_calculator"
|
11
|
+
autoload :HTTPAdapter, "renren-api/http_adapter"
|
12
|
+
|
13
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
require "rack"
|
2
|
+
require_relative "signature_calculator"
|
3
|
+
|
4
|
+
module RenrenAPI
|
5
|
+
class Authentication
|
6
|
+
def initialize(app, api_key, secret_key, &failed_handler)
|
7
|
+
@app = app
|
8
|
+
@api_key = api_key
|
9
|
+
@secret_key = secret_key
|
10
|
+
@signature_calculator = SignatureCalculator.new(@secret_key)
|
11
|
+
@required_keys = %w{user session_key ss expires}.collect { |e| @api_key + "_" + e } << @api_key
|
12
|
+
@failed_handler = block_given? ? failed_handler : proc { [401, {"Content-Type" => "text/plain"}, ["Unauthorized!"]] }
|
13
|
+
end
|
14
|
+
def call(env)
|
15
|
+
request = Rack::Request.new(env)
|
16
|
+
if %r{^/people/(?<person_id>\d+)} =~ request.path_info
|
17
|
+
cookies = request.cookies
|
18
|
+
if valid?(cookies) && cookies["#{@api_key}_user"] == person_id
|
19
|
+
@app.call(env)
|
20
|
+
else
|
21
|
+
@failed_handler.call(env)
|
22
|
+
end
|
23
|
+
else
|
24
|
+
@app.call(env)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
private
|
28
|
+
def valid?(cookies)
|
29
|
+
@required_keys.each do |k|
|
30
|
+
return false unless cookies.has_key?(k)
|
31
|
+
end
|
32
|
+
return false if cookies["#{@api_key}_expires"].to_i < Time.now.to_i
|
33
|
+
cookies[@api_key] == @signature_calculator.calculate(filter(cookies))
|
34
|
+
end
|
35
|
+
def filter(cookies)
|
36
|
+
hash = {}
|
37
|
+
%w{user session_key ss expires}.each { |e| hash[e] = cookies["#{@api_key}_#{e}"] }
|
38
|
+
hash
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,48 @@
|
|
1
|
+
require_relative "signature_calculator"
|
2
|
+
require "uri"
|
3
|
+
require "zlib"
|
4
|
+
|
5
|
+
module RenrenAPI
|
6
|
+
class HTTPAdapter
|
7
|
+
def initialize(http, api_key, secret_key, session_key)
|
8
|
+
@http, @api_key, @secret_key, @session_key = http, api_key, secret_key, session_key
|
9
|
+
@signature_calculator = SignatureCalculator.new(@secret_key)
|
10
|
+
end
|
11
|
+
def get_friends
|
12
|
+
params = {
|
13
|
+
:api_key => @api_key,
|
14
|
+
:method => "friends.getFriends",
|
15
|
+
:call_id => current_time_in_millisecond,
|
16
|
+
:v => "1.0",
|
17
|
+
:session_key => @session_key,
|
18
|
+
:format => "JSON"
|
19
|
+
}
|
20
|
+
request(params)
|
21
|
+
end
|
22
|
+
def get_info(uids, fields)
|
23
|
+
params = {
|
24
|
+
:api_key => @api_key,
|
25
|
+
:method => "users.getInfo",
|
26
|
+
:call_id => current_time_in_millisecond,
|
27
|
+
:v => "1.0",
|
28
|
+
:session_key => @session_key,
|
29
|
+
:fields => fields * ",",
|
30
|
+
:uids => uids * ",",
|
31
|
+
:format => "JSON"
|
32
|
+
}
|
33
|
+
request(params)
|
34
|
+
end
|
35
|
+
private
|
36
|
+
def current_time_in_millisecond
|
37
|
+
"%.3f" % Time.now.to_f
|
38
|
+
end
|
39
|
+
def request(params)
|
40
|
+
params[:sig] = @signature_calculator.calculate(params)
|
41
|
+
response = @http.post("/restserver.do", URI.encode_www_form(params), {"Accept-Encoding" => "gzip"})
|
42
|
+
gzip_reader = Zlib::GzipReader.new(StringIO.new(response.body))
|
43
|
+
result = JSON.parse(gzip_reader.read)
|
44
|
+
gzip_reader.close
|
45
|
+
result
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
require "digest/md5"
|
2
|
+
|
3
|
+
module RenrenAPI
|
4
|
+
class SignatureCalculator
|
5
|
+
def initialize(secret_key)
|
6
|
+
@secret_key = secret_key
|
7
|
+
end
|
8
|
+
def calculate(hash)
|
9
|
+
Digest::MD5.hexdigest(hash.collect { |(k, v)| "#{k}=#{v}" }.sort * "" << @secret_key)
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
@@ -0,0 +1,163 @@
|
|
1
|
+
require_relative "../lib/renren-api/authentication"
|
2
|
+
require "rack/test"
|
3
|
+
require "helpers"
|
4
|
+
|
5
|
+
class Rack::MockResponse
|
6
|
+
def unauthorized?
|
7
|
+
@status == 401
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
describe RenrenAPI::Authentication do
|
12
|
+
include Rack::Test::Methods
|
13
|
+
include Helpers
|
14
|
+
def app
|
15
|
+
RenrenAPI::Authentication.new(lambda { |env| [200, {}, ["OK"]] }, "8802f8e9b2cf4eb993e8c8adb1e02b06", "34d3d1e26cd44c05a0c450c0a0f8147b") do |env|
|
16
|
+
[401, {}, ["Get out of #{env["PATH_INFO"]}!"]]
|
17
|
+
end
|
18
|
+
end
|
19
|
+
subject { request(path, @env); last_response }
|
20
|
+
before { @env = {} }
|
21
|
+
|
22
|
+
context "when path does not have prefix /people/{person-id}" do
|
23
|
+
let(:path) { "/" }
|
24
|
+
%w{GET POST PUT DELETE}.each do |m|
|
25
|
+
context(m) do
|
26
|
+
before { @env[:method] = m }
|
27
|
+
it { should be_ok }
|
28
|
+
its(:body) { should == "OK" }
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
context "when path has prefix /people/{person-id}" do
|
34
|
+
let(:path) { "/people/#{person_id}" }
|
35
|
+
let(:person_id) { rand(9999).to_s }
|
36
|
+
context "when no login information provided" do
|
37
|
+
%w{GET POST PUT DELETE}.each do |m|
|
38
|
+
context(m) do
|
39
|
+
before { @env[:method] = m }
|
40
|
+
it { should be_unauthorized }
|
41
|
+
its(:body) { should == "Get out of #{path}!" }
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
context "when correct login information provided" do
|
46
|
+
before { @env[:cookie] = generate_cookie(secret_key, api_key, hash) }
|
47
|
+
let(:secret_key) { "34d3d1e26cd44c05a0c450c0a0f8147b" }
|
48
|
+
let(:api_key) { "8802f8e9b2cf4eb993e8c8adb1e02b06" }
|
49
|
+
let(:hash) do
|
50
|
+
{
|
51
|
+
"user" => person_id,
|
52
|
+
"session_key" => "session_key",
|
53
|
+
"ss" => "session_key_secret",
|
54
|
+
"expires" => (Time.now.to_i + rand(9999) + 1).to_s
|
55
|
+
}
|
56
|
+
end
|
57
|
+
%w{GET POST PUT DELETE}.each do |m|
|
58
|
+
context(m) do
|
59
|
+
before { @env[:method] = m }
|
60
|
+
it { should be_ok }
|
61
|
+
its(:body) { should == "OK" }
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
context "when incorrect login information provided" do
|
66
|
+
before { @env[:cookie] = generate_cookie("xxxx", api_key, hash) }
|
67
|
+
let(:api_key) { "8802f8e9b2cf4eb993e8c8adb1e02b06" }
|
68
|
+
let(:hash) do
|
69
|
+
{
|
70
|
+
"user" => person_id,
|
71
|
+
"session_key" => "session_key",
|
72
|
+
"ss" => "session_key_secret",
|
73
|
+
"expires" => (Time.now.to_i + rand(9999) + 1).to_s
|
74
|
+
}
|
75
|
+
end
|
76
|
+
%w{GET POST PUT DELETE}.each do |m|
|
77
|
+
context(m) do
|
78
|
+
before { @env[:method] = m }
|
79
|
+
it { should be_unauthorized }
|
80
|
+
its(:body) { should == "Get out of #{path}!" }
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
end
|
87
|
+
|
88
|
+
describe RenrenAPI::Authentication, "no failed handler is provided" do
|
89
|
+
include Rack::Test::Methods
|
90
|
+
include Helpers
|
91
|
+
def app
|
92
|
+
RenrenAPI::Authentication.new(lambda { |env| [200, {}, ["OK"]] }, "8802f8e9b2cf4eb993e8c8adb1e02b06", "34d3d1e26cd44c05a0c450c0a0f8147b")
|
93
|
+
end
|
94
|
+
subject { request(path, @env); last_response }
|
95
|
+
before { @env = {} }
|
96
|
+
|
97
|
+
context "when path does not have prefix /people/{person-id}" do
|
98
|
+
let(:path) { "/" }
|
99
|
+
%w{GET POST PUT DELETE}.each do |m|
|
100
|
+
context(m) do
|
101
|
+
before { @env[:method] = m }
|
102
|
+
it { should be_ok }
|
103
|
+
its(:body) { should == "OK" }
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
context "when path has prefix /people/{person-id}" do
|
109
|
+
let(:path) { "/people/#{person_id}" }
|
110
|
+
let(:person_id) { rand(9999).to_s }
|
111
|
+
context "when no login information provided" do
|
112
|
+
%w{GET POST PUT DELETE}.each do |m|
|
113
|
+
context(m) do
|
114
|
+
before { @env[:method] = m }
|
115
|
+
it { should be_unauthorized }
|
116
|
+
its(:content_type) { should == "text/plain"}
|
117
|
+
its(:body) { should == "Unauthorized!" }
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
context "when correct login information provided" do
|
122
|
+
before { @env[:cookie] = generate_cookie(secret_key, api_key, hash) }
|
123
|
+
let(:secret_key) { "34d3d1e26cd44c05a0c450c0a0f8147b" }
|
124
|
+
let(:api_key) { "8802f8e9b2cf4eb993e8c8adb1e02b06" }
|
125
|
+
let(:hash) do
|
126
|
+
{
|
127
|
+
"user" => person_id,
|
128
|
+
"session_key" => "session_key",
|
129
|
+
"ss" => "session_key_secret",
|
130
|
+
"expires" => (Time.now.to_i + rand(9999) + 1).to_s
|
131
|
+
}
|
132
|
+
end
|
133
|
+
%w{GET POST PUT DELETE}.each do |m|
|
134
|
+
context(m) do
|
135
|
+
before { @env[:method] = m }
|
136
|
+
it { should be_ok }
|
137
|
+
its(:body) { should == "OK" }
|
138
|
+
end
|
139
|
+
end
|
140
|
+
end
|
141
|
+
context "when incorrect login information provided" do
|
142
|
+
before { @env[:cookie] = generate_cookie("xxxx", api_key, hash) }
|
143
|
+
let(:api_key) { "8802f8e9b2cf4eb993e8c8adb1e02b06" }
|
144
|
+
let(:hash) do
|
145
|
+
{
|
146
|
+
"user" => person_id,
|
147
|
+
"session_key" => "session_key",
|
148
|
+
"ss" => "session_key_secret",
|
149
|
+
"expires" => (Time.now.to_i + rand(9999) + 1).to_s
|
150
|
+
}
|
151
|
+
end
|
152
|
+
%w{GET POST PUT DELETE}.each do |m|
|
153
|
+
context(m) do
|
154
|
+
before { @env[:method] = m }
|
155
|
+
it { should be_unauthorized }
|
156
|
+
its(:content_type) { should == "text/plain" }
|
157
|
+
its(:body) { should == "Unauthorized!" }
|
158
|
+
end
|
159
|
+
end
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
163
|
+
end
|
data/spec/helpers.rb
ADDED
@@ -0,0 +1,12 @@
|
|
1
|
+
require "uuidtools"
|
2
|
+
require "digest/md5"
|
3
|
+
|
4
|
+
module Helpers
|
5
|
+
def generate_hash(secret_key, api_key, hash = {})
|
6
|
+
auth_code = Digest::MD5.hexdigest(hash.sort.collect { |e| e * "=" } * "" << secret_key)
|
7
|
+
Hash[hash.collect { |k, v| [api_key + "_" + k, v] }].merge!(api_key => auth_code)
|
8
|
+
end
|
9
|
+
def generate_cookie(secret_key, api_key, hash = {})
|
10
|
+
generate_hash(secret_key, api_key, hash).collect { |(k, v)| "#{k}=#{v}" }
|
11
|
+
end
|
12
|
+
end
|
@@ -0,0 +1,98 @@
|
|
1
|
+
require_relative "../lib/renren-api/http_adapter"
|
2
|
+
require_relative "../lib/renren-api/signature_calculator"
|
3
|
+
require "json"
|
4
|
+
require "net/http"
|
5
|
+
require "zlib"
|
6
|
+
require "stringio"
|
7
|
+
|
8
|
+
describe RenrenAPI::HTTPAdapter, "#get_friends" do
|
9
|
+
subject { described_class.new(http, api_key, secret_key, session_key).get_friends }
|
10
|
+
let(:http) { Net::HTTP.new("api.renren.com") }
|
11
|
+
let(:api_key) { "8802f8e9b2cf4eb993e8c8adb1e02b06" }
|
12
|
+
let(:secret_key) { "34d3d1e26cd44c05a0c450c0a0f8147b" }
|
13
|
+
let(:session_key) { "session_key" }
|
14
|
+
let(:result) do
|
15
|
+
[
|
16
|
+
{"id" => 12345, "name" => "Levin", "tinyurl" => "http://renren.com/1.jpg"},
|
17
|
+
{"id" => 12346, "name" => "James", "tinyurl" => "http://renren.com/2.jpg"}
|
18
|
+
]
|
19
|
+
end
|
20
|
+
let!(:now) do
|
21
|
+
Time.now
|
22
|
+
end
|
23
|
+
let(:params) do
|
24
|
+
{
|
25
|
+
:api_key => api_key,
|
26
|
+
:method => "friends.getFriends",
|
27
|
+
:call_id => "%.3f" % now.to_f,
|
28
|
+
:v => "1.0",
|
29
|
+
:session_key => session_key,
|
30
|
+
:format => "JSON"
|
31
|
+
}
|
32
|
+
end
|
33
|
+
let(:form_params) do
|
34
|
+
signature_calculator = RenrenAPI::SignatureCalculator.new(secret_key)
|
35
|
+
signature = signature_calculator.calculate(params)
|
36
|
+
URI.encode_www_form(params.merge(:sig => signature))
|
37
|
+
end
|
38
|
+
before :each do
|
39
|
+
Time.stub(:now).and_return(now)
|
40
|
+
response = mock(Net::HTTPResponse)
|
41
|
+
gzip_writer = Zlib::GzipWriter.new(StringIO.new(buffer = ""))
|
42
|
+
gzip_writer << JSON.generate(result)
|
43
|
+
gzip_writer.close
|
44
|
+
response.stub(:code => '200', :message => "OK", :content_type => "application/json", :body => buffer)
|
45
|
+
http.should_receive(:post).with("/restserver.do", form_params, {"Accept-Encoding" => "gzip"}).once.and_return(response)
|
46
|
+
end
|
47
|
+
it { should == result }
|
48
|
+
end
|
49
|
+
|
50
|
+
describe RenrenAPI::HTTPAdapter, "#get_info" do
|
51
|
+
subject { described_class.new(http, api_key, secret_key, session_key).get_info(uids, fields) }
|
52
|
+
let(:http) { Net::HTTP.new("api.renren.com") }
|
53
|
+
let(:api_key) { "8802f8e9b2cf4eb993e8c8adb1e02b06" }
|
54
|
+
let(:secret_key) { "34d3d1e26cd44c05a0c450c0a0f8147b" }
|
55
|
+
let(:session_key) { "session_key" }
|
56
|
+
let(:result) do
|
57
|
+
[
|
58
|
+
{"uid" => 12345, "name" => "Levin", "tinyurl" => "http://renren.com/1.jpg"},
|
59
|
+
{"uid" => 12346, "name" => "James", "tinyurl" => "http://renren.com/2.jpg"}
|
60
|
+
]
|
61
|
+
end
|
62
|
+
let!(:now) do
|
63
|
+
Time.now
|
64
|
+
end
|
65
|
+
let(:fields) do
|
66
|
+
%w{uid name tinyurl}
|
67
|
+
end
|
68
|
+
let(:uids) do
|
69
|
+
[12345, 12346]
|
70
|
+
end
|
71
|
+
let(:params) do
|
72
|
+
{
|
73
|
+
:api_key => api_key,
|
74
|
+
:method => "users.getInfo",
|
75
|
+
:call_id => "%.3f" % now.to_f,
|
76
|
+
:v => "1.0",
|
77
|
+
:session_key => session_key,
|
78
|
+
:fields => fields * ",",
|
79
|
+
:uids => uids * ",",
|
80
|
+
:format => "JSON"
|
81
|
+
}
|
82
|
+
end
|
83
|
+
let(:form_params) do
|
84
|
+
signature_calculator = RenrenAPI::SignatureCalculator.new(secret_key)
|
85
|
+
signature = signature_calculator.calculate(params)
|
86
|
+
URI.encode_www_form(params.merge(:sig => signature))
|
87
|
+
end
|
88
|
+
before :each do
|
89
|
+
Time.stub(:now).and_return(now)
|
90
|
+
response = mock(Net::HTTPResponse)
|
91
|
+
gzip_writer = Zlib::GzipWriter.new(StringIO.new(buffer = ""))
|
92
|
+
gzip_writer << JSON.generate(result)
|
93
|
+
gzip_writer.close
|
94
|
+
response.stub(:code => '200', :message => "OK", :content_type => "application/json", :body => buffer)
|
95
|
+
http.should_receive(:post).with("/restserver.do", form_params, {"Accept-Encoding" => "gzip"}).once.and_return(response)
|
96
|
+
end
|
97
|
+
it { should == result }
|
98
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
require_relative "../lib/renren-api/signature_calculator"
|
2
|
+
|
3
|
+
describe RenrenAPI::SignatureCalculator do
|
4
|
+
let(:calculator) { RenrenAPI::SignatureCalculator.new(secret_key) }
|
5
|
+
describe "#calculate" do
|
6
|
+
subject { calculator.calculate(hash) }
|
7
|
+
context "when secret_key is 7fbf9791036749cb82e74efd62e9eb38" do
|
8
|
+
let(:secret_key) { "7fbf9791036749cb82e74efd62e9eb38" }
|
9
|
+
example_hash = {
|
10
|
+
"v" => "1.0",
|
11
|
+
"api_key" => "ec9e57913c5b42b282ab7b743559e1b0",
|
12
|
+
"method" => "xiaonei.users.getLoggedInUser",
|
13
|
+
"call_id" => 1232095295656,
|
14
|
+
"session_key" => "L6Xe8dXVGISZ17LJy7GzZaeYGpeGfeNdqEPLNUtCJfxPCxCRLWT83x+s/Ur94PqP-700001044"
|
15
|
+
}
|
16
|
+
context "when hash is #{example_hash.inspect}" do
|
17
|
+
let(:hash) { example_hash }
|
18
|
+
it { should == "66f332c08191b8a5dd3477d36f3af49f" }
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
metadata
ADDED
@@ -0,0 +1,66 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: renren-api
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
prerelease:
|
5
|
+
version: 0.3.3
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Lei, Zhi-Qiang
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
|
13
|
+
date: 2011-04-30 00:00:00 +08:00
|
14
|
+
default_executable:
|
15
|
+
dependencies: []
|
16
|
+
|
17
|
+
description: " renren-api provides capability to request the service of Renren Social Network.\n"
|
18
|
+
email: zhiqiang.lei@gmail.com
|
19
|
+
executables: []
|
20
|
+
|
21
|
+
extensions: []
|
22
|
+
|
23
|
+
extra_rdoc_files:
|
24
|
+
- README
|
25
|
+
files:
|
26
|
+
- lib/renren-api/authentication.rb
|
27
|
+
- lib/renren-api/http_adapter.rb
|
28
|
+
- lib/renren-api/signature_calculator.rb
|
29
|
+
- lib/renren-api.rb
|
30
|
+
- spec/authentication_spec.rb
|
31
|
+
- spec/helpers.rb
|
32
|
+
- spec/http_adapter_spec.rb
|
33
|
+
- spec/signature_calculator_spec.rb
|
34
|
+
- README
|
35
|
+
has_rdoc: true
|
36
|
+
homepage: https://github.com/siegfried/renren-api
|
37
|
+
licenses:
|
38
|
+
- BSD
|
39
|
+
post_install_message:
|
40
|
+
rdoc_options: []
|
41
|
+
|
42
|
+
require_paths:
|
43
|
+
- lib
|
44
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
45
|
+
none: false
|
46
|
+
requirements:
|
47
|
+
- - ">="
|
48
|
+
- !ruby/object:Gem::Version
|
49
|
+
version: 1.9.2
|
50
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
51
|
+
none: false
|
52
|
+
requirements:
|
53
|
+
- - ">="
|
54
|
+
- !ruby/object:Gem::Version
|
55
|
+
version: "0"
|
56
|
+
requirements: []
|
57
|
+
|
58
|
+
rubyforge_project:
|
59
|
+
rubygems_version: 1.5.0
|
60
|
+
signing_key:
|
61
|
+
specification_version: 3
|
62
|
+
summary: a library to request Renren's API
|
63
|
+
test_files:
|
64
|
+
- spec/authentication_spec.rb
|
65
|
+
- spec/http_adapter_spec.rb
|
66
|
+
- spec/signature_calculator_spec.rb
|