sentofu 0.0.1 → 0.1.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/CHANGELOG.md +6 -1
- data/Makefile +11 -1
- data/lib/sentofu.rb +101 -4
- data/lib/sentofu/api.rb +149 -35
- data/lib/sentofu/explo.rb +47 -0
- data/lib/sentofu/http.rb +62 -11
- metadata +3 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: ec10af5cec1acf4c593f766637358a1cc323665bf65cde68c5c727c637582f7f
|
4
|
+
data.tar.gz: e75a6edec73c0c0faa5cc7e9a8657f9d348a25bbcc023d3422b4f62dd9e89a5e
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 7e8145180ed891775ef9cf4358778091d18fe5f4199cf2c87a899de9ec14e5ab3a60d18cfa93314a3417660a3a2cb34757bedcaefc0d6b51f5da862659f9836a
|
7
|
+
data.tar.gz: 2f3a1bf05a54b680bdf6247ea2c9b72cac64a590586341476d9b90fe2c2b27986bbcd32d55f94e190fda85d13ffca4daa5a005930abc50e3f2855ea642b44ee0
|
data/CHANGELOG.md
CHANGED
data/Makefile
CHANGED
@@ -40,5 +40,15 @@ test: spec
|
|
40
40
|
|
41
41
|
## specific to project ##
|
42
42
|
|
43
|
-
|
43
|
+
detail_apis:
|
44
|
+
time bundle exec ruby -I lib -r yaml -r sentofu -e "puts YAML.dump(Sentofu.detail_apis)"
|
45
|
+
list_apis:
|
46
|
+
time bundle exec ruby -I lib -r yaml -r sentofu -e "puts YAML.dump(Sentofu.list_apis)"
|
47
|
+
dump_apis:
|
48
|
+
time bundle exec ruby -I lib -r sentofu -e "Sentofu.dump_apis"
|
49
|
+
|
50
|
+
|
51
|
+
## done ##
|
52
|
+
|
53
|
+
.PHONY: count_lines scan gemspec_validate name cw build push spec detail_apis list_apis dump_apis
|
44
54
|
|
data/lib/sentofu.rb
CHANGED
@@ -1,21 +1,112 @@
|
|
1
1
|
|
2
2
|
require 'json'
|
3
3
|
require 'yaml'
|
4
|
+
require 'time'
|
4
5
|
require 'base64'
|
5
6
|
require 'ostruct'
|
6
7
|
require 'net/http'
|
7
8
|
|
8
9
|
require 'sentofu/http'
|
9
10
|
require 'sentofu/api'
|
11
|
+
require 'sentofu/explo'
|
10
12
|
|
11
13
|
|
12
14
|
module Sentofu
|
13
15
|
|
14
|
-
VERSION = '0.0
|
16
|
+
VERSION = '0.1.0'
|
15
17
|
|
16
|
-
|
18
|
+
USER_AGENT =
|
19
|
+
"Sentofu #{Sentofu::VERSION} - " +
|
20
|
+
[ 'Ruby', RUBY_VERSION, RUBY_RELEASE_DATE, RUBY_PLATFORM ].join(' ')
|
21
|
+
@user_agent =
|
22
|
+
USER_AGENT
|
17
23
|
|
18
|
-
|
24
|
+
@auth_uri = nil
|
25
|
+
@apis = {}
|
26
|
+
|
27
|
+
class << self
|
28
|
+
|
29
|
+
attr_reader :auth_uri, :apis
|
30
|
+
attr_accessor :user_agent
|
31
|
+
|
32
|
+
def init(versions=%w[ common:1.0.0 company:1.0.0 markets:1.0.0 ])
|
33
|
+
|
34
|
+
vers = split_versions(versions)
|
35
|
+
|
36
|
+
vers << %w[ auth * ] unless vers.find { |n, _| n == 'auth' }
|
37
|
+
|
38
|
+
vers.each do |api_name, ver_pattern|
|
39
|
+
|
40
|
+
doc_uri =
|
41
|
+
'https://api.swaggerhub.com/apis/sentifi-api-docs' +
|
42
|
+
(api_name == 'auth' ?
|
43
|
+
'/sentifi-api_o_auth_2_authentication_and_authorization/' :
|
44
|
+
"/sentifi-intelligence_#{api_name}_api/")
|
45
|
+
|
46
|
+
metas = Sentofu::Http.get_and_parse(doc_uri)
|
47
|
+
|
48
|
+
v, u, meta = metas['apis']
|
49
|
+
.collect { |m|
|
50
|
+
prs = m['properties']
|
51
|
+
[ prs.find { |pr| pr['type'] == 'X-Version' }['value'],
|
52
|
+
prs.find { |pr| pr['type'] == 'Swagger' }['url'],
|
53
|
+
m ] }
|
54
|
+
.select { |v, _, _| version_match(v, ver_pattern) }
|
55
|
+
.sort_by(&:first)
|
56
|
+
.first
|
57
|
+
|
58
|
+
spec = Sentofu::Http.get_and_parse(u)
|
59
|
+
spec[:meta] = meta
|
60
|
+
|
61
|
+
if api_name == 'auth'
|
62
|
+
@auth_uri = spec['servers'][0]['url'] + spec['paths'].keys.first
|
63
|
+
else
|
64
|
+
api = Sentofu::Api.new(api_name, spec)
|
65
|
+
Sentofu.define_singleton_method(api_name) { api }
|
66
|
+
@apis[api_name] = api
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def credentials=(cs)
|
72
|
+
|
73
|
+
apis.each { |_, api| api.credentials = cs }
|
74
|
+
end
|
75
|
+
|
76
|
+
protected
|
77
|
+
|
78
|
+
def split_versions(vs)
|
79
|
+
|
80
|
+
case vs
|
81
|
+
when Array
|
82
|
+
vs
|
83
|
+
.select { |v| v.strip.length > 0 }
|
84
|
+
.collect { |v| split_version(v) }
|
85
|
+
when String
|
86
|
+
vs.split(/[,;]/)
|
87
|
+
.select { |v| v.strip.length > 0 }
|
88
|
+
.collect { |v| split_version(v) }
|
89
|
+
else
|
90
|
+
vs
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
def split_version(v)
|
95
|
+
|
96
|
+
v.is_a?(Array) ? v : v.split(':').collect(&:strip)
|
97
|
+
end
|
98
|
+
|
99
|
+
def version_match(version, pattern)
|
100
|
+
|
101
|
+
ves = version.split('.')
|
102
|
+
pattern.split('.').each do |pa|
|
103
|
+
ve = ves.shift
|
104
|
+
next if pa == 'x' || pa == '*'
|
105
|
+
return false if ve != pa
|
106
|
+
end
|
107
|
+
|
108
|
+
true
|
109
|
+
end
|
19
110
|
end
|
20
111
|
end
|
21
112
|
|
@@ -31,7 +122,13 @@ if $0 == __FILE__
|
|
31
122
|
#pp y['paths'].keys
|
32
123
|
|
33
124
|
#p Sentofu.company.class
|
34
|
-
p Sentofu.company.topic_search
|
125
|
+
#p Sentofu.company.topic_search
|
35
126
|
#pp Sentofu.markets
|
127
|
+
|
128
|
+
#puts Sentofu.company.paths.keys
|
129
|
+
|
130
|
+
t0 = Time.now
|
131
|
+
Sentofu.init
|
132
|
+
puts "took #{Time.now - t0}s..."
|
36
133
|
end
|
37
134
|
|
data/lib/sentofu/api.rb
CHANGED
@@ -3,6 +3,8 @@ module Sentofu
|
|
3
3
|
|
4
4
|
class Resource
|
5
5
|
|
6
|
+
TVP = '_sentofu_' # THREAD VARIABLE PREFIX
|
7
|
+
|
6
8
|
attr_reader :parent, :segment
|
7
9
|
|
8
10
|
def initialize(parent, segment)
|
@@ -14,20 +16,22 @@ module Sentofu
|
|
14
16
|
|
15
17
|
def add_segment(segment)
|
16
18
|
|
17
|
-
m = segment.match(/\A\{
|
19
|
+
m = segment.match(/\A\{[^}]+\}\z/)
|
18
20
|
mth = m ? :[] : segment
|
19
21
|
|
20
22
|
return @children[mth] if @children[mth]
|
21
23
|
|
24
|
+
res = @children[mth] = Sentofu::Resource.new(self, segment)
|
25
|
+
|
22
26
|
if mth == :[]
|
23
|
-
|
24
|
-
|
25
|
-
res
|
27
|
+
define_singleton_method(:[]) { |i|
|
28
|
+
Thread.current.thread_variable_set(TVP + segment, i); res }
|
26
29
|
else
|
27
|
-
|
28
|
-
|
29
|
-
res
|
30
|
+
define_singleton_method(mth) {
|
31
|
+
res }
|
30
32
|
end
|
33
|
+
|
34
|
+
res
|
31
35
|
end
|
32
36
|
|
33
37
|
def add_leaf_segment(segment, point)
|
@@ -45,49 +49,105 @@ module Sentofu
|
|
45
49
|
|
46
50
|
def fetch(segment, point, index, query)
|
47
51
|
|
48
|
-
p [ :fetch, segment, '(point)', index, query ]
|
49
|
-
p path(segment)
|
52
|
+
#p [ :fetch, segment, '(point)', index, query ]
|
53
|
+
#p path(segment)
|
50
54
|
#pp point
|
51
|
-
|
55
|
+
Thread.current.thread_variable_set(TVP + segment, index) if index
|
56
|
+
|
57
|
+
q = rectify_query_parameters(point, query)
|
58
|
+
|
59
|
+
pa = File.join(path, segment)
|
60
|
+
pa = pa.gsub(/_/, '-')
|
61
|
+
|
62
|
+
pa = pa + '?' + URI.encode_www_form(q) if q.any?
|
52
63
|
|
53
|
-
|
64
|
+
return query.merge(path: pa) if query[:debug]
|
54
65
|
|
55
|
-
|
66
|
+
JSON.parse(Sentofu::Http.get(pa, api.token).body)
|
56
67
|
end
|
57
68
|
|
58
|
-
def path
|
69
|
+
def path
|
59
70
|
|
60
|
-
|
61
|
-
|
71
|
+
seg =
|
72
|
+
segment[0, 1] == '{' ?
|
73
|
+
Thread.current.thread_variable_get(TVP + segment).to_s :
|
74
|
+
segment
|
62
75
|
|
63
76
|
if parent
|
64
|
-
File.join(parent.send(:path),
|
77
|
+
File.join(parent.send(:path), seg)
|
65
78
|
else
|
66
|
-
|
79
|
+
seg
|
67
80
|
end
|
68
81
|
end
|
69
82
|
|
70
|
-
def
|
83
|
+
def rectify_query_parameters(point, query)
|
84
|
+
|
85
|
+
q = query
|
86
|
+
.inject({}) { |h, (k, v)|
|
87
|
+
next h if k == :debug
|
88
|
+
h[k.to_s.gsub(/_/, '-')] =
|
89
|
+
case v
|
90
|
+
when Symbol then v.to_s
|
91
|
+
when Array then v.collect(&:to_s).join(',')
|
92
|
+
#when Time, Date then v.utc.strftime('%F')
|
93
|
+
when Time, Date then v.strftime('%F')
|
94
|
+
else v
|
95
|
+
end
|
96
|
+
h }
|
97
|
+
|
98
|
+
point['get']['parameters']
|
99
|
+
.each { |par|
|
100
|
+
|
101
|
+
next if par['in'] != 'query'
|
102
|
+
|
103
|
+
nam = par['name']
|
104
|
+
key = nam.gsub(/-/, '_')
|
105
|
+
|
106
|
+
fail ArgumentError.new(
|
107
|
+
"missing query parameter :#{key}"
|
108
|
+
) if par['required'] == true && !q.has_key?(nam)
|
109
|
+
|
110
|
+
v = q[nam]
|
111
|
+
typ = par['schema']['type']
|
112
|
+
|
113
|
+
fail ArgumentError.new(
|
114
|
+
"argument to :#{key} not an integer"
|
115
|
+
) if v && typ == 'integer' && ! v.is_a?(Integer)
|
116
|
+
fail ArgumentError.new(
|
117
|
+
"argument to :#{key} not a string (or a symbol)"
|
118
|
+
) if v && typ == 'string' && ! v.is_a?(String)
|
119
|
+
|
120
|
+
enu = par['schema']['enum']
|
121
|
+
|
122
|
+
fail ArgumentError.new(
|
123
|
+
"value #{v.inspect} for :#{key} not present in #{enu.inspect}"
|
124
|
+
) if v && enu && ! enu.include?(v) }
|
125
|
+
|
126
|
+
q
|
127
|
+
end
|
128
|
+
|
129
|
+
def api
|
71
130
|
|
72
|
-
|
73
|
-
.each { |pa|
|
74
|
-
k = (pa[:key] ||= pa['name'].gsub(/-/, '_').to_sym)
|
75
|
-
fail ArgumentError.new("missing query parameter #{k.inspect}") \
|
76
|
-
if pa['required'] == true && !query.has_key?(k) }
|
77
|
-
pp params
|
131
|
+
parent ? parent.api : self
|
78
132
|
end
|
79
133
|
end
|
80
134
|
|
81
135
|
class Api < Resource
|
82
136
|
|
83
137
|
attr_reader :spec
|
138
|
+
attr_accessor :credentials
|
84
139
|
|
85
|
-
def initialize(
|
140
|
+
def initialize(name, spec)
|
86
141
|
|
87
|
-
super(nil,
|
142
|
+
super(nil, spec['servers'].first['url'])
|
88
143
|
|
89
|
-
@spec = spec
|
90
144
|
@name = name
|
145
|
+
@spec = spec
|
146
|
+
|
147
|
+
@credentials = nil
|
148
|
+
@token = nil
|
149
|
+
|
150
|
+
inflate_parameters
|
91
151
|
|
92
152
|
#puts "================== #{name}"
|
93
153
|
spec['paths'].each do |path, point|
|
@@ -103,17 +163,71 @@ pp params
|
|
103
163
|
end
|
104
164
|
end
|
105
165
|
|
106
|
-
|
166
|
+
def token
|
107
167
|
|
108
|
-
|
168
|
+
if @token && @token.not_expired?
|
169
|
+
@token
|
170
|
+
else
|
171
|
+
@token = Sentofu::Http.fetch_token(@credentials)
|
172
|
+
end
|
173
|
+
end
|
174
|
+
|
175
|
+
def paths
|
176
|
+
|
177
|
+
@spec['paths']
|
178
|
+
end
|
179
|
+
|
180
|
+
def query(path, params={})
|
181
|
+
|
182
|
+
pa = server + path
|
183
|
+
pa = pa + '?' + URI.encode_www_form(params) if params.any?
|
184
|
+
|
185
|
+
return params.merge(path: pa) if params[:debug]
|
186
|
+
|
187
|
+
JSON.parse(Sentofu::Http.get(pa, token).body)
|
188
|
+
end
|
109
189
|
|
110
|
-
|
111
|
-
api_spec['info']['title'].split(' - ').last[0..-4].strip
|
112
|
-
.gsub(/([a-z])([A-Z])/) { |_| $1 + '_' + $2.downcase }
|
113
|
-
.gsub(/([A-Z])/) { |c| c.downcase }
|
190
|
+
def modified
|
114
191
|
|
115
|
-
|
116
|
-
|
192
|
+
Time.parse(
|
193
|
+
@spec[:meta]['properties']
|
194
|
+
.find { |pr| pr['type'] == 'X-Modified' }['value'])
|
195
|
+
end
|
196
|
+
|
197
|
+
def version
|
198
|
+
|
199
|
+
@spec[:meta]['properties']
|
200
|
+
.find { |pr| pr['type'] == 'X-Version' }['value']
|
201
|
+
end
|
202
|
+
|
203
|
+
protected
|
204
|
+
|
205
|
+
def server
|
206
|
+
|
207
|
+
@spec['servers'].first['url']
|
208
|
+
end
|
209
|
+
|
210
|
+
def inflate_parameters
|
211
|
+
|
212
|
+
pars = @spec['components']['parameters']
|
213
|
+
return unless pars
|
214
|
+
|
215
|
+
refs = pars
|
216
|
+
.inject({}) { |h, (k, v)|
|
217
|
+
h["#/components/parameters/#{k}"] = v
|
218
|
+
h }
|
219
|
+
|
220
|
+
@spec['paths'].each do |_, pa|
|
221
|
+
pa.each do |_, me|
|
222
|
+
next unless me['parameters']
|
223
|
+
me['parameters'] = me['parameters']
|
224
|
+
.collect { |pm|
|
225
|
+
if ref = pm['$ref']
|
226
|
+
refs[ref] || fail("found no $ref #{ref.inspect} in spec")
|
227
|
+
else
|
228
|
+
pm
|
229
|
+
end }
|
230
|
+
end
|
117
231
|
end
|
118
232
|
end
|
119
233
|
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
|
2
|
+
module Sentofu
|
3
|
+
|
4
|
+
class << self
|
5
|
+
|
6
|
+
def list_apis
|
7
|
+
|
8
|
+
detail_apis['apis']
|
9
|
+
.collect { |meta|
|
10
|
+
|
11
|
+
ps = meta['properties']
|
12
|
+
|
13
|
+
u = ps.find { |p| p['type'] == 'Swagger' }['url']
|
14
|
+
v = ps.find { |p| p['type'] == 'X-Version' }['value']
|
15
|
+
|
16
|
+
m = u.match(/intelligence_([^_]+)_api/)
|
17
|
+
n = m ? m[1] : meta['name']
|
18
|
+
|
19
|
+
d = meta['description']
|
20
|
+
|
21
|
+
{ 'n' => n, 'v' => v, 'd' => d, 'u' => u } }
|
22
|
+
end
|
23
|
+
|
24
|
+
def detail_apis
|
25
|
+
|
26
|
+
Sentofu::Http.get_and_parse(
|
27
|
+
'https://api.swaggerhub.com/apis/sentifi-api-docs/')
|
28
|
+
end
|
29
|
+
|
30
|
+
#- curl https://api.swaggerhub.com/apis/sentifi-api-docs/sentifi-intelligence_company_api/1.0.0/swagger.yaml > api_company.yaml
|
31
|
+
def dump_apis
|
32
|
+
|
33
|
+
puts
|
34
|
+
|
35
|
+
list_apis.each do |h|
|
36
|
+
n = h['n']; n = "auth" if n.match(/auth/i)
|
37
|
+
fn = "api_#{n}_#{h['v']}.yaml"
|
38
|
+
res = Sentofu::Http.get(h['u'] + '/swagger.yaml')
|
39
|
+
File.open(fn, 'wb') { |f| f.write(res.body) }
|
40
|
+
puts " wrote #{fn}"
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
#protected
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
data/lib/sentofu/http.rb
CHANGED
@@ -3,39 +3,71 @@ module Sentofu
|
|
3
3
|
|
4
4
|
module Http
|
5
5
|
|
6
|
-
HOST = 'apis.sentifi.com'
|
7
|
-
|
8
6
|
class << self
|
9
7
|
|
10
8
|
def fetch_token(credentials=nil)
|
11
9
|
|
10
|
+
u = URI(Sentofu.auth_uri)
|
11
|
+
|
12
12
|
cs = narrow_credentials(credentials)
|
13
13
|
|
14
14
|
a = Base64.encode64("#{cs.id}:#{cs.secret}").strip
|
15
15
|
#p a
|
16
16
|
|
17
|
-
req = Net::HTTP::Post.new(
|
17
|
+
req = Net::HTTP::Post.new(u.path)
|
18
18
|
req.add_field('Content-Type', 'application/json')
|
19
19
|
req.add_field('Authorization', a)
|
20
20
|
|
21
21
|
req.body = JSON.dump(
|
22
22
|
grant_type: 'password', username: cs.user, password: cs.pass)
|
23
23
|
|
24
|
-
res = request(req)
|
25
|
-
|
24
|
+
res = make_http(u).request(req)
|
25
|
+
|
26
|
+
Sentofu::Token.new(res)
|
27
|
+
end
|
28
|
+
|
29
|
+
def get(uri, token=nil)
|
30
|
+
|
31
|
+
u = URI(uri)
|
32
|
+
|
33
|
+
#t0 = Time.now
|
34
|
+
res = make_http(u).request(make_get_req(u, token))
|
35
|
+
#def res.headers; r = {}; each_header { |k, v| r[k] = v }; r; end
|
36
|
+
#puts "*** GET #{uri} took #{Time.now - t0}s"
|
26
37
|
|
27
|
-
|
38
|
+
res
|
39
|
+
end
|
40
|
+
|
41
|
+
def get_and_parse(uri, token=nil)
|
42
|
+
|
43
|
+
JSON.parse(get(uri, token).body)
|
28
44
|
end
|
29
45
|
|
30
46
|
protected
|
31
47
|
|
32
|
-
def
|
48
|
+
def make_http(uri)
|
33
49
|
|
34
|
-
|
35
|
-
t
|
36
|
-
|
50
|
+
#p uri.to_s
|
51
|
+
t = Net::HTTP.new(uri.host, uri.port)
|
52
|
+
t.use_ssl = (uri.scheme == 'https')
|
53
|
+
#t.set_debug_output($stdout) if uri.to_s.match(/search/)
|
37
54
|
|
38
|
-
t
|
55
|
+
t
|
56
|
+
end
|
57
|
+
|
58
|
+
def make_get_req(uri, token)
|
59
|
+
|
60
|
+
req = Net::HTTP::Get.new(uri.to_s)
|
61
|
+
req.instance_eval { @header.clear }
|
62
|
+
def req.set_header(k, v); @header[k] = [ v ]; end
|
63
|
+
|
64
|
+
req.set_header('User-Agent', Sentofu.user_agent)
|
65
|
+
req.set_header('Accept', 'application/json')
|
66
|
+
|
67
|
+
req.set_header('Authorization', token.header_value) if token
|
68
|
+
#pp req.instance_variable_get(:@header)
|
69
|
+
|
70
|
+
req
|
39
71
|
end
|
40
72
|
|
41
73
|
def narrow_credentials(o)
|
@@ -55,5 +87,24 @@ module Sentofu
|
|
55
87
|
end
|
56
88
|
end
|
57
89
|
end
|
90
|
+
|
91
|
+
class Token
|
92
|
+
|
93
|
+
def initialize(res)
|
94
|
+
|
95
|
+
@h = JSON.parse(res.body)
|
96
|
+
@expires_at = Time.now + @h['expires_in']
|
97
|
+
end
|
98
|
+
|
99
|
+
def not_expired?
|
100
|
+
|
101
|
+
Time.now < @expires_at
|
102
|
+
end
|
103
|
+
|
104
|
+
def header_value
|
105
|
+
|
106
|
+
'Bearer ' + @h['access_token']
|
107
|
+
end
|
108
|
+
end
|
58
109
|
end
|
59
110
|
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: sentofu
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0
|
4
|
+
version: 0.1.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- John Mettraux
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2019-05-
|
11
|
+
date: 2019-05-23 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rspec
|
@@ -37,6 +37,7 @@ files:
|
|
37
37
|
- README.md
|
38
38
|
- lib/sentofu.rb
|
39
39
|
- lib/sentofu/api.rb
|
40
|
+
- lib/sentofu/explo.rb
|
40
41
|
- lib/sentofu/http.rb
|
41
42
|
- sentofu.gemspec
|
42
43
|
homepage: http://github.com/jmettraux/sentofu
|