cf-uaac 1.3.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +8 -0
- data/Gemfile +16 -0
- data/README.md +48 -0
- data/Rakefile +50 -0
- data/bin/completion-helper +80 -0
- data/bin/uaac +5 -0
- data/bin/uaac-completion.sh +34 -0
- data/bin/uaas +7 -0
- data/cf-uaac.gemspec +48 -0
- data/lib/cli.rb +15 -0
- data/lib/cli/base.rb +277 -0
- data/lib/cli/client_reg.rb +103 -0
- data/lib/cli/common.rb +187 -0
- data/lib/cli/config.rb +163 -0
- data/lib/cli/favicon.ico +0 -0
- data/lib/cli/group.rb +85 -0
- data/lib/cli/info.rb +54 -0
- data/lib/cli/runner.rb +52 -0
- data/lib/cli/token.rb +217 -0
- data/lib/cli/user.rb +108 -0
- data/lib/cli/version.rb +18 -0
- data/lib/stub/scim.rb +387 -0
- data/lib/stub/server.rb +310 -0
- data/lib/stub/uaa.rb +485 -0
- data/spec/client_reg_spec.rb +104 -0
- data/spec/common_spec.rb +89 -0
- data/spec/group_spec.rb +93 -0
- data/spec/http_spec.rb +165 -0
- data/spec/info_spec.rb +74 -0
- data/spec/spec_helper.rb +87 -0
- data/spec/token_spec.rb +119 -0
- data/spec/user_spec.rb +61 -0
- metadata +292 -0
data/lib/cli/version.rb
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
#--
|
2
|
+
# Cloud Foundry 2012.02.03 Beta
|
3
|
+
# Copyright (c) [2009-2012] VMware, Inc. All Rights Reserved.
|
4
|
+
#
|
5
|
+
# This product is licensed to you under the Apache License, Version 2.0 (the "License").
|
6
|
+
# You may not use this product except in compliance with the License.
|
7
|
+
#
|
8
|
+
# This product includes a number of subcomponents with
|
9
|
+
# separate copyright notices and license terms. Your use of these
|
10
|
+
# subcomponents is subject to the terms and conditions of the
|
11
|
+
# subcomponent's license, as noted in the LICENSE file.
|
12
|
+
#++
|
13
|
+
|
14
|
+
module CF
|
15
|
+
module UAA
|
16
|
+
CLI_VERSION = "1.3.0"
|
17
|
+
end
|
18
|
+
end
|
data/lib/stub/scim.rb
ADDED
@@ -0,0 +1,387 @@
|
|
1
|
+
#--
|
2
|
+
# Cloud Foundry 2012.02.03 Beta
|
3
|
+
# Copyright (c) [2009-2012] VMware, Inc. All Rights Reserved.
|
4
|
+
#
|
5
|
+
# This product is licensed to you under the Apache License, Version 2.0 (the "License").
|
6
|
+
# You may not use this product except in compliance with the License.
|
7
|
+
#
|
8
|
+
# This product includes a number of subcomponents with
|
9
|
+
# separate copyright notices and license terms. Your use of these
|
10
|
+
# subcomponents is subject to the terms and conditions of the
|
11
|
+
# subcomponent's license, as noted in the LICENSE file.
|
12
|
+
#++
|
13
|
+
|
14
|
+
require 'set'
|
15
|
+
require 'time'
|
16
|
+
require 'uaa/util'
|
17
|
+
|
18
|
+
module CF::UAA
|
19
|
+
|
20
|
+
class SchemaViolation < RuntimeError; end
|
21
|
+
class AlreadyExists < RuntimeError; end
|
22
|
+
class BadFilter < RuntimeError; end
|
23
|
+
class BadVersion < RuntimeError; end
|
24
|
+
|
25
|
+
class StubScim
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
# attribute types. Anything not listed is case-ignore string
|
30
|
+
HIDDEN_ATTRS = [:rtype, :password, :client_secret].to_set
|
31
|
+
READ_ONLY_ATTRS = [:rtype, :id, :meta, :groups].to_set
|
32
|
+
BOOLEANS = [:active].to_set
|
33
|
+
NUMBERS = [:access_token_validity, :refresh_token_validity].to_set
|
34
|
+
GROUPS = [:groups, :auto_approved_scope, :scope, :authorities].to_set
|
35
|
+
REFERENCES = [*GROUPS, :members, :owners, :readers].to_set # users or groups
|
36
|
+
ENUMS = { authorized_grant_types: ["client_credentials", "implicit",
|
37
|
+
"authorization_code", "password", "refresh_token"].to_set }
|
38
|
+
GENERAL_MULTI = [:emails, :phonenumbers, :ims, :photos, :entitlements,
|
39
|
+
:roles, :x509certificates].to_set
|
40
|
+
GENERAL_SUBATTRS = [:value, :display, :primary, :type].to_set
|
41
|
+
EXPLICIT_SINGLE = {
|
42
|
+
name: [:formatted, :familyname, :givenname, :middlename,
|
43
|
+
:honorificprefix, :honorificsuffix].to_set,
|
44
|
+
meta: [:created, :lastmodified, :location, :version].to_set }
|
45
|
+
EXPLICIT_MULTI = {
|
46
|
+
addresses: [:formatted, :streetaddress, :locality, :region,
|
47
|
+
:postal_code, :country, :primary, :type].to_set,
|
48
|
+
authorizations: [:client_id, :approved, :denied, :exp].to_set }
|
49
|
+
|
50
|
+
# resource class definitions: naming and legal attributes
|
51
|
+
NAME_ATTR = { user: :username, client: :client_id, group: :displayname }
|
52
|
+
COMMON_ATTRS = [:rtype, :externalid, :id, :meta].to_set
|
53
|
+
LEGAL_ATTRS = {
|
54
|
+
user: [*COMMON_ATTRS, :displayname, :username, :nickname,
|
55
|
+
:profileurl, :title, :usertype, :preferredlanguage, :locale,
|
56
|
+
:timezone, :active, :password, :emails, :phonenumbers, :ims, :photos,
|
57
|
+
:entitlements, :roles, :x509certificates, :name, :addresses,
|
58
|
+
:authorizations, :groups].to_set,
|
59
|
+
client: [*COMMON_ATTRS, :client_id, :client_secret, :authorities,
|
60
|
+
:authorized_grant_types, :scope, :auto_approved_scope,
|
61
|
+
:access_token_validity, :refresh_token_validity, :redirect_uri].to_set,
|
62
|
+
group: [*COMMON_ATTRS, :displayname, :members, :owners, :readers].to_set }
|
63
|
+
VISIBLE_ATTRS = {user: Set.new(LEGAL_ATTRS[:user] - HIDDEN_ATTRS),
|
64
|
+
client: Set.new(LEGAL_ATTRS[:client] - HIDDEN_ATTRS),
|
65
|
+
group: Set.new(LEGAL_ATTRS[:group] - HIDDEN_ATTRS)}
|
66
|
+
ATTR_NAMES = LEGAL_ATTRS.each_with_object(Set.new) { |(k, v), s|
|
67
|
+
v.each {|a| s << a.to_s }
|
68
|
+
}
|
69
|
+
SUBATTR_NAMES = GENERAL_SUBATTRS.each_with_object(Set.new) { |sa, s| s << sa.to_s } +
|
70
|
+
[*EXPLICIT_SINGLE, *EXPLICIT_MULTI].each_with_object(Set.new) { |(k, v), s|
|
71
|
+
v.each {|sa| s << sa.to_s }
|
72
|
+
}
|
73
|
+
|
74
|
+
def self.remove_hidden(attrs = nil) attrs - HIDDEN_ATTRS if attrs end
|
75
|
+
def self.searchable_attribute(attr)
|
76
|
+
attr if ATTR_NAMES.include?(attr) && !HIDDEN_ATTRS.include?(attr = attr.to_sym)
|
77
|
+
end
|
78
|
+
|
79
|
+
def remove_attrs(stuff, attrs = HIDDEN_ATTRS)
|
80
|
+
attrs.each { |a| stuff.delete(a.to_s) }
|
81
|
+
stuff
|
82
|
+
end
|
83
|
+
|
84
|
+
def valid_id?(id, rtype)
|
85
|
+
id && (t = @things_by_id[id]) && (rtype.nil? || t[:rtype] == rtype)
|
86
|
+
end
|
87
|
+
|
88
|
+
def ref_by_name(name, rtype) @things_by_name[rtype.to_s + name.downcase] end
|
89
|
+
|
90
|
+
def ref_by_id(id, rtype = nil)
|
91
|
+
(t = @things_by_id[id]) && (rtype.nil? || t[:rtype] == rtype) ? t : nil
|
92
|
+
end
|
93
|
+
|
94
|
+
def valid_complex?(value, subattrs, simple_ok = false)
|
95
|
+
return true if simple_ok && value.is_a?(String)
|
96
|
+
return unless value.is_a?(Hash) && (!simple_ok || value.key?("value"))
|
97
|
+
value.each { |k, v| return unless SUBATTR_NAMES.include?(k) && subattrs.include?(k.to_sym) }
|
98
|
+
end
|
99
|
+
|
100
|
+
def valid_multi?(values, subattrs, simple_ok = false)
|
101
|
+
return unless values.is_a?(Array)
|
102
|
+
values.each { |value| return unless valid_complex?(value, subattrs, simple_ok) }
|
103
|
+
end
|
104
|
+
|
105
|
+
def valid_ids?(value, rtype = nil)
|
106
|
+
return unless value.is_a?(Array)
|
107
|
+
value.each do |ref|
|
108
|
+
return unless ref.is_a?(String) && valid_id?(ref, rtype) ||
|
109
|
+
ref.is_a?(Hash) && valid_id?(ref["value"], rtype)
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
def enforce_schema(rtype, stuff)
|
114
|
+
stuff.each do |ks, v|
|
115
|
+
unless ATTR_NAMES.include?(ks.to_s) && LEGAL_ATTRS[rtype].include?(k = ks.to_sym)
|
116
|
+
raise SchemaViolation, "illegal #{ks} on #{rtype}"
|
117
|
+
end
|
118
|
+
if READ_ONLY_ATTRS.include?(k)
|
119
|
+
raise SchemaViolation, "attempt to modify read-only attribute #{k} on #{rtype}"
|
120
|
+
end
|
121
|
+
valid_attr = case k
|
122
|
+
when *BOOLEANS then v == !!v
|
123
|
+
when *NUMBERS then v.is_a?(Integer)
|
124
|
+
when *GENERAL_MULTI then valid_multi?(v, GENERAL_SUBATTRS, true)
|
125
|
+
when *GROUPS then valid_ids?(v, :group)
|
126
|
+
when *REFERENCES then valid_ids?(v)
|
127
|
+
when ENUMS[k] then ENUMS[k].include?(v)
|
128
|
+
when *EXPLICIT_SINGLE.keys then valid_complex?(v, EXPLICIT_SINGLE[k])
|
129
|
+
when *EXPLICIT_MULTI.keys then valid_multi?(v, EXPLICIT_MULTI[k])
|
130
|
+
else k.is_a?(String) || k.is_a?(Symbol)
|
131
|
+
end
|
132
|
+
raise SchemaViolation, "#{v} is an invalid #{k}" unless valid_attr
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
def input(stuff)
|
137
|
+
thing = Util.hash_keys(stuff.dup, :tosym)
|
138
|
+
REFERENCES.each {|a|
|
139
|
+
next unless thing[a]
|
140
|
+
thing[a] = thing[a].each_with_object(Set.new) { |r, s|
|
141
|
+
s << (r.is_a?(Hash)? r[:value] : r )
|
142
|
+
}
|
143
|
+
}
|
144
|
+
GENERAL_MULTI.each {|a|
|
145
|
+
next unless thing[a]
|
146
|
+
thing[a] = thing[a].each_with_object({}) { |v, o|
|
147
|
+
v = {value: v} unless v.is_a?(Hash)
|
148
|
+
# enforce values are unique by type and value
|
149
|
+
k = URI.encode_www_form(t: [v[:type], v: v[:value]]).downcase
|
150
|
+
o[k] = v
|
151
|
+
}
|
152
|
+
}
|
153
|
+
thing
|
154
|
+
end
|
155
|
+
|
156
|
+
def output(thing, attrs = nil)
|
157
|
+
attrs = thing.keys if attrs.nil? || attrs.empty?
|
158
|
+
attrs.each_with_object({}) {|a, o|
|
159
|
+
next unless thing[a]
|
160
|
+
case a
|
161
|
+
when *REFERENCES then o[a] = thing[a].to_a
|
162
|
+
when *GENERAL_MULTI then o[a] = thing[a].values
|
163
|
+
else o[a] = thing[a]
|
164
|
+
end
|
165
|
+
}
|
166
|
+
end
|
167
|
+
|
168
|
+
def add_user_groups(gid, members)
|
169
|
+
members.each {|m| (m[:groups] ||= Set.new) << gid if m = ref_by_id(m, :user)} if members
|
170
|
+
end
|
171
|
+
|
172
|
+
def remove_user_groups(gid, members)
|
173
|
+
members.each {|m| m[:groups].delete(gid) if m = ref_by_id(m, :user) } if members
|
174
|
+
end
|
175
|
+
|
176
|
+
public
|
177
|
+
|
178
|
+
def initialize; @things_by_id, @things_by_name = {}, {} end
|
179
|
+
def name(id, rtype = nil) (t = ref_by_id(id, rtype))? t[NAME_ATTR[t[:rtype]]]: nil end
|
180
|
+
def id(name, rtype) (t = ref_by_name(name, rtype))? t[:id] : nil end
|
181
|
+
|
182
|
+
def add(rtype, stuff)
|
183
|
+
unless stuff.is_a?(Hash) && (name = stuff[NAME_ATTR[rtype].to_s])
|
184
|
+
raise SchemaViolation, "new #{rtype} has no name #{NAME_ATTR[rtype]}"
|
185
|
+
end
|
186
|
+
raise AlreadyExists if @things_by_name.key?(name = rtype.to_s + name.downcase)
|
187
|
+
enforce_schema(rtype, stuff)
|
188
|
+
thing = input(stuff).merge!(rtype: rtype, id: (id = SecureRandom.uuid),
|
189
|
+
meta: { created: Time.now.iso8601, last_modified: Time.now.iso8601, version: 1 })
|
190
|
+
add_user_groups(id, thing[:members])
|
191
|
+
@things_by_id[id] = @things_by_name[name] = thing
|
192
|
+
id
|
193
|
+
end
|
194
|
+
|
195
|
+
def update(id, stuff, match_version = nil, match_type = nil)
|
196
|
+
raise NotFound unless thing = ref_by_id(id, match_type)
|
197
|
+
raise BadVersion if match_version && match_version != thing[:meta][:version]
|
198
|
+
enforce_schema(rtype = thing[:rtype], remove_attrs(stuff, READ_ONLY_ATTRS))
|
199
|
+
new_thing = input(stuff)
|
200
|
+
if newname = new_thing[NAME_ATTR[rtype]]
|
201
|
+
oldname = rtype.to_s + thing[NAME_ATTR[rtype]].downcase
|
202
|
+
unless (newname = rtype.to_s + newname.downcase) == oldname
|
203
|
+
raise AlreadyExists if @things_by_name.key?(newname)
|
204
|
+
@things_by_name.delete(oldname)
|
205
|
+
@things_by_name[newname] = thing
|
206
|
+
end
|
207
|
+
end
|
208
|
+
if new_thing[:members] || thing[:members]
|
209
|
+
old_members = thing[:members] || Set.new
|
210
|
+
new_members = new_thing[:members] || Set.new
|
211
|
+
remove_user_groups(id, old_members - new_members)
|
212
|
+
add_user_groups(id, new_members - old_members)
|
213
|
+
end
|
214
|
+
READ_ONLY_ATTRS.each { |a| new_thing[a] = thing[a] if thing[a] }
|
215
|
+
HIDDEN_ATTRS.each { |a| new_thing[a] = thing[a] if thing[a] }
|
216
|
+
thing.replace new_thing
|
217
|
+
thing[:meta][:version] += 1
|
218
|
+
thing[:meta][:lastmodified] == Time.now.iso8601
|
219
|
+
id
|
220
|
+
end
|
221
|
+
|
222
|
+
def add_member(gid, member)
|
223
|
+
return unless g = ref_by_id(gid, :group)
|
224
|
+
(g[:members] ||= Set.new) << member
|
225
|
+
add_user_groups(gid, Set[member])
|
226
|
+
end
|
227
|
+
|
228
|
+
def set_hidden_attr(id, attr, value)
|
229
|
+
raise NotFound unless thing = ref_by_id(id)
|
230
|
+
raise ArgumentError unless HIDDEN_ATTRS.include?(attr)
|
231
|
+
thing[attr] = value
|
232
|
+
end
|
233
|
+
|
234
|
+
def remove(id, rtype = nil)
|
235
|
+
return unless thing = ref_by_id(id, rtype)
|
236
|
+
rtype = thing[:rtype]
|
237
|
+
remove_user_groups(id, thing[:members])
|
238
|
+
@things_by_id.delete(id)
|
239
|
+
thing = @things_by_name.delete(rtype.to_s + thing[NAME_ATTR[rtype]].downcase)
|
240
|
+
remove_attrs(output(thing))
|
241
|
+
end
|
242
|
+
|
243
|
+
def get(id, rtype = nil, *attrs)
|
244
|
+
return unless thing = ref_by_id(id, rtype)
|
245
|
+
output(thing, attrs)
|
246
|
+
end
|
247
|
+
|
248
|
+
def get_by_name(name, rtype, *attrs)
|
249
|
+
return unless thing = ref_by_name(name, rtype)
|
250
|
+
output(thing, attrs)
|
251
|
+
end
|
252
|
+
|
253
|
+
def find(rtype, start = 0, count = nil, filter_string = nil, attrs = nil)
|
254
|
+
filter, total = ScimFilter.new(filter_string), 0
|
255
|
+
objs = @things_by_id.each_with_object([]) { |(k, v), o|
|
256
|
+
next unless rtype == v[:rtype] && filter.match?(v)
|
257
|
+
o << output(v, attrs) if total >= start && (count.nil? || o.length < count)
|
258
|
+
total += 1
|
259
|
+
}
|
260
|
+
[objs, total]
|
261
|
+
end
|
262
|
+
|
263
|
+
end
|
264
|
+
|
265
|
+
class ScimFilter
|
266
|
+
|
267
|
+
private
|
268
|
+
|
269
|
+
def eat_json_string
|
270
|
+
raise BadFilter unless @input.skip(/\s*"/)
|
271
|
+
str = ""
|
272
|
+
while true
|
273
|
+
case
|
274
|
+
when @input.scan(/[^\\"]+/); str << @input.matched
|
275
|
+
when @input.scan(%r{\\["\\/]}); str << @input.matched[-1]
|
276
|
+
when @input.scan(/\\[bfnrt]/); str << eval(%Q{"#{@input.matched}"})
|
277
|
+
when @input.scan(/\\u[0-9a-fA-F]{4}/); str << [Integer("0x#{@input.matched[2..-1]}")].pack("U")
|
278
|
+
else break
|
279
|
+
end
|
280
|
+
end
|
281
|
+
raise BadFilter unless @input.skip(/"\s*/)
|
282
|
+
str
|
283
|
+
end
|
284
|
+
|
285
|
+
def eat_word(*words)
|
286
|
+
@input.skip(/\s*/)
|
287
|
+
return unless s = @input.scan(/(\S+)\s*/)
|
288
|
+
w = @input[1].downcase
|
289
|
+
return w if words.empty? || words.include?(w)
|
290
|
+
@input.unscan
|
291
|
+
false
|
292
|
+
end
|
293
|
+
|
294
|
+
def eat_expr
|
295
|
+
if @input.skip(/\s*\(\s*/)
|
296
|
+
phrase = eat_phrase
|
297
|
+
raise BadFilter unless @input.skip(/\s*\)\s*/)
|
298
|
+
return phrase
|
299
|
+
end
|
300
|
+
raise BadFilter unless (attr = eat_word) &&
|
301
|
+
(op = eat_word("eq", "co", "sw", "pr", "gt", "ge", "lt", "le")) &&
|
302
|
+
(op == "pr" || value = eat_json_string)
|
303
|
+
(attr_sym = StubScim.searchable_attribute(attr)) ?
|
304
|
+
[:item, attr_sym, op, value] : [:undefined, attr, op, value]
|
305
|
+
end
|
306
|
+
|
307
|
+
# AND level
|
308
|
+
def eat_subphrase
|
309
|
+
phrase = [:and, eat_expr]
|
310
|
+
while eat_word("and"); phrase << eat_expr end
|
311
|
+
phrase.length == 2 ? phrase[1] : phrase
|
312
|
+
end
|
313
|
+
|
314
|
+
# OR level
|
315
|
+
def eat_phrase
|
316
|
+
phrase = [:or, eat_subphrase]
|
317
|
+
while eat_word("or"); phrase << eat_subphrase end
|
318
|
+
phrase.length == 2 ? phrase[1] : phrase
|
319
|
+
end
|
320
|
+
|
321
|
+
def eval_expr(entry, attr, op, value)
|
322
|
+
return false unless val = entry[attr]
|
323
|
+
return true if op == "pr"
|
324
|
+
case attr
|
325
|
+
when *StubScim::REFERENCES
|
326
|
+
return nil unless op == "eq"
|
327
|
+
val.each {|v| return true if v.casecmp(value) == 0 }
|
328
|
+
false
|
329
|
+
when *StubScim::GENERAL_MULTI
|
330
|
+
return nil unless op == "eq"
|
331
|
+
val.each {|k, v| return true if v.casecmp(value) == 0 }
|
332
|
+
false
|
333
|
+
else
|
334
|
+
case op
|
335
|
+
when "eq"; val.casecmp(value) == 0
|
336
|
+
when "sw"; val =~ /^#{Regexp.escape(value)}/i
|
337
|
+
when "co"; val =~ /#{Regexp.escape(value)}/i
|
338
|
+
when "gt"; val.casecmp(value) > 0
|
339
|
+
when "ge"; val.casecmp(value) >= 0
|
340
|
+
when "lt"; val.casecmp(value) < 0
|
341
|
+
when "le"; val.casecmp(value) <= 0
|
342
|
+
end
|
343
|
+
end
|
344
|
+
end
|
345
|
+
|
346
|
+
def eval(entry, filtr)
|
347
|
+
undefd = 0
|
348
|
+
case filtr[0]
|
349
|
+
when :undefined ; nil
|
350
|
+
when :item ; eval_expr(entry, filtr[1], filtr[2], filtr[3])
|
351
|
+
when :or
|
352
|
+
filtr[1..-1].each { |f|
|
353
|
+
return true if (res = eval(entry, f)) == true
|
354
|
+
undefd += 1 if res.nil?
|
355
|
+
}
|
356
|
+
filtr.length == undefd + 1 ? nil: false
|
357
|
+
when :and
|
358
|
+
filtr[1..-1].each { |f|
|
359
|
+
return false if (res = eval(entry, f)) == false
|
360
|
+
undefd += 1 if res.nil?
|
361
|
+
}
|
362
|
+
filtr.length == undefd + 1 ? nil: true
|
363
|
+
end
|
364
|
+
end
|
365
|
+
|
366
|
+
public
|
367
|
+
|
368
|
+
def initialize(filter_string)
|
369
|
+
if filter_string.nil?
|
370
|
+
@filter = true
|
371
|
+
else
|
372
|
+
@input = StringScanner.new(filter_string)
|
373
|
+
@filter = eat_phrase
|
374
|
+
raise BadFilter unless @input.eos?
|
375
|
+
end
|
376
|
+
self
|
377
|
+
rescue BadFilter => b
|
378
|
+
raise BadFilter, "invalid filter expression at offset #{@input.pos}: #{@input.string}"
|
379
|
+
end
|
380
|
+
|
381
|
+
def match?(entry)
|
382
|
+
@filter == true || eval(entry, @filter)
|
383
|
+
end
|
384
|
+
|
385
|
+
end
|
386
|
+
|
387
|
+
end
|
data/lib/stub/server.rb
ADDED
@@ -0,0 +1,310 @@
|
|
1
|
+
#--
|
2
|
+
# Cloud Foundry 2012.02.03 Beta
|
3
|
+
# Copyright (c) [2009-2012] VMware, Inc. All Rights Reserved.
|
4
|
+
#
|
5
|
+
# This product is licensed to you under the Apache License, Version 2.0 (the "License").
|
6
|
+
# You may not use this product except in compliance with the License.
|
7
|
+
#
|
8
|
+
# This product includes a number of subcomponents with
|
9
|
+
# separate copyright notices and license terms. Your use of these
|
10
|
+
# subcomponents is subject to the terms and conditions of the
|
11
|
+
# subcomponent's license, as noted in the LICENSE file.
|
12
|
+
#++
|
13
|
+
|
14
|
+
require 'eventmachine'
|
15
|
+
require 'date'
|
16
|
+
require 'logger'
|
17
|
+
require 'pp'
|
18
|
+
require 'erb'
|
19
|
+
require 'multi_json'
|
20
|
+
|
21
|
+
module Stub
|
22
|
+
|
23
|
+
class StubError < RuntimeError; end
|
24
|
+
class BadHeader < StubError; end
|
25
|
+
|
26
|
+
#------------------------------------------------------------------------------
|
27
|
+
class Request
|
28
|
+
|
29
|
+
attr_reader :headers, :body, :path, :method
|
30
|
+
def initialize; @state, @prelude = :init, "" end
|
31
|
+
|
32
|
+
private
|
33
|
+
|
34
|
+
def bslice(str, range)
|
35
|
+
# byteslice is available in ruby 1.9.3
|
36
|
+
str.respond_to?(:byteslice) ? str.byteslice(range) : str.slice(range)
|
37
|
+
end
|
38
|
+
|
39
|
+
def add_lines(str)
|
40
|
+
return @body << str if @state == :body
|
41
|
+
processed = 0
|
42
|
+
str.each_line("\r\n") do |ln|
|
43
|
+
processed += ln.bytesize
|
44
|
+
unless ln.chomp!("\r\n")
|
45
|
+
raise BadHeader unless ln.ascii_only?
|
46
|
+
return @prelude = ln # must be partial header at end of str
|
47
|
+
end
|
48
|
+
if @state == :init
|
49
|
+
start = ln.split(/\s+/)
|
50
|
+
@method, @path, @headers, @body = start[0].downcase, start[1], {}, ""
|
51
|
+
raise BadHeader unless @method.ascii_only? && @path.ascii_only?
|
52
|
+
@state = :headers
|
53
|
+
elsif ln.empty?
|
54
|
+
@state, @content_length = :body, headers["content-length"].to_i
|
55
|
+
return @body << bslice(str, processed..-1)
|
56
|
+
else
|
57
|
+
raise BadHeader unless ln.ascii_only?
|
58
|
+
key, sep, val = ln.partition(/:\s+/)
|
59
|
+
@headers[key.downcase] = val
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
public
|
65
|
+
|
66
|
+
# adds data to the request, returns true if request is complete
|
67
|
+
def completed?(str)
|
68
|
+
str, @prelude = @prelude + str, "" unless @prelude.empty?
|
69
|
+
add_lines(str)
|
70
|
+
return unless @state == :body && @body.bytesize >= @content_length
|
71
|
+
@prelude = bslice(@body, @content_length..-1)
|
72
|
+
@body = bslice(@body, 0..@content_length)
|
73
|
+
@state = :init
|
74
|
+
end
|
75
|
+
|
76
|
+
def cookies
|
77
|
+
return {} unless chdr = @headers["cookie"]
|
78
|
+
chdr.strip.split(/\s*;\s*/).each_with_object({}) do |pair, o|
|
79
|
+
k, v = pair.split(/\s*=\s*/)
|
80
|
+
o[k.downcase] = v
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
end
|
85
|
+
|
86
|
+
#------------------------------------------------------------------------------
|
87
|
+
class Reply
|
88
|
+
attr_accessor :status, :headers, :body
|
89
|
+
def initialize(status = 200) @status, @headers, @cookies, @body = status, {}, [], "" end
|
90
|
+
def to_s
|
91
|
+
reply = "HTTP/1.1 #{@status} OK\r\n"
|
92
|
+
headers["server"] = "stub server"
|
93
|
+
headers["date"] = DateTime.now.httpdate
|
94
|
+
headers["content-length"] = body.bytesize
|
95
|
+
headers.each { |k, v| reply << "#{k}: #{v}\r\n" }
|
96
|
+
@cookies.each { |c| reply << "Set-Cookie: #{c}\r\n" }
|
97
|
+
reply << "\r\n" << body
|
98
|
+
end
|
99
|
+
def json(status = nil, info)
|
100
|
+
info = {message: info} unless info.respond_to? :each
|
101
|
+
@status = status if status
|
102
|
+
headers["content-type"] = "application/json"
|
103
|
+
@body = MultiJson.dump(info)
|
104
|
+
nil
|
105
|
+
end
|
106
|
+
def text(status = nil, info)
|
107
|
+
@status = status if status
|
108
|
+
headers["content-type"] = "text/plain"
|
109
|
+
@body = info.pretty_inspect
|
110
|
+
nil
|
111
|
+
end
|
112
|
+
def html(status = nil, info)
|
113
|
+
@status = status if status
|
114
|
+
headers["content-type"] = "text/html"
|
115
|
+
info = ERB::Util.html_escape(info.pretty_inspect) unless info.is_a?(String)
|
116
|
+
@body = "<html><body>#{info}</body></html>"
|
117
|
+
nil
|
118
|
+
end
|
119
|
+
def set_cookie(name, value, options = {})
|
120
|
+
@cookies << options.each_with_object("#{name}=#{value}") { |(k, v), o|
|
121
|
+
o << (v.nil? ? "; #{k}" : "; #{k}=#{v}")
|
122
|
+
}
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
#------------------------------------------------------------------------------
|
127
|
+
# request handler logic -- server is initialized with a class derived from this.
|
128
|
+
# there will be one instance of this object per connection.
|
129
|
+
class Base
|
130
|
+
attr_accessor :request, :reply, :match, :server
|
131
|
+
|
132
|
+
def self.route(http_methods, matcher, filters = {}, &handler)
|
133
|
+
fail unless !EM.reactor_running? || EM.reactor_thread?
|
134
|
+
matcher = Regexp.new("^#{Regexp.escape(matcher.to_s)}$") unless matcher.is_a?(Regexp)
|
135
|
+
filters = filters.each_with_object({}) { |(k, v), o|
|
136
|
+
o[k.downcase] = v.is_a?(Regexp) ? v : Regexp.new("^#{Regexp.escape(v.to_s)}$")
|
137
|
+
}
|
138
|
+
@routes ||= {}
|
139
|
+
@route_number = @route_number.to_i + 1
|
140
|
+
route_name = "route_#{@route_number}".to_sym
|
141
|
+
define_method(route_name, handler)
|
142
|
+
[*http_methods].each do |m|
|
143
|
+
m = m.to_s.downcase
|
144
|
+
@routes[m] ||= []
|
145
|
+
i = @routes[m].index { |r| r[0].to_s.length < matcher.to_s.length }
|
146
|
+
unless i && @routes[m][i][0] == matcher
|
147
|
+
@routes[m].insert(i || -1, [matcher, filters, route_name])
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
151
|
+
|
152
|
+
def self.find_route(request)
|
153
|
+
fail unless EM.reactor_thread?
|
154
|
+
if @routes && (rary = @routes[request.method])
|
155
|
+
rary.each { |r; m|
|
156
|
+
next unless (m = r[0].match(request.path))
|
157
|
+
r[1].each { |k, v|
|
158
|
+
next if v.match(request.headers[k])
|
159
|
+
return reply_in_kind(400, "header '#{k}: #{request.headers[k]}' is not accepted")
|
160
|
+
}
|
161
|
+
return [m, r[2]]
|
162
|
+
}
|
163
|
+
end
|
164
|
+
[nil, :default_route]
|
165
|
+
end
|
166
|
+
|
167
|
+
def initialize(server)
|
168
|
+
@server, @request, @reply, @match = server, Request.new, Reply.new, nil
|
169
|
+
end
|
170
|
+
|
171
|
+
def process
|
172
|
+
@reply = Reply.new
|
173
|
+
@match, handler = self.class.find_route(request)
|
174
|
+
server.logger.debug "processing request to path #{request.path} for route #{@match ? @match.regexp : 'default'}"
|
175
|
+
send handler
|
176
|
+
reply.headers['connection'] ||= request.headers['connection'] if request.headers['connection']
|
177
|
+
server.logger.debug "replying to path #{request.path} with #{reply.body.length} bytes of #{reply.headers['content-type']}"
|
178
|
+
#server.logger.debug "full reply is: #{reply.body.inspect}"
|
179
|
+
rescue Exception => e
|
180
|
+
server.logger.debug "exception from route handler: #{e.message}"
|
181
|
+
server.trace { e.backtrace }
|
182
|
+
reply_in_kind 500, e
|
183
|
+
end
|
184
|
+
|
185
|
+
def reply_in_kind(status = nil, info)
|
186
|
+
case request.headers['accept']
|
187
|
+
when /application\/json/ then reply.json(status, info)
|
188
|
+
when /text\/html/ then reply.html(status, info)
|
189
|
+
else reply.text(status, info)
|
190
|
+
end
|
191
|
+
end
|
192
|
+
|
193
|
+
def default_route
|
194
|
+
reply_in_kind(404, error: "path not handled")
|
195
|
+
end
|
196
|
+
|
197
|
+
end
|
198
|
+
|
199
|
+
#------------------------------------------------------------------------------
|
200
|
+
module Connection
|
201
|
+
attr_accessor :req_handler
|
202
|
+
def unbind; req_handler.server.delete_connection(self) end
|
203
|
+
|
204
|
+
def receive_data(data)
|
205
|
+
#req_handler.server.logger.debug "got #{data.bytesize} bytes: #{data.inspect}"
|
206
|
+
return unless req_handler.request.completed? data
|
207
|
+
req_handler.process
|
208
|
+
send_data req_handler.reply.to_s
|
209
|
+
if req_handler.reply.headers['connection'] =~ /^close$/i || req_handler.server.status != :running
|
210
|
+
close_connection_after_writing
|
211
|
+
end
|
212
|
+
rescue Exception => e
|
213
|
+
req_handler.server.logger.debug "exception from receive_data: #{e.message}"
|
214
|
+
req_handler.server.trace { e.backtrace }
|
215
|
+
close_connection
|
216
|
+
end
|
217
|
+
end
|
218
|
+
|
219
|
+
#--------------------------------------------------------------------------
|
220
|
+
class Server
|
221
|
+
attr_reader :host, :port, :status, :logger
|
222
|
+
attr_accessor :info
|
223
|
+
def url; "http://#{@host}:#{@port}" end
|
224
|
+
def trace(msg = nil, &blk); logger.trace(msg, &blk) if logger.respond_to?(:trace) end
|
225
|
+
|
226
|
+
def initialize(req_handler, logger = Logger.new($stdout), info = nil)
|
227
|
+
@req_handler, @logger, @info = req_handler, logger, info
|
228
|
+
@connections, @status, @sig, @em_thread = [], :stopped, nil, nil
|
229
|
+
end
|
230
|
+
|
231
|
+
def start(hostname = "localhost", port = nil)
|
232
|
+
raise ArgumentError, "attempt to start a server that's already running" unless @status == :stopped
|
233
|
+
@host = hostname
|
234
|
+
logger.debug "starting #{self.class} server #{@host}"
|
235
|
+
EM.schedule do
|
236
|
+
@sig = EM.start_server(@host, port || 0, Connection) { |c| initialize_connection(c) }
|
237
|
+
@port = Socket.unpack_sockaddr_in(EM.get_sockname(@sig))[0]
|
238
|
+
logger.debug "#{self.class} server started at #{url}, signature #{@sig}"
|
239
|
+
end
|
240
|
+
@status = :running
|
241
|
+
self
|
242
|
+
end
|
243
|
+
|
244
|
+
def run_on_thread(hostname = "localhost", port = 0)
|
245
|
+
raise ArgumentError, "can't run on thread, EventMachine already running" if EM.reactor_running?
|
246
|
+
logger.debug { "starting eventmachine on thread" }
|
247
|
+
cthred = Thread.current
|
248
|
+
@em_thread = Thread.new do
|
249
|
+
begin
|
250
|
+
EM.run { start(hostname, port); cthred.run }
|
251
|
+
logger.debug "server thread done"
|
252
|
+
rescue Exception => e
|
253
|
+
logger.debug { "unhandled exception on stub server thread: #{e.message}" }
|
254
|
+
trace { e.backtrace }
|
255
|
+
raise
|
256
|
+
end
|
257
|
+
end
|
258
|
+
Thread.stop
|
259
|
+
logger.debug "running on thread"
|
260
|
+
self
|
261
|
+
end
|
262
|
+
|
263
|
+
def run(hostname = "localhost", port = 0)
|
264
|
+
raise ArgumentError, "can't run, EventMachine already running" if EM.reactor_running?
|
265
|
+
@em_thread = Thread.current
|
266
|
+
EM.run { start(hostname, port) }
|
267
|
+
logger.debug "server and event machine done"
|
268
|
+
end
|
269
|
+
|
270
|
+
# if on reactor thread, start shutting down but return if connections still
|
271
|
+
# in process, and let them disconnect when complete -- server is not really
|
272
|
+
# done until it's status is stopped.
|
273
|
+
# if not on reactor thread, wait until everything's cleaned up and stopped
|
274
|
+
def stop
|
275
|
+
logger.debug "stopping server"
|
276
|
+
@status = :stopping
|
277
|
+
EM.stop_server @sig
|
278
|
+
done if @connections.empty?
|
279
|
+
sleep 0.1 while @status != :stopped unless EM.reactor_thread?
|
280
|
+
end
|
281
|
+
|
282
|
+
def delete_connection(conn)
|
283
|
+
logger.debug "deleting connection"
|
284
|
+
fail unless EM.reactor_thread?
|
285
|
+
@connections.delete(conn)
|
286
|
+
done if @status != :running && @connections.empty?
|
287
|
+
end
|
288
|
+
|
289
|
+
private
|
290
|
+
|
291
|
+
def done
|
292
|
+
fail unless @connections.empty?
|
293
|
+
EM.stop if @em_thread && EM.reactor_running?
|
294
|
+
@connections, @status, @sig, @em_thread = [], :stopped, nil, nil
|
295
|
+
sleep 0.1 unless EM.reactor_thread? # give EM a chance to stop
|
296
|
+
logger.debug EM.reactor_running? ?
|
297
|
+
"server done but EM still running" : "server really done"
|
298
|
+
end
|
299
|
+
|
300
|
+
def initialize_connection(conn)
|
301
|
+
logger.debug "starting connection"
|
302
|
+
fail unless EM.reactor_thread?
|
303
|
+
@connections << conn
|
304
|
+
conn.req_handler = @req_handler.new(self)
|
305
|
+
conn.comm_inactivity_timeout = 30
|
306
|
+
end
|
307
|
+
|
308
|
+
end
|
309
|
+
|
310
|
+
end
|